Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 4 additions & 16 deletions packages/core/src/plugin/provider/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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")),
Expand Down
92 changes: 49 additions & 43 deletions packages/core/src/session/model-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
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.
Expand Down Expand Up @@ -76,6 +78,40 @@ const unsupportedMedia = (mime: string, name: string | undefined, capabilities:
}
}

export const composeHttpMiddleware = (middlewares: ReadonlyArray<SessionHttpMiddleware>): StreamOptions["http"] => {
if (middlewares.length === 0) return undefined
return (request, handler) =>
Effect.gen(function* () {
let latest = request
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
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<SessionHttpHandler>(
(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({
Expand Down Expand Up @@ -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<Response, HttpClientRequest.HttpClientRequest>()
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<SessionHttpHandler>(
(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)
Expand Down Expand Up @@ -301,6 +306,7 @@ export const layer = Layer.effect(
return {
request,
options,
webSocketEligible: middlewares.length === 0,
executeTool,
stepLimitReached,
}
Expand Down
48 changes: 11 additions & 37 deletions packages/core/test/plugin/provider-openai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -30,27 +29,15 @@ function required<T>(value: T | undefined): T {
return value
}

const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
const httpMiddlewareCount = Effect.fn(function* () {
const middlewares: Parameters<PluginHooks.Domains["session"]["http"]["use"]>[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<SessionHttpHandler>(
(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", () => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}),
)

})
62 changes: 61 additions & 1 deletion packages/core/test/session-model-request.test.ts
Original file line number Diff line number Diff line change
@@ -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"] })

Expand Down Expand Up @@ -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")
})
})
20 changes: 20 additions & 0 deletions packages/core/test/session-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading