From 674d08f9bef2b4ca1a89415e1001c15c7d03da45 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Jul 2026 00:15:34 -0400 Subject: [PATCH] fix(core): route ChatGPT OAuth to the codex backend (#34843) --- .../core/src/plugin/provider/openai-codex.ts | 42 ++++++++ packages/core/src/plugin/provider/openai.ts | 38 +++++++- packages/core/src/session/runner/model.ts | 16 ++++ .../core/test/plugin/provider-openai.test.ts | 89 +++++++++++++++++ .../core/test/session-runner-model.test.ts | 95 +++++++++++++++++++ 5 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/plugin/provider/openai-codex.ts diff --git a/packages/core/src/plugin/provider/openai-codex.ts b/packages/core/src/plugin/provider/openai-codex.ts new file mode 100644 index 0000000000..de31d39b39 --- /dev/null +++ b/packages/core/src/plugin/provider/openai-codex.ts @@ -0,0 +1,42 @@ +export * as OpenAICodex from "./openai-codex" + +// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so +// codex routing lives in SessionRunnerModel.fromCatalogModel and catalog filtering +// in OpenAIPlugin, sharing this module. Once the native provider packages land +// (#33689/#33925/#34462) this should collapse into the native OpenAI provider. +// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no +// plan-eligibility data for OpenAI today, but models other vendors' subscriptions +// as dedicated providers (e.g. zai-coding-plan) - a future openai-chatgpt-plan +// provider entry could replace the hardcoded rules with catalog data. + +/** ChatGPT-plan requests must target the codex backend instead of the public API. */ +export const baseURL = "https://chatgpt.com/backend-api/codex" + +const methodIDs: readonly string[] = ["chatgpt-browser", "chatgpt-headless"] + +/** Structural credential shape so both core and plugin-facing credential types fit. */ +type CredentialLike = { + readonly type: string + readonly methodID?: string + readonly metadata?: Record | undefined +} + +export const isChatGPT = (credential: CredentialLike | undefined) => + credential?.type === "oauth" && credential.methodID !== undefined && methodIDs.includes(credential.methodID) + +export const accountID = (credential: CredentialLike | undefined) => { + if (!isChatGPT(credential)) return undefined + const value = credential?.metadata?.accountID + return typeof value === "string" ? value : undefined +} + +const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"]) +const disallowed = new Set(["gpt-5.5-pro"]) + +/** Which API model ids a ChatGPT subscription may call through the codex backend. */ +export const eligible = (apiID: string) => { + if (allowed.has(apiID)) return true + if (disallowed.has(apiID)) return false + const match = apiID.match(/^gpt-(\d+\.\d+)/) + return match ? Number.parseFloat(match[1]) > 5.4 : false +} diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 46553eaff4..82e2319a1b 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,15 +1,17 @@ import { createServer } from "node:http" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { define } from "@opencode-ai/plugin/v2/effect/plugin" -import { Deferred, Effect } from "effect" +import { Deferred, Effect, Semaphore, Stream } from "effect" import type { Scope } from "effect" import { Credential } from "../../credential" +import { EventV2 } from "../../event" import { InstallationVersion } from "../../installation/version" import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { OauthCallbackPage } from "../../oauth/page" import { ProviderV2 } from "../../provider" import type { PluginInternal } from "../internal" +import { OpenAICodex } from "./openai-codex" const clientID = "app_EMoamEEZ73f0CkXaXp7hrann" const issuer = "https://auth.openai.com" @@ -154,6 +156,18 @@ const headless = { export const OpenAIPlugin = define({ id: "openai", effect: Effect.fn(function* (ctx) { + const events = yield* EventV2.Service + const loading = Semaphore.makeUnsafe(1) + let chatgpt = false + + 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.catch(() => Effect.succeed(undefined))) + : undefined + chatgpt = OpenAICodex.isChatGPT(credential) + }) + yield* ctx.integration.transform((draft) => { draft.method.update(browser) draft.method.update(headless) @@ -170,8 +184,30 @@ export const OpenAIPlugin = define({ model.enabled = false }) } + if (!chatgpt) return + const item = evt.provider.get(ProviderV2.ID.openai) + if (!item) return + 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 (!OpenAICodex.eligible(draft.api.id)) { + draft.enabled = false + return + } + draft.cost = [] + }) + } }), ) + + const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) + yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( + Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")), + Stream.runForEach(refresh), + Effect.forkScoped({ startImmediately: true }), + ) + yield* refresh().pipe(Effect.forkScoped) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/openai") return diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index cc67437572..7f517e0b10 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -12,6 +12,7 @@ import { Catalog } from "../../catalog" import { Credential } from "../../credential" import { Integration } from "../../integration" import { ModelV2 } from "../../model" +import { OpenAICodex } from "../../plugin/provider/openai-codex" import { ProviderV2 } from "../../provider" import { SessionSchema } from "../schema" @@ -140,6 +141,21 @@ export const fromCatalogModel = ( }) const key = apiKey(resolved, credential) if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") { + // ChatGPT-plan OAuth tokens are not API-key credentials: the public API rejects + // them, so requests must target the codex backend with the account header. + if (OpenAICodex.isChatGPT(credential)) { + const account = OpenAICodex.accountID(credential) + return Effect.succeed( + withDefaults(resolved, OpenAIResponses.route) + .with({ + endpoint: { baseURL: OpenAICodex.baseURL }, + auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen( + account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }), + ), + }) + .model({ id: resolved.api.id }), + ) + } return Effect.succeed( withDefaults(resolved, OpenAIResponses.route) .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 31a80f9319..44941a4aa1 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -3,6 +3,7 @@ import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" @@ -27,6 +28,20 @@ function required(value: T | undefined): T { return value } +function eventually( + effect: Effect.Effect, + predicate: (value: A) => boolean, + remaining = 1000, +): Effect.Effect { + return Effect.gen(function* () { + const value = yield* effect + if (predicate(value)) return value + if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value")) + yield* Effect.promise(() => Bun.sleep(1)) + return yield* eventually(effect, predicate, remaining - 1) + }) +} + function fakeSelectorSdk(calls: string[]) { const make = (method: string) => (id: string) => { calls.push(`${method}:${id}`) @@ -153,6 +168,80 @@ describe("OpenAIPlugin", () => { }), ) + it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const credentials = yield* Credential.Service + yield* catalog.transform((catalog) => { + const item = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "@ai-sdk/openai" }, + }) + catalog.provider.update(item.id, (draft) => { + draft.api = item.api + }) + catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => { + model.cost = [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }] + }) + catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5-pro"), () => {}) + catalog.model.update(item.id, ModelV2.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" }, + }), + }) + yield* addPlugin() + + const eligible = required( + yield* eventually( + catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5")), + (model) => model?.cost.length === 0, + ), + ) + expect(eligible.enabled).toBe(true) + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5-pro"))).enabled).toBe( + false, + ) + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(false) + }), + ) + + 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 + yield* catalog.transform((catalog) => { + const item = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "@ai-sdk/openai" }, + }) + catalog.provider.update(item.id, (draft) => { + draft.api = item.api + }) + catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), () => {}) + catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {}) + }) + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.Key.make({ type: "key", key: "sk-test" }), + }) + yield* addPlugin() + // The connection refresh is asynchronous; give it time to settle before + // asserting nothing was filtered. + yield* Effect.promise(() => Bun.sleep(25)) + + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5"))).enabled).toBe(true) + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(true) + }), + ) + it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () => Effect.gen(function* () { const catalog = yield* Catalog.Service diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 49bbce95a3..77c1bd3d3a 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -313,6 +313,101 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("routes ChatGPT OAuth credentials to the codex backend", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + ModelV2.Info.make({ + ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), + request: { headers: {}, body: {} }, + }), + 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" }, + }), + ) + const request = LLM.request({ model: resolved, prompt: "Hello" }) + const headers = yield* resolved.route.auth.apply({ + request, + method: "POST", + url: "https://chatgpt.com/backend-api/codex/responses", + body: "{}", + headers: Headers.empty, + }) + + expect(resolved.route).toMatchObject({ + id: "openai-responses", + endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" }, + }) + expect(headers.authorization).toBe("Bearer chatgpt-token") + expect(headers["chatgpt-account-id"]).toBe("acct_123") + }), + ) + + it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + ModelV2.Info.make({ + ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), + request: { headers: {}, body: {} }, + }), + Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-headless"), + access: "chatgpt-token", + refresh: "refresh", + expires: Date.now() + 60_000, + }), + ) + const request = LLM.request({ model: resolved, prompt: "Hello" }) + const headers = yield* resolved.route.auth.apply({ + request, + method: "POST", + url: "https://chatgpt.com/backend-api/codex/responses", + body: "{}", + headers: Headers.empty, + }) + + expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex") + expect(headers.authorization).toBe("Bearer chatgpt-token") + expect(headers["chatgpt-account-id"]).toBeUndefined() + }), + ) + + it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + ModelV2.Info.make({ + ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), + request: { headers: {}, body: {} }, + }), + Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("device"), + access: "oauth-token", + refresh: "refresh", + expires: Date.now() + 60_000, + metadata: { accountID: "acct_123" }, + }), + ) + const request = LLM.request({ model: resolved, prompt: "Hello" }) + const headers = yield* resolved.route.auth.apply({ + request, + method: "POST", + url: "https://openai.example/v1/responses", + body: "{}", + headers: Headers.empty, + }) + + expect(resolved.route.endpoint.baseURL).toBe("https://openai.example/v1") + expect(headers.authorization).toBe("Bearer oauth-token") + expect(headers["chatgpt-account-id"]).toBeUndefined() + }), + ) + it.effect("rejects catalog APIs without a native route", () => Effect.gen(function* () { const failure = yield* SessionRunnerModel.fromCatalogModel(