Skip to content

Commit 674d08f

Browse files
authored
fix(core): route ChatGPT OAuth to the codex backend (#34843)
1 parent 02f012f commit 674d08f

5 files changed

Lines changed: 279 additions & 1 deletion

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
export * as OpenAICodex from "./openai-codex"
2+
3+
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
4+
// codex routing lives in SessionRunnerModel.fromCatalogModel and catalog filtering
5+
// in OpenAIPlugin, sharing this module. Once the native provider packages land
6+
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
7+
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
8+
// plan-eligibility data for OpenAI today, but models other vendors' subscriptions
9+
// as dedicated providers (e.g. zai-coding-plan) - a future openai-chatgpt-plan
10+
// provider entry could replace the hardcoded rules with catalog data.
11+
12+
/** ChatGPT-plan requests must target the codex backend instead of the public API. */
13+
export const baseURL = "https://chatgpt.com/backend-api/codex"
14+
15+
const methodIDs: readonly string[] = ["chatgpt-browser", "chatgpt-headless"]
16+
17+
/** Structural credential shape so both core and plugin-facing credential types fit. */
18+
type CredentialLike = {
19+
readonly type: string
20+
readonly methodID?: string
21+
readonly metadata?: Record<string, unknown> | undefined
22+
}
23+
24+
export const isChatGPT = (credential: CredentialLike | undefined) =>
25+
credential?.type === "oauth" && credential.methodID !== undefined && methodIDs.includes(credential.methodID)
26+
27+
export const accountID = (credential: CredentialLike | undefined) => {
28+
if (!isChatGPT(credential)) return undefined
29+
const value = credential?.metadata?.accountID
30+
return typeof value === "string" ? value : undefined
31+
}
32+
33+
const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
34+
const disallowed = new Set(["gpt-5.5-pro"])
35+
36+
/** Which API model ids a ChatGPT subscription may call through the codex backend. */
37+
export const eligible = (apiID: string) => {
38+
if (allowed.has(apiID)) return true
39+
if (disallowed.has(apiID)) return false
40+
const match = apiID.match(/^gpt-(\d+\.\d+)/)
41+
return match ? Number.parseFloat(match[1]) > 5.4 : false
42+
}

packages/core/src/plugin/provider/openai.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import { createServer } from "node:http"
22
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
33
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
4-
import { Deferred, Effect } from "effect"
4+
import { Deferred, Effect, Semaphore, Stream } from "effect"
55
import type { Scope } from "effect"
66
import { Credential } from "../../credential"
7+
import { EventV2 } from "../../event"
78
import { InstallationVersion } from "../../installation/version"
89
import { Integration } from "../../integration"
910
import { ModelV2 } from "../../model"
1011
import { OauthCallbackPage } from "../../oauth/page"
1112
import { ProviderV2 } from "../../provider"
1213
import type { PluginInternal } from "../internal"
14+
import { OpenAICodex } from "./openai-codex"
1315

1416
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
1517
const issuer = "https://auth.openai.com"
@@ -154,6 +156,18 @@ const headless = {
154156
export const OpenAIPlugin = define({
155157
id: "openai",
156158
effect: Effect.fn(function* (ctx) {
159+
const events = yield* EventV2.Service
160+
const loading = Semaphore.makeUnsafe(1)
161+
let chatgpt = false
162+
163+
const load = Effect.fn("OpenAIPlugin.load")(function* () {
164+
const connection = yield* ctx.integration.connection.active("openai")
165+
const credential = connection
166+
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
167+
: undefined
168+
chatgpt = OpenAICodex.isChatGPT(credential)
169+
})
170+
157171
yield* ctx.integration.transform((draft) => {
158172
draft.method.update(browser)
159173
draft.method.update(headless)
@@ -170,8 +184,30 @@ export const OpenAIPlugin = define({
170184
model.enabled = false
171185
})
172186
}
187+
if (!chatgpt) return
188+
const item = evt.provider.get(ProviderV2.ID.openai)
189+
if (!item) return
190+
for (const model of item.models.values()) {
191+
// ChatGPT-plan tokens only authorize codex-eligible models, and the
192+
// subscription covers usage, so hide the rest and zero the cost.
193+
evt.model.update(item.provider.id, model.id, (draft) => {
194+
if (!OpenAICodex.eligible(draft.api.id)) {
195+
draft.enabled = false
196+
return
197+
}
198+
draft.cost = []
199+
})
200+
}
173201
}),
174202
)
203+
204+
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
205+
yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe(
206+
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
207+
Stream.runForEach(refresh),
208+
Effect.forkScoped({ startImmediately: true }),
209+
)
210+
yield* refresh().pipe(Effect.forkScoped)
175211
yield* ctx.aisdk.sdk(
176212
Effect.fn(function* (evt) {
177213
if (evt.package !== "@ai-sdk/openai") return

packages/core/src/session/runner/model.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Catalog } from "../../catalog"
1212
import { Credential } from "../../credential"
1313
import { Integration } from "../../integration"
1414
import { ModelV2 } from "../../model"
15+
import { OpenAICodex } from "../../plugin/provider/openai-codex"
1516
import { ProviderV2 } from "../../provider"
1617
import { SessionSchema } from "../schema"
1718

@@ -140,6 +141,21 @@ export const fromCatalogModel = (
140141
})
141142
const key = apiKey(resolved, credential)
142143
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
144+
// ChatGPT-plan OAuth tokens are not API-key credentials: the public API rejects
145+
// them, so requests must target the codex backend with the account header.
146+
if (OpenAICodex.isChatGPT(credential)) {
147+
const account = OpenAICodex.accountID(credential)
148+
return Effect.succeed(
149+
withDefaults(resolved, OpenAIResponses.route)
150+
.with({
151+
endpoint: { baseURL: OpenAICodex.baseURL },
152+
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
153+
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
154+
),
155+
})
156+
.model({ id: resolved.api.id }),
157+
)
158+
}
143159
return Effect.succeed(
144160
withDefaults(resolved, OpenAIResponses.route)
145161
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })

packages/core/test/plugin/provider-openai.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect } from "bun:test"
33
import type { LanguageModelV3 } from "@ai-sdk/provider"
44
import { Effect } from "effect"
55
import { Catalog } from "@opencode-ai/core/catalog"
6+
import { Credential } from "@opencode-ai/core/credential"
67
import { Integration } from "@opencode-ai/core/integration"
78
import { ModelV2 } from "@opencode-ai/core/model"
89
import { PluginV2 } from "@opencode-ai/core/plugin"
@@ -27,6 +28,20 @@ function required<T>(value: T | undefined): T {
2728
return value
2829
}
2930

31+
function eventually<A>(
32+
effect: Effect.Effect<A>,
33+
predicate: (value: A) => boolean,
34+
remaining = 1000,
35+
): Effect.Effect<A, Error> {
36+
return Effect.gen(function* () {
37+
const value = yield* effect
38+
if (predicate(value)) return value
39+
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
40+
yield* Effect.promise(() => Bun.sleep(1))
41+
return yield* eventually(effect, predicate, remaining - 1)
42+
})
43+
}
44+
3045
function fakeSelectorSdk(calls: string[]) {
3146
const make = (method: string) => (id: string) => {
3247
calls.push(`${method}:${id}`)
@@ -153,6 +168,80 @@ describe("OpenAIPlugin", () => {
153168
}),
154169
)
155170

171+
it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () =>
172+
Effect.gen(function* () {
173+
const catalog = yield* Catalog.Service
174+
const credentials = yield* Credential.Service
175+
yield* catalog.transform((catalog) => {
176+
const item = ProviderV2.Info.make({
177+
...ProviderV2.Info.empty(ProviderV2.ID.openai),
178+
api: { type: "aisdk", package: "@ai-sdk/openai" },
179+
})
180+
catalog.provider.update(item.id, (draft) => {
181+
draft.api = item.api
182+
})
183+
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => {
184+
model.cost = [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }]
185+
})
186+
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5-pro"), () => {})
187+
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
188+
})
189+
yield* credentials.create({
190+
integrationID: Integration.ID.make("openai"),
191+
value: Credential.OAuth.make({
192+
type: "oauth",
193+
methodID: Integration.MethodID.make("chatgpt-browser"),
194+
access: "chatgpt-token",
195+
refresh: "refresh",
196+
expires: Date.now() + 60_000,
197+
metadata: { accountID: "acct_123" },
198+
}),
199+
})
200+
yield* addPlugin()
201+
202+
const eligible = required(
203+
yield* eventually(
204+
catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5")),
205+
(model) => model?.cost.length === 0,
206+
),
207+
)
208+
expect(eligible.enabled).toBe(true)
209+
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5-pro"))).enabled).toBe(
210+
false,
211+
)
212+
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(false)
213+
}),
214+
)
215+
216+
it.effect("keeps the full OpenAI catalog under an API key connection", () =>
217+
Effect.gen(function* () {
218+
const catalog = yield* Catalog.Service
219+
const credentials = yield* Credential.Service
220+
yield* catalog.transform((catalog) => {
221+
const item = ProviderV2.Info.make({
222+
...ProviderV2.Info.empty(ProviderV2.ID.openai),
223+
api: { type: "aisdk", package: "@ai-sdk/openai" },
224+
})
225+
catalog.provider.update(item.id, (draft) => {
226+
draft.api = item.api
227+
})
228+
catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), () => {})
229+
catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {})
230+
})
231+
yield* credentials.create({
232+
integrationID: Integration.ID.make("openai"),
233+
value: Credential.Key.make({ type: "key", key: "sk-test" }),
234+
})
235+
yield* addPlugin()
236+
// The connection refresh is asynchronous; give it time to settle before
237+
// asserting nothing was filtered.
238+
yield* Effect.promise(() => Bun.sleep(25))
239+
240+
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5"))).enabled).toBe(true)
241+
expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(true)
242+
}),
243+
)
244+
156245
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
157246
Effect.gen(function* () {
158247
const catalog = yield* Catalog.Service

packages/core/test/session-runner-model.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,101 @@ describe("SessionRunnerModel", () => {
313313
}),
314314
)
315315

316+
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
317+
Effect.gen(function* () {
318+
const resolved = yield* SessionRunnerModel.fromCatalogModel(
319+
ModelV2.Info.make({
320+
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
321+
request: { headers: {}, body: {} },
322+
}),
323+
Credential.OAuth.make({
324+
type: "oauth",
325+
methodID: Integration.MethodID.make("chatgpt-browser"),
326+
access: "chatgpt-token",
327+
refresh: "refresh",
328+
expires: Date.now() + 60_000,
329+
metadata: { accountID: "acct_123" },
330+
}),
331+
)
332+
const request = LLM.request({ model: resolved, prompt: "Hello" })
333+
const headers = yield* resolved.route.auth.apply({
334+
request,
335+
method: "POST",
336+
url: "https://chatgpt.com/backend-api/codex/responses",
337+
body: "{}",
338+
headers: Headers.empty,
339+
})
340+
341+
expect(resolved.route).toMatchObject({
342+
id: "openai-responses",
343+
endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" },
344+
})
345+
expect(headers.authorization).toBe("Bearer chatgpt-token")
346+
expect(headers["chatgpt-account-id"]).toBe("acct_123")
347+
}),
348+
)
349+
350+
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
351+
Effect.gen(function* () {
352+
const resolved = yield* SessionRunnerModel.fromCatalogModel(
353+
ModelV2.Info.make({
354+
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
355+
request: { headers: {}, body: {} },
356+
}),
357+
Credential.OAuth.make({
358+
type: "oauth",
359+
methodID: Integration.MethodID.make("chatgpt-headless"),
360+
access: "chatgpt-token",
361+
refresh: "refresh",
362+
expires: Date.now() + 60_000,
363+
}),
364+
)
365+
const request = LLM.request({ model: resolved, prompt: "Hello" })
366+
const headers = yield* resolved.route.auth.apply({
367+
request,
368+
method: "POST",
369+
url: "https://chatgpt.com/backend-api/codex/responses",
370+
body: "{}",
371+
headers: Headers.empty,
372+
})
373+
374+
expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex")
375+
expect(headers.authorization).toBe("Bearer chatgpt-token")
376+
expect(headers["chatgpt-account-id"]).toBeUndefined()
377+
}),
378+
)
379+
380+
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
381+
Effect.gen(function* () {
382+
const resolved = yield* SessionRunnerModel.fromCatalogModel(
383+
ModelV2.Info.make({
384+
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
385+
request: { headers: {}, body: {} },
386+
}),
387+
Credential.OAuth.make({
388+
type: "oauth",
389+
methodID: Integration.MethodID.make("device"),
390+
access: "oauth-token",
391+
refresh: "refresh",
392+
expires: Date.now() + 60_000,
393+
metadata: { accountID: "acct_123" },
394+
}),
395+
)
396+
const request = LLM.request({ model: resolved, prompt: "Hello" })
397+
const headers = yield* resolved.route.auth.apply({
398+
request,
399+
method: "POST",
400+
url: "https://openai.example/v1/responses",
401+
body: "{}",
402+
headers: Headers.empty,
403+
})
404+
405+
expect(resolved.route.endpoint.baseURL).toBe("https://openai.example/v1")
406+
expect(headers.authorization).toBe("Bearer oauth-token")
407+
expect(headers["chatgpt-account-id"]).toBeUndefined()
408+
}),
409+
)
410+
316411
it.effect("rejects catalog APIs without a native route", () =>
317412
Effect.gen(function* () {
318413
const failure = yield* SessionRunnerModel.fromCatalogModel(

0 commit comments

Comments
 (0)