Compare commits

...

1 Commits

Author SHA1 Message Date
Shoubhit Dash b3e1471fa7 refactor(core): expose session transport eligibility 2026-08-10 19:51:06 +05:30
5 changed files with 145 additions and 97 deletions
+4 -16
View File
@@ -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")),
+49 -43
View File
@@ -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.
@@ -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({
@@ -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)
@@ -301,6 +306,7 @@ export const layer = Layer.effect(
return {
request,
options,
webSocketEligible: middlewares.length === 0,
executeTool,
stepLimitReached,
}
@@ -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<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", () => {
@@ -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)
}),
)
})
@@ -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")
})
})
+20
View File
@@ -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