diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index dac3496c459a..5a97db2fe02f 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -660,6 +660,7 @@ export type SessionLogOutput = readonly assistantMessageID: SessionMessage.ID readonly agent: Agent.ID readonly model: Model.Ref + readonly providerStateRoute?: "openai-api" | "openai-chatgpt-codex" | undefined readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined } } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 372872e0ffd3..8316ed7717c1 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -717,7 +717,14 @@ export type SessionStepStarted = { type: "session.step.started" durable: { aggregateID: string; seq: number; version: 1 } location?: LocationRef - data: { sessionID: string; assistantMessageID: string; agent: string; model: ModelRef; snapshot?: string } + data: { + sessionID: string + assistantMessageID: string + agent: string + model: ModelRef + providerStateRoute?: "openai-api" | "openai-chatgpt-codex" + snapshot?: string + } } export type SessionStepStreamed = { @@ -2087,6 +2094,7 @@ export type SessionMessageAssistant = { type: "assistant" agent: string model: ModelRef + providerStateRoute?: "openai-api" | "openai-chatgpt-codex" content: Array snapshot?: { start?: string; end?: string; files?: Array } finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" @@ -2867,6 +2875,7 @@ export type SessionImportInput = { readonly type: "assistant" readonly agent: string readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly providerStateRoute?: "openai-api" | "openai-chatgpt-codex" readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } } | { @@ -3143,6 +3152,7 @@ export type SessionImportInput = { readonly type: "assistant" readonly agent: string readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly providerStateRoute?: "openai-api" | "openai-chatgpt-codex" readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } } | { @@ -3419,6 +3429,7 @@ export type SessionImportInput = { readonly type: "assistant" readonly agent: string readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly providerStateRoute?: "openai-api" | "openai-chatgpt-codex" readonly content: ReadonlyArray< | { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } } | { diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts index 3f4760bc586d..46b292328b12 100644 --- a/packages/core/src/model-resolver.ts +++ b/packages/core/src/model-resolver.ts @@ -12,6 +12,7 @@ import { Credential } from "./credential.js" import { Integration } from "./integration.js" import { Capabilities, ID, Info, Ref, VariantID } from "./model.js" import { Npm } from "@opencode-ai/util/npm" +import { applyCredentialTransport } from "./plugin/provider/openai.js" import { Provider } from "./provider.js" export class VariantUnavailableError extends Schema.TaggedError()( @@ -277,7 +278,8 @@ export const layer = Layer.effect( provider?.integrationID ?? Integration.ID.make(selected.providerID), ) const credential = connection ? yield* integrations.connection.resolve(connection) : undefined - const runtimeInfo = yield* withVariant(selected, variant) + // A ChatGPT OAuth-to-OpenAI API-key switch must not split a request between credential and route snapshots. + const runtimeInfo = applyCredentialTransport(yield* withVariant(selected, variant), credential) const model = yield* fromCatalogModel(runtimeInfo, credential, { loadPackage: (specifier) => Provider.loadPackage(specifier, npm), loadAISDK: (model) => aisdk.model(model), diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 930d32d02f96..75f0b534d3aa 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,11 +1,11 @@ import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration" import { define } from "@opencode-ai/plugin/effect/plugin" -import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect" +import { Deferred, Effect, Option, Schema } from "effect" import type { Server } from "node:http" import { App } from "../../app.js" import { Credential } from "../../credential.js" -import { Bus } from "../../bus.js" import { Integration } from "../../integration.js" +import type { Info } from "../../model.js" import { OauthCallbackPage } from "../../oauth/page.js" import { Provider } from "../../provider.js" import type { PluginInternal } from "../internal.js" @@ -20,8 +20,27 @@ const pollingSafetyMargin = 3000 const codexBaseURL = "https://chatgpt.com/backend-api/codex" const browserMethodID = Integration.MethodID.make("chatgpt-browser") const headlessMethodID = Integration.MethodID.make("chatgpt-headless") -const codexAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"]) -const codexDisallowed = new Set(["gpt-5.5-pro", "gpt-5.6"]) + +export function applyCredentialTransport(model: Info, credential: Credential.Value | undefined): Info { + if (model.providerID !== Provider.ID.openai || !isChatGPTCredential(credential)) return model + const account = credential.metadata?.accountID + return { + ...model, + // ChatGPT OAuth access tokens are valid only for Codex, never a configured proxy endpoint. + settings: Provider.mergeOverlay(model.settings, { baseURL: codexBaseURL }), + headers: Provider.mergeHeaders(model.headers, { + originator: "opencode", + ...(typeof account === "string" ? { "chatgpt-account-id": account } : {}), + }), + } +} + +function isChatGPTCredential(credential: Credential.Value | undefined): credential is Credential.OAuth { + return ( + credential?.type === "oauth" && + (credential.methodID === browserMethodID || credential.methodID === headlessMethodID) + ) +} type Pkce = { verifier: string @@ -229,27 +248,10 @@ const headless = (app: App.Info) => export const OpenAIPlugin = define({ id: "opencode.provider.openai", effect: Effect.fn(function* (ctx) { - const bus = yield* Bus.Service - const loading = Semaphore.makeUnsafe(1) - let chatgpt: Credential.OAuth | undefined - - const load = Effect.fn("OpenAIPlugin.load")(function* () { - const connection = yield* ctx.integration.connection.active("openai") - const credential = connection - ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined)) - : undefined - chatgpt = - credential?.type === "oauth" && - (credential.methodID === browserMethodID || credential.methodID === headlessMethodID) - ? credential - : undefined - }) - yield* ctx.integration.transform((draft) => { draft.method.update(browser(ctx.app)) draft.method.update(headless(ctx.app)) }) - yield* load() yield* ctx.catalog.transform((evt) => { const item = evt.provider.get(Provider.ID.openai) if (!item) return @@ -258,54 +260,16 @@ export const OpenAIPlugin = define({ draft.capabilities.responsesWebsockets = true }) } - if (!chatgpt) return - item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL }) - const account = chatgpt.metadata?.accountID - 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. - evt.model.update(item.provider.id, model.id, (draft) => { - if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) { - draft.enabled = false - return - } - const apiID = draft.modelID ?? draft.id - const match = apiID.match(/^gpt-(\d+\.\d+)/) - if ( - !codexAllowed.has(apiID) && - (codexDisallowed.has(apiID) || !match || Number.parseFloat(match[1]) <= 5.4) - ) { - draft.enabled = false - return - } - draft.cost = [] - // Match Codex CLI so context consumption and subscription usage stay consistent between clients. - draft.limit = { ...draft.limit, context: 400_000, input: 272_000 } - }) - } }) yield* ctx.session.hook( "model.request", (evt) => Effect.sync(() => { - if (!chatgpt) return - if (evt.baseURL && URL.canParse(evt.baseURL) && new URL(evt.baseURL).origin === "https://api.openai.com") - evt.baseURL = codexBaseURL - evt.headers.originator = "opencode" + if (evt.baseURL !== codexBaseURL) return evt.headers["session-id"] = evt.sessionID }), { providerID: Provider.ID.openai }, ) - const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) - yield* bus.subscribe(Credential.Event.Switched).pipe( - Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")), - Stream.runForEach(refresh), - Effect.forkScoped({ startImmediately: true }), - ) }), } satisfies PluginInternal.InternalPlugin) diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 0788c474954c..22ab674442e9 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -205,6 +205,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { produce(existing, (draft) => { draft.agent = event.data.agent draft.model = castDraft(event.data.model) + draft.providerStateRoute = event.data.providerStateRoute draft.retry = undefined draft.error = undefined draft.finish = undefined @@ -232,6 +233,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { type: "assistant", agent: event.data.agent, model: event.data.model, + providerStateRoute: event.data.providerStateRoute, metadata: event.metadata, time: { created }, content: [], diff --git a/packages/core/src/session/model-request.ts b/packages/core/src/session/model-request.ts index 2d818ebbf3ef..53723586c53f 100644 --- a/packages/core/src/session/model-request.ts +++ b/packages/core/src/session/model-request.ts @@ -16,7 +16,7 @@ import { SessionModelTransport } from "./model-transport.js" import { SessionRunnerModel } from "./runner/model.js" import { SessionSchema } from "./schema.js" import { SessionSystemPrompt } from "./system-prompt.js" -import { toLLMMessages } from "./runner/to-llm-message.js" +import { providerStateRoute, toLLMMessages } from "./runner/to-llm-message.js" import type { SessionMessage } from "./message.js" import type { Agent } from "../agent.js" @@ -86,8 +86,10 @@ export const baseTranscript = (input: { readonly messages: ReadonlyArray }) => { const providerMetadataKey = input.model.model.route.providerMetadataKey ?? input.model.model.provider + const route = providerStateRoute(input.model.model) return { providerMetadataKey, + providerStateRoute: route, system: [ input.agent.system ? input.agent.system @@ -96,7 +98,7 @@ export const baseTranscript = (input: { ] .filter((part) => part.length > 0) .map(SystemPart.make), - messages: toLLMMessages(input.messages, input.model.ref, providerMetadataKey), + messages: toLLMMessages(input.messages, input.model.ref, providerMetadataKey, route), } } diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index d50a9578fa9e..5ac7d113713c 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -18,6 +18,7 @@ type Input = { readonly agent: Agent.ID readonly model: Model.Ref readonly providerMetadataKey: string + readonly providerStateRoute?: SessionMessage.ProviderStateRoute readonly snapshot?: Snapshot.ID readonly assistantMessageID: SessionMessage.ID } @@ -107,6 +108,7 @@ export const createLLMEventPublisher = (bus: Pick, inp sessionID: input.sessionID, agent: input.agent, model: input.model, + providerStateRoute: input.providerStateRoute, assistantMessageID, snapshot: input.snapshot, }) diff --git a/packages/core/src/session/runner/step.ts b/packages/core/src/session/runner/step.ts index f103a2728d2b..38f01a105594 100644 --- a/packages/core/src/session/runner/step.ts +++ b/packages/core/src/session/runner/step.ts @@ -28,6 +28,7 @@ import { SessionUsage } from "../usage.js" import { SessionRunnerModel } from "./model.js" import { createLLMEventPublisher } from "./publish-llm-event.js" import { SessionRunnerRetry } from "./retry.js" +import { providerStateRoute } from "./to-llm-message.js" export type Outcome = Data.TaggedEnum<{ Completed: { readonly needsContinuation: boolean } @@ -69,6 +70,7 @@ export const make = Effect.gen(function* () { agent: input.agent, model: input.model.ref, providerMetadataKey: input.model.model.route.providerMetadataKey ?? input.model.model.provider, + providerStateRoute: providerStateRoute(input.model.model), snapshot: startSnapshot, }) const toolRuns: Array<{ diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index 51b0f1ef1ef9..23f4730d3c86 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -1,7 +1,15 @@ -import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai" +import { + type LanguageModel, + Message, + ToolCallPart, + ToolResultPart, + type ContentPart, + type ProviderMetadata, +} from "@opencode-ai/ai" import { Option, Schema } from "effect" import { fileURLToPath } from "url" import type { Model } from "../../model.js" +import { Provider } from "../../provider.js" import { SessionMessage } from "../message.js" import type { FileAttachment } from "@opencode-ai/schema/prompt" @@ -100,6 +108,13 @@ const providerMetadata = ( state: Record | undefined, ): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state }) +export const providerStateRoute = (model: LanguageModel): SessionMessage.ProviderStateRoute | undefined => { + if (String(model.provider) !== String(Provider.ID.openai)) return + return model.route.endpoint.baseURL === "https://chatgpt.com/backend-api/codex" + ? "openai-chatgpt-codex" + : "openai-api" +} + const toolInput = (tool: SessionMessage.AssistantTool) => tool.state.status === "streaming" ? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input) @@ -142,10 +157,20 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid } } -const assistant = (message: SessionMessage.Assistant, model: Model.Ref, providerMetadataKey: string) => { +const assistant = ( + message: SessionMessage.Assistant, + model: Model.Ref, + providerMetadataKey: string, + currentProviderStateRoute: SessionMessage.ProviderStateRoute | undefined, +) => { const sameProvider = String(message.model.providerID) === String(model.providerID) const sameModel = sameProvider && String(message.model.id) === String(model.id) - const reuseProviderMetadata = sameModel && message.error === undefined + // A ChatGPT OAuth-to-OpenAI API-key switch retains the catalog model but changes + // endpoints, so OpenAI item IDs and encrypted reasoning cannot cross routes. + const compatibleProviderStateRoute = + String(message.model.providerID) !== String(Provider.ID.openai) || + message.providerStateRoute === currentProviderStateRoute + const reuseProviderMetadata = sameModel && compatibleProviderStateRoute && message.error === undefined const content = message.content.flatMap((item): ContentPart[] => { if (item.type === "text") return [ @@ -174,7 +199,10 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider // replay it. const reuseToolProviderMetadata = reuseProviderMetadata || - (sameModel && item.executed === true && (item.state.status === "completed" || item.state.status === "error")) + (sameModel && + compatibleProviderStateRoute && + item.executed === true && + (item.state.status === "completed" || item.state.status === "error")) const call = toolCall( item, reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined, @@ -190,7 +218,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider item, reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerResultState ?? item.providerState) - : sameProvider && item.providerResultState !== undefined + : sameProvider && compatibleProviderStateRoute && item.providerResultState !== undefined ? providerMetadata(providerMetadataKey, item.providerResultState) : undefined, ) @@ -220,7 +248,12 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider ] } -function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMetadataKey: string): Message[] { +function toLLMMessage( + message: SessionMessage.Info, + model: Model.Ref, + providerMetadataKey: string, + providerStateRoute: SessionMessage.ProviderStateRoute | undefined, +): Message[] { switch (message.type) { case "agent-switched": case "model-switched": @@ -268,7 +301,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe }), ] case "assistant": - return assistant(message, model, providerMetadataKey) + return assistant(message, model, providerMetadataKey, providerStateRoute) case "compaction": if (message.status !== "completed") return [] return [ @@ -297,4 +330,5 @@ export const toLLMMessages = ( messages: readonly SessionMessage.Info[], model: Model.Ref, providerMetadataKey: string = model.providerID, -) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey)) + currentProviderStateRoute: SessionMessage.ProviderStateRoute | undefined = undefined, +) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey, currentProviderStateRoute)) diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 2e96b3f8a077..d9875e284745 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -2,13 +2,16 @@ import { Money } from "@opencode-ai/schema/money" import { Agent } from "@opencode-ai/schema/agent" import { Session } from "@opencode-ai/schema/session" import { OpenAIResponses } from "@opencode-ai/ai/protocols/openai-responses" +import { LLM } from "@opencode-ai/ai" import { describe, expect } from "bun:test" import { ConfigProvider, DateTime, Effect } from "effect" +import { Headers } from "effect/unstable/http" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" import { Location } from "@opencode-ai/core/location" import { Model } from "@opencode-ai/core/model" +import { ModelResolver } from "@opencode-ai/core/model-resolver" import { Plugin } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginHooks } from "@opencode-ai/core/plugin/hooks" @@ -80,7 +83,7 @@ describe("OpenAIPlugin", () => { }), ) - it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () => + it.effect("keeps the full OpenAI catalog under an API key connection", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const credentials = yield* Credential.Service @@ -94,80 +97,31 @@ describe("OpenAIPlugin", () => { }) catalog.model.update(item.id, Model.ID.make("gpt-5.5"), (model) => { model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } - model.cost = [ - { - input: Money.USDPerMillionTokens.make(1), - output: Money.USDPerMillionTokens.make(2), - cache: { - read: Money.USDPerMillionTokens.make(0.1), - write: Money.USDPerMillionTokens.zero, - }, - }, - ] - }) - catalog.model.update(item.id, Model.ID.make("gpt-5.5-pro"), () => {}) - catalog.model.update(item.id, Model.ID.make("gpt-5.4"), (model) => { - model.limit = { context: 1_050_000, input: 922_000, output: 64_000 } - }) - catalog.model.update(item.id, Model.ID.make("gpt-5.4-pro"), (model) => { - model.modelID = Model.ID.make("gpt-5.4") - model.body = { reasoning: { mode: "pro" } } - }) - catalog.model.update(item.id, Model.ID.make("gpt-5.6"), () => {}) - catalog.model.update(item.id, Model.ID.make("gpt-5.6-sol"), (model) => { - model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } }) catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {}) }) yield* credentials.create({ integrationID: Integration.ID.make("openai"), - value: Credential.OAuth.make({ - type: "oauth", - methodID: Integration.MethodID.make("chatgpt-browser"), - access: "chatgpt-token", - refresh: "refresh", - expires: Date.now() + 60_000, - metadata: { accountID: "acct_123" }, - }), + value: Credential.Key.make({ type: "key", key: "sk-test" }), }) yield* addPlugin() const direct = yield* request(Provider.ID.openai, "https://api.openai.com/v1") - const custom = yield* request(Provider.ID.make("custom-openai"), "https://custom.example/v1") - const proxy = yield* request(Provider.ID.openai, "https://proxy.example/v1?region=us") const provider = required(yield* catalog.provider.get(Provider.ID.openai)) - expect(provider.package).toBe(Provider.aisdk("@ai-sdk/openai")) - expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" }) - expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" }) - expect(direct.baseURL).toBe("https://chatgpt.com/backend-api/codex") - expect(direct.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" }) + const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) + expect(model.package).toBe(Provider.aisdk("@ai-sdk/openai")) + expect(model.enabled).toBe(true) + expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) + expect(model.capabilities.responsesWebsockets).toBe(true) + expect(direct.headers).not.toHaveProperty("originator") expect(direct.hasHttpHooks).toBe(false) - expect(custom.headers).not.toHaveProperty("originator") - expect(proxy.baseURL).toBe("https://proxy.example/v1?region=us") - expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" }) - const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) - expect(eligible.package).toBe(Provider.aisdk("@ai-sdk/openai")) - expect(eligible.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" }) - expect(eligible.cost).toEqual([]) - expect(eligible.limit).toEqual({ context: 400_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.4"))).limit).toEqual({ - context: 400_000, - input: 272_000, - output: 64_000, - }) - expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false) - const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"))) - expect(gpt56.enabled).toBe(true) - expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 }) - expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false) + expect(provider.headers).not.toHaveProperty("originator") + expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true) }), ) - it.effect("keeps the full OpenAI catalog under an API key connection", () => + it.effect("atomically switches ChatGPT OAuth and API-key OpenAI transports", () => Effect.gen(function* () { const catalog = yield* Catalog.Service const credentials = yield* Credential.Service @@ -180,29 +134,65 @@ describe("OpenAIPlugin", () => { draft.package = item.package }) catalog.model.update(item.id, Model.ID.make("gpt-5.5"), (model) => { - model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } + model.settings = { baseURL: "https://proxy.example/v1" } }) - catalog.model.update(item.id, Model.ID.make("gpt-4.1"), () => {}) }) yield* credentials.create({ integrationID: Integration.ID.make("openai"), - value: Credential.Key.make({ type: "key", key: "sk-test" }), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-browser"), + access: "chatgpt-token", + refresh: "refresh", + expires: Date.now() + 60_000, + metadata: { accountID: "acct_123" }, + }), }) yield* addPlugin() - const direct = yield* request(Provider.ID.openai, "https://api.openai.com/v1") + const selected = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) + const resolver = yield* ModelResolver.Service + const codex = yield* resolver.resolveModel(selected) + const codexHeaders = yield* codex.model.route.auth.apply({ + request: LLM.request({ model: codex.model, prompt: "Hello" }), + method: "POST", + url: "https://chatgpt.com/backend-api/codex/responses", + body: "{}", + headers: Headers.fromInput(codex.model.route.defaults.headers), + }) - 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(Provider.aisdk("@ai-sdk/openai")) - expect(model.enabled).toBe(true) - expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) - expect(model.capabilities.responsesWebsockets).toBe(true) - expect(direct.headers).not.toHaveProperty("originator") - expect(direct.hasHttpHooks).toBe(false) - expect(provider.headers).not.toHaveProperty("originator") - expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true) - }), + expect(codex.model.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex") + expect(codexHeaders.authorization).toBe("Bearer chatgpt-token") + expect(codexHeaders.originator).toBe("opencode") + expect(codexHeaders["chatgpt-account-id"]).toBe("acct_123") + + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.Key.make({ type: "key", key: "sk-test" }), + }) + const preservedHeaders = yield* codex.model.route.auth.apply({ + request: LLM.request({ model: codex.model, prompt: "Hello" }), + method: "POST", + url: "https://chatgpt.com/backend-api/codex/responses", + body: "{}", + headers: Headers.fromInput(codex.model.route.defaults.headers), + }) + const direct = yield* resolver.resolveModel(selected) + const headers = yield* direct.model.route.auth.apply({ + request: LLM.request({ model: direct.model, prompt: "Hello" }), + method: "POST", + url: "https://proxy.example/v1/responses", + body: "{}", + headers: Headers.fromInput(direct.model.route.defaults.headers), + }) + + expect(codex.model.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex") + expect(preservedHeaders.authorization).toBe("Bearer chatgpt-token") + expect(direct.model.route.endpoint.baseURL).toBe("https://proxy.example/v1") + expect(headers.authorization).toBe("Bearer sk-test") + expect(headers.originator).toBeUndefined() + expect(headers["chatgpt-account-id"]).toBeUndefined() + }).pipe(Effect.provide(ModelResolver.layer)), ) it.effect("selects Azure WebSocket from capability and the Azure flag only", () => diff --git a/packages/core/test/session-message-update.test.ts b/packages/core/test/session-message-update.test.ts index 23810107e1df..effb002a9e11 100644 --- a/packages/core/test/session-message-update.test.ts +++ b/packages/core/test/session-message-update.test.ts @@ -69,6 +69,27 @@ const complete = (bus: Bus.Interface, sessionID: Session.ID, messageID: SessionM }) describe("Session.updateMessage", () => { + it.effect("persists the OpenAI opaque-state route with its assistant message", () => + Effect.gen(function* () { + const session = yield* Session.Service + const bus = yield* Bus.Service + const created = yield* session.create({ location }) + const messageID = SessionMessage.ID.create() + + yield* bus.publish(SessionEvent.Step.Started, { + sessionID: created.id, + assistantMessageID: messageID, + agent: Agent.defaultID, + model: { id: Model.ID.make("gpt-5.5"), providerID: Provider.ID.openai }, + providerStateRoute: "openai-chatgpt-codex", + }) + + expect(yield* session.message({ sessionID: created.id, messageID })).toMatchObject({ + providerStateRoute: "openai-chatgpt-codex", + }) + }), + ) + it.effect("replaces assistant content through a durable projected event", () => Effect.gen(function* () { const session = yield* Session.Service diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 706ecc30227c..922eaa13e6fb 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -808,6 +808,65 @@ Recent work ]) }) + test("does not replay opaque OpenAI state from ChatGPT Codex to the API", () => { + const openai = Model.Ref.make({ id: Model.ID.make("gpt-5.5"), providerID: Provider.ID.openai }) + const history = [ + SessionMessage.Assistant.make({ + id: id("assistant-chatgpt-codex"), + type: "assistant", + agent: build, + model: openai, + providerStateRoute: "openai-chatgpt-codex", + content: [ + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + text: "Visible thought", + state: { itemId: "rs_codex", reasoningEncryptedContent: "encrypted-codex-state" }, + }), + SessionMessage.AssistantTool.make({ + type: "tool", + id: "search", + name: "web_search", + executed: true, + providerState: { itemId: "call_codex" }, + providerResultState: { itemId: "result_codex" }, + state: SessionMessage.ToolStateCompleted.make({ + status: "completed", + input: { query: "Effect" }, + content: [{ type: "text", text: "Found it" }], + }), + time: { created, completed: created }, + }), + ], + time: { created, completed: created }, + }), + ] + + const api = toLLMMessages(history, openai, "openai", "openai-api") + const codex = toLLMMessages(history, openai, "openai", "openai-chatgpt-codex") + + expect(api[0]?.content).toEqual([ + { type: "text", text: "Visible thought" }, + { type: "tool-call", id: "search", name: "web_search", input: { query: "Effect" }, providerExecuted: true }, + { + type: "tool-result", + id: "search", + name: "web_search", + providerExecuted: true, + result: { type: "text", value: "Found it" }, + }, + ]) + expect(codex[0]?.content).toMatchObject([ + { + type: "reasoning", + text: "Visible thought", + providerMetadata: { openai: { itemId: "rs_codex", reasoningEncryptedContent: "encrypted-codex-state" } }, + }, + { type: "tool-call", providerMetadata: { openai: { itemId: "call_codex" } } }, + { type: "tool-result", providerMetadata: { openai: { itemId: "result_codex" } } }, + ]) + }) + test("replays flat state under an OpenCode hosted model's route key", () => { const opencode = Model.Ref.make({ id: Model.ID.make("claude-fable-5"), providerID: Provider.ID.opencode }) const messages = toLLMMessages( diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index ce1e5b91ef1e..213cb764d5f0 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -311,6 +311,7 @@ export namespace Step { assistantMessageID: SessionMessage.ID, agent: Agent.ID, model: Model.Ref, + providerStateRoute: SessionMessage.ProviderStateRoute.pipe(optional), snapshot: Snapshot.ID.pipe(optional), }, }) diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 64b82ee50230..1206a25f3dd8 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -39,6 +39,9 @@ export const ProviderState = Schema.Record(Schema.String, Schema.Unknown).annota }) export type ProviderState = typeof ProviderState.Type +export const ProviderStateRoute = Schema.Literals(["openai-api", "openai-chatgpt-codex"]) +export type ProviderStateRoute = typeof ProviderStateRoute.Type + export interface AgentSelected extends Schema.Schema.Type {} export const AgentSelected = Schema.Struct({ ...Base, @@ -213,6 +216,7 @@ export const Assistant = Schema.Struct({ type: Schema.tag("assistant"), agent: Agent.ID, model: Model.Ref, + providerStateRoute: ProviderStateRoute.pipe(optional), content: AssistantContent.pipe(Schema.Array), snapshot: Schema.Struct({ start: Snapshot.ID.pipe(optional),