fix(core): route ChatGPT OAuth to the codex backend (#34843)
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import { createServer } from "node:http"
|
import { createServer } from "node:http"
|
||||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration"
|
||||||
import { define } from "@opencode-ai/plugin/v2/effect/plugin"
|
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 type { Scope } from "effect"
|
||||||
import { Credential } from "../../credential"
|
import { Credential } from "../../credential"
|
||||||
|
import { EventV2 } from "../../event"
|
||||||
import { InstallationVersion } from "../../installation/version"
|
import { InstallationVersion } from "../../installation/version"
|
||||||
import { Integration } from "../../integration"
|
import { Integration } from "../../integration"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
import { OauthCallbackPage } from "../../oauth/page"
|
import { OauthCallbackPage } from "../../oauth/page"
|
||||||
import { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
import type { PluginInternal } from "../internal"
|
import type { PluginInternal } from "../internal"
|
||||||
|
import { OpenAICodex } from "./openai-codex"
|
||||||
|
|
||||||
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||||
const issuer = "https://auth.openai.com"
|
const issuer = "https://auth.openai.com"
|
||||||
@@ -154,6 +156,18 @@ const headless = {
|
|||||||
export const OpenAIPlugin = define({
|
export const OpenAIPlugin = define({
|
||||||
id: "openai",
|
id: "openai",
|
||||||
effect: Effect.fn(function* (ctx) {
|
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) => {
|
yield* ctx.integration.transform((draft) => {
|
||||||
draft.method.update(browser)
|
draft.method.update(browser)
|
||||||
draft.method.update(headless)
|
draft.method.update(headless)
|
||||||
@@ -170,8 +184,30 @@ export const OpenAIPlugin = define({
|
|||||||
model.enabled = false
|
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(
|
yield* ctx.aisdk.sdk(
|
||||||
Effect.fn(function* (evt) {
|
Effect.fn(function* (evt) {
|
||||||
if (evt.package !== "@ai-sdk/openai") return
|
if (evt.package !== "@ai-sdk/openai") return
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Catalog } from "../../catalog"
|
|||||||
import { Credential } from "../../credential"
|
import { Credential } from "../../credential"
|
||||||
import { Integration } from "../../integration"
|
import { Integration } from "../../integration"
|
||||||
import { ModelV2 } from "../../model"
|
import { ModelV2 } from "../../model"
|
||||||
|
import { OpenAICodex } from "../../plugin/provider/openai-codex"
|
||||||
import { ProviderV2 } from "../../provider"
|
import { ProviderV2 } from "../../provider"
|
||||||
import { SessionSchema } from "../schema"
|
import { SessionSchema } from "../schema"
|
||||||
|
|
||||||
@@ -140,6 +141,21 @@ export const fromCatalogModel = (
|
|||||||
})
|
})
|
||||||
const key = apiKey(resolved, credential)
|
const key = apiKey(resolved, credential)
|
||||||
if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") {
|
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(
|
return Effect.succeed(
|
||||||
withDefaults(resolved, OpenAIResponses.route)
|
withDefaults(resolved, OpenAIResponses.route)
|
||||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect } from "bun:test"
|
|||||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
|
import { Credential } from "@opencode-ai/core/credential"
|
||||||
import { Integration } from "@opencode-ai/core/integration"
|
import { Integration } from "@opencode-ai/core/integration"
|
||||||
import { ModelV2 } from "@opencode-ai/core/model"
|
import { ModelV2 } from "@opencode-ai/core/model"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
@@ -27,6 +28,20 @@ function required<T>(value: T | undefined): T {
|
|||||||
return value
|
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[]) {
|
function fakeSelectorSdk(calls: string[]) {
|
||||||
const make = (method: string) => (id: string) => {
|
const make = (method: string) => (id: string) => {
|
||||||
calls.push(`${method}:${id}`)
|
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", () =>
|
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
|
|||||||
@@ -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", () =>
|
it.effect("rejects catalog APIs without a native route", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const failure = yield* SessionRunnerModel.fromCatalogModel(
|
const failure = yield* SessionRunnerModel.fromCatalogModel(
|
||||||
|
|||||||
Reference in New Issue
Block a user