Przeglądaj źródła

fix(core): route ChatGPT OAuth to the codex backend (#34843)

Kit Langton 1 miesiąc temu
rodzic
commit
674d08f9be

+ 42 - 0
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<string, unknown> | 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
+}

+ 37 - 1
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

+ 16 - 0
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) })

+ 89 - 0
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<T>(value: T | undefined): T {
   return value
 }
 
+function eventually<A>(
+  effect: Effect.Effect<A>,
+  predicate: (value: A) => boolean,
+  remaining = 1000,
+): Effect.Effect<A, Error> {
+  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

+ 95 - 0
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(