diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 8a464a314151..94b1a554dc10 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -6,7 +6,6 @@ import { App } from "../../app" import { Credential } from "../../credential" import { Bus } from "../../bus" import { Integration } from "../../integration" -import { Model } from "../../model" import { OauthCallbackPage } from "../../oauth/page" import { Provider } from "../../provider" import type { PluginInternal } from "../internal" @@ -198,10 +197,10 @@ export const OpenAIPlugin = define({ if (!item) return item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL }) const account = chatgpt.metadata?.accountID - item.provider.headers = Provider.mergeHeaders( - item.provider.headers, - typeof account === "string" ? { "chatgpt-account-id": account } : undefined, - ) + item.provider.headers = Provider.mergeHeaders(item.provider.headers, { + originator: "opencode", + ...(typeof account === "string" ? { "chatgpt-account-id": account } : {}), + }) for (const model of item.models.values()) { // ChatGPT-plan tokens only authorize codex-eligible models, and the // subscription covers usage, so hide the rest and zero the cost. @@ -225,17 +224,6 @@ export const OpenAIPlugin = define({ }) } }) - yield* ctx.session.hook("http", (evt) => - evt.use((request, next) => { - if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return next(request) - const url = new URL(request.url) - request.headers.set("originator", "opencode") - request.headers.set("session-id", evt.sessionID) - if (url.origin !== "https://api.openai.com") return next(request) - return next(new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, request)) - }), - ) - const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe( Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")), diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 8107f547cdfd..b2d0292e50c9 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -46,6 +46,8 @@ const declineDefect = (cause: Cause.Cause) => { interface Prepared { readonly request: LLMRequest readonly options: StreamOptions + /** False when Session HTTP middleware requires the request to remain on HTTP. */ + readonly webSocketEligible: boolean /** * One request-scoped execution operation. Unknown, hook-removed, and * step-limit-violating calls fail individually through the same seam. @@ -76,6 +78,40 @@ const unsupportedMedia = (mime: string, name: string | undefined, capabilities: } } +export const composeHttpMiddleware = (middlewares: ReadonlyArray): StreamOptions["http"] => { + if (middlewares.length === 0) return undefined + return (request, handler) => + Effect.gen(function* () { + let latest = request + const origins = new WeakMap() + const web = yield* HttpClientRequest.toWeb(request) + const send = (input: Request) => + Effect.gen(function* () { + let sent = HttpClientRequest.fromWeb(input) + if (input.body) + sent = HttpClientRequest.bodyUint8Array( + sent, + new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())), + input.headers.get("content-type") ?? undefined, + ) + latest = sent + const response = yield* handler(sent) + const body = [204, 205, 304].includes(response.status) + ? null + : yield* Stream.toReadableStreamEffect(response.stream) + const output = new Response(body, { status: response.status, headers: response.headers }) + origins.set(output, sent) + return output + }) + const dispatch = middlewares.reduce( + (next, item) => (input: Request) => item(input, next), + send, + ) + const response = yield* dispatch(web) + return HttpClientResponse.fromWeb(origins.get(response) ?? latest, response) + }).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))) +} + export const unsupportedParts = (messages: LLMRequest["messages"], capabilities: Model.Capabilities) => messages.map((message) => Message.make({ @@ -227,49 +263,18 @@ export const layer = Layer.effect( tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })), toolChoice: stepLimitReached ? "none" : undefined, }) - const options: StreamOptions = { - http: (request, handler) => - Effect.gen(function* () { - let latest = request - const origins = new WeakMap() - const middlewares: SessionHttpMiddleware[] = [] - const web = yield* HttpClientRequest.toWeb(request) - yield* hooks.trigger("session", "http", { - sessionID: session.id, - agent: agent.id, - model: resolved.ref, - use: (item) => - Effect.sync(() => { - middlewares.push(item) - }), - }) - const send = (input: Request) => - Effect.gen(function* () { - let sent = HttpClientRequest.fromWeb(input) - if (input.body) - sent = HttpClientRequest.bodyUint8Array( - sent, - new Uint8Array(yield* Effect.promise(() => input.clone().arrayBuffer())), - input.headers.get("content-type") ?? undefined, - ) - latest = sent - const response = yield* handler(sent) - const body = [204, 205, 304].includes(response.status) - ? null - : yield* Stream.toReadableStreamEffect(response.stream) - const output = new Response(body, { status: response.status, headers: response.headers }) - origins.set(output, sent) - return output - }) - const dispatch = middlewares.reduce( - (next, item) => (input: Request) => item(input, next), - send, - ) - const response = yield* dispatch(web) - const origin = origins.get(response) ?? latest - return HttpClientResponse.fromWeb(origin, response) - }).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))), - } + const middlewares: SessionHttpMiddleware[] = [] + yield* hooks.trigger("session", "http", { + sessionID: session.id, + agent: agent.id, + model: resolved.ref, + use: (item) => + Effect.sync(() => { + middlewares.push(item) + }), + }) + const http = composeHttpMiddleware(middlewares) + const options: StreamOptions = http ? { http } : {} if (promptCacheSnapshots) { const current = PromptCacheDiagnostics.snapshot(request) const comparison = PromptCacheDiagnostics.compare(promptCacheSnapshots.get(session.id), current) @@ -301,6 +306,7 @@ export const layer = Layer.effect( return { request, options, + webSocketEligible: middlewares.length === 0, executeTool, stepLimitReached, } diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 239599e31dbf..4850950f707d 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -12,7 +12,6 @@ import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai" import { Provider } from "@opencode-ai/core/provider" -import type { SessionHttpHandler } from "@opencode-ai/plugin/effect/session" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -30,27 +29,15 @@ function required(value: T | undefined): T { return value } -const http = Effect.fn(function* (providerID: Provider.ID, url: string) { +const httpMiddlewareCount = Effect.fn(function* () { const middlewares: Parameters[0][] = [] yield* (yield* PluginHooks.Service).trigger("session", "http", { sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build"), - model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }), - use: (item) => - Effect.sync(() => { - middlewares.push(item) - }), + model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }), + use: (middleware) => Effect.sync(() => middlewares.push(middleware)), }) - const request = middlewares.reduce( - (next, item) => (input: Request) => item(input, next), - (input: Request) => { - const headers = new Headers(input.headers) - headers.set("x-seen-url", input.url) - return Effect.succeed(new Response(null, { headers })) - }, - ) - const response = yield* request(new Request(url, { method: "POST", body: "{}" })) - return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) } + return middlewares.length }) describe("OpenAIPlugin", () => { @@ -124,30 +111,18 @@ describe("OpenAIPlugin", () => { }) yield* addPlugin() - const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses") - const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses") - const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us") - const provider = required(yield* catalog.provider.get(Provider.ID.openai)) expect(provider.package).toBe("@opencode-ai/ai/providers/openai") expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" }) - expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" }) - expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses") - expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" }) - expect(custom.headers).not.toHaveProperty("originator") - expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us") - expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" }) + expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" }) + expect(yield* httpMiddlewareCount()).toBe(0) const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) expect(eligible.package).toBe("@opencode-ai/ai/providers/openai") expect(eligible.cost).toEqual([]) expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 }) expect(eligible.enabled).toBe(true) - expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe( - false, - ) - expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe( - false, - ) + expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(false) + expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe(false) expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({ context: 272_000, input: 272_000, @@ -184,15 +159,14 @@ describe("OpenAIPlugin", () => { }) yield* addPlugin() - const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses") - + const provider = required(yield* catalog.provider.get(Provider.ID.openai)) const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) expect(model.package).toBe("@opencode-ai/ai/providers/openai") expect(model.enabled).toBe(true) expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) - expect(request.headers).not.toHaveProperty("originator") + expect(provider.headers).not.toHaveProperty("originator") + expect(yield* httpMiddlewareCount()).toBe(0) expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true) }), ) - }) diff --git a/packages/core/test/session-model-request.test.ts b/packages/core/test/session-model-request.test.ts index e2c77ed69cee..b76f40f64c6c 100644 --- a/packages/core/test/session-model-request.test.ts +++ b/packages/core/test/session-model-request.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "bun:test" import { Message, ToolResultPart } from "@opencode-ai/ai" -import { boundImages, unsupportedParts } from "@opencode-ai/core/session/model-request" +import { boundImages, composeHttpMiddleware, unsupportedParts } from "@opencode-ai/core/session/model-request" +import type { SessionHttpMiddleware } from "@opencode-ai/plugin/effect/session" +import { Effect } from "effect" +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] }) @@ -110,3 +113,60 @@ describe("SessionModelRequest.boundImages", () => { }) }) }) + +describe("SessionModelRequest.composeHttpMiddleware", () => { + test("keeps WebSocket eligibility when no middleware is registered", () => { + expect(composeHttpMiddleware([])).toBeUndefined() + }) + + test("forces HTTP when middleware is registered", () => { + expect(composeHttpMiddleware([(request, next) => next(request)])).toBeFunction() + }) + + test("preserves middleware nesting order", async () => { + const order: string[] = [] + const middleware = + (name: string): SessionHttpMiddleware => + (request, next) => + Effect.sync(() => order.push(`${name}:before`)).pipe( + Effect.andThen(next(request)), + Effect.tap(() => Effect.sync(() => order.push(`${name}:after`))), + ) + const composed = composeHttpMiddleware([middleware("first"), middleware("second")]) + if (!composed) throw new Error("Expected HTTP middleware") + const request = HttpClientRequest.post("https://provider.test/responses").pipe( + HttpClientRequest.bodyText("payload", "text/plain"), + ) + const response = await Effect.runPromise( + composed(request, (sent) => + Effect.sync(() => { + order.push("send") + return HttpClientResponse.fromWeb(sent, new Response("response")) + }), + ), + ) + + expect(order).toEqual(["second:before", "first:before", "send", "first:after", "second:after"]) + expect(await Effect.runPromise(response.text)).toBe("response") + }) + + test("preserves a synthetic replacement response", async () => { + let sent = false + const composed = composeHttpMiddleware([() => Effect.succeed(new Response("synthetic", { status: 202 }))]) + if (!composed) throw new Error("Expected HTTP middleware") + const request = HttpClientRequest.post("https://provider.test/responses") + const response = await Effect.runPromise( + composed(request, (input) => + Effect.sync(() => { + sent = true + return HttpClientResponse.fromWeb(input, new Response("network")) + }), + ), + ) + + expect(sent).toBe(false) + expect(response.status).toBe(202) + expect(response.request.url).toBe(request.url) + expect(await Effect.runPromise(response.text)).toBe("synthetic") + }) +}) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 5ac96a6def19..a6530a6db140 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -897,6 +897,26 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("collects session HTTP middleware once per prepared request", () => + Effect.gen(function* () { + const session = yield* setup + const hooks = yield* PluginHooks.Service + let triggers = 0 + yield* hooks.register("session", "http", (event) => + Effect.gen(function* () { + triggers++ + yield* event.use((request, next) => next(request)) + }), + ) + yield* admit(session, "Use HTTP middleware") + yield* TestLLM.push(TestLLM.text("Done", "text-http-middleware")) + + yield* session.resume(sessionID) + + expect(triggers).toBe(1) + }), + ) + it.effect("executes a tool renamed by a session context hook", () => Effect.gen(function* () { const session = yield* setup