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
1 change: 1 addition & 0 deletions packages/client/src/effect/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
13 changes: 12 additions & 1 deletion packages/client/src/promise/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -2087,6 +2094,7 @@ export type SessionMessageAssistant = {
type: "assistant"
agent: string
model: ModelRef
providerStateRoute?: "openai-api" | "openai-chatgpt-codex"
content: Array<SessionMessageAssistantText | SessionMessageAssistantReasoning | SessionMessageAssistantTool>
snapshot?: { start?: string; end?: string; files?: Array<string> }
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
Expand Down Expand Up @@ -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 } }
| {
Expand Down Expand Up @@ -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 } }
| {
Expand Down Expand Up @@ -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 } }
| {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/model-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<VariantUnavailableError>()(
Expand Down Expand Up @@ -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),
Expand Down
84 changes: 24 additions & 60 deletions packages/core/src/plugin/provider/openai.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/session/message-updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: [],
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/session/model-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -86,8 +86,10 @@ export const baseTranscript = (input: {
readonly messages: ReadonlyArray<SessionMessage.Info>
}) => {
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
Expand All @@ -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),
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/session/runner/publish-llm-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -107,6 +108,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
sessionID: input.sessionID,
agent: input.agent,
model: input.model,
providerStateRoute: input.providerStateRoute,
assistantMessageID,
snapshot: input.snapshot,
})
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/session/runner/step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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<{
Expand Down
50 changes: 42 additions & 8 deletions packages/core/src/session/runner/to-llm-message.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -100,6 +108,13 @@ const providerMetadata = (
state: Record<string, unknown> | 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)
Expand Down Expand Up @@ -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 [
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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 [
Expand Down Expand Up @@ -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))
Loading
Loading