From 4d9090790b92d3f3074c5710cfaa8911d4be523c Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:48:51 -0500 Subject: [PATCH] feat(core): add separate ChatGPT token-sharing OAuth method (#52147) --- bun.lock | 1 + .../ai/test/provider/openai-responses.test.ts | 36 + packages/core/package.json | 1 + packages/core/src/plugin/provider.ts | 2 + packages/core/src/plugin/provider/chatgpt.ts | 511 ++++++++ .../core/test/plugin/provider-chatgpt.test.ts | 1057 +++++++++++++++++ 6 files changed, 1608 insertions(+) create mode 100644 packages/core/src/plugin/provider/chatgpt.ts create mode 100644 packages/core/test/plugin/provider-chatgpt.test.ts diff --git a/bun.lock b/bun.lock index f36aea209a4..cdf403165f7 100644 --- a/bun.lock +++ b/bun.lock @@ -377,6 +377,7 @@ "https-proxy-agent": "7.0.6", "ignore": "7.0.5", "immer": "11.1.4", + "jose": "6.0.11", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "tree-sitter-bash": "0.25.0", diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index cb982f88e89..280271a31e8 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -4996,6 +4996,42 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("retains the token-sharing HTTP 429 body for retry hooks", () => + Effect.gen(function* () { + const body = JSON.stringify({ error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" } }) + const error = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse(body, { status: 429, headers: { "content-type": "application/json" } }), + ), + Effect.flip, + ) + + expect(error).toMatchObject({ reason: { _tag: "RateLimit", http: { status: 429 }, body } }) + }), + ) + + it.effect("retains the token-sharing response.failed event for retry hooks", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents({ + type: "response.failed", + response: { + id: "resp_usage_limit", + error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" }, + }, + }), + ), + ), + Effect.flip, + ) + + expect(error).toMatchObject({ reason: { _tag: "RateLimit" } }) + expect(error.reason.body).toContain("subscription_sharing_usage_limit_exceeded") + }), + ) + it.effect("surfaces response.failed code when no nested message is present", () => Effect.gen(function* () { const error = yield* LLMClient.generate(request).pipe( diff --git a/packages/core/package.json b/packages/core/package.json index e7544467e41..73987283aec 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -129,6 +129,7 @@ "https-proxy-agent": "7.0.6", "ignore": "7.0.5", "immer": "11.1.4", + "jose": "6.0.11", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "tree-sitter-bash": "0.25.0", diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index fd415abf83f..121e570e615 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -1,6 +1,7 @@ import { AmazonBedrockPlugin } from "./provider/amazon-bedrock.js" import { AzurePlugin } from "./provider/azure.js" import { CerebrasPlugin } from "./provider/cerebras.js" +import { ChatGPTPlugin } from "./provider/chatgpt.js" import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway.js" import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai.js" import { CoherePlugin } from "./provider/cohere.js" @@ -35,6 +36,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [ AmazonBedrockPlugin, AzurePlugin, CerebrasPlugin, + ChatGPTPlugin, CloudflareAIGatewayPlugin, CloudflareWorkersAIPlugin, CoherePlugin, diff --git a/packages/core/src/plugin/provider/chatgpt.ts b/packages/core/src/plugin/provider/chatgpt.ts new file mode 100644 index 00000000000..665041fed17 --- /dev/null +++ b/packages/core/src/plugin/provider/chatgpt.ts @@ -0,0 +1,511 @@ +import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration" +import type { Context } from "@opencode/plugin/effect/plugin" +import { define } from "@opencode/plugin/effect/plugin" +import { Deferred, Duration, Effect, Option, Schema, Semaphore, Stream } from "effect" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import type { Server, ServerResponse } from "node:http" +import { App } from "../../app.js" +import { Credential } from "../../credential.js" +import { Bus } from "../../bus.js" +import { Integration } from "../../integration.js" +import { IntegrationConnection } from "../../integration/connection.js" +import { Model } from "../../model.js" +import { OauthCallbackPage } from "../../oauth/page.js" +import { Provider } from "../../provider.js" +import type { PluginInternal } from "../internal.js" + +// First-time sign-in registers a user-owned client; OpenAI returns its issued client ID on the callback. +const registrationClientID = "dynamic_agent_client" +const agentName = "OpenCode" +const issuer = "https://auth.openai.com" +const tokenURL = `${issuer}/api/accounts/oauth/token` +const resource = "https://api.openai.com/v1" +const tokenSharingScope = "chatgpt.tokens.use.direct" +const providerID = Provider.ID.openai +const integrationID = Integration.ID.make("openai") +const methodID = Integration.MethodID.make("chatgpt-token-sharing") +const modelCacheKey = (clientID: string) => `models:${clientID}` +const fallbackModels = new Set([ + "gpt-5.5", + "gpt-5.5-fast", + "gpt-5.6-luna", + "gpt-5.6-luna-fast", + "gpt-5.6-sol", + "gpt-5.6-sol-fast", + "gpt-5.6-terra", + "gpt-5.6-terra-fast", + "gpt-6-astra", + "gpt-6-astra-fast", +]) +const nonRetryableSharingCodes = [ + "subscription_sharing_usage_limit_exceeded", + "subscription_sharing_v2_user_not_eligible", + "subscription_sharing_unsupported_capability", + "subscription_sharing_v2_client_not_enabled", + "subscription_sharing_v2_route_not_supported", + "subscription_sharing_v2_invalid_user", +] +type Pkce = { + verifier: string + challenge: string +} + +type TokenResponse = { + access_token: string + refresh_token: string + id_token?: string + expires_in?: number + scope?: string +} + +const RemoteModel = Schema.Struct({ + slug: Schema.String, + display_name: Schema.String, + visibility: Schema.String, + supported_in_api: Schema.Boolean, + context_window: Schema.Int.check(Schema.isGreaterThan(1)), + input_modalities: Schema.Array(Schema.String), + supported_reasoning_levels: Schema.optional(Schema.Array(Schema.Struct({ effort: Schema.String }))), +}) +type RemoteModel = typeof RemoteModel.Type +const RemoteModels = Schema.Struct({ models: Schema.Array(RemoteModel) }) +const decodeCachedModels = Schema.decodeUnknownOption(Schema.Array(RemoteModel)) + +// Credential metadata is merged into provider settings; these keys are not OpenAI request options. +const decodeMetadata = Schema.decodeUnknownOption( + Schema.Struct({ + clientID: Schema.String, + scopes: Schema.optional(Schema.Array(Schema.String)), + models: Schema.optional(Schema.Array(RemoteModel)), + }), +) + +const signIn = (app: App.Info, savedClientID: () => string | undefined, storage: Context["storage"]) => + ({ + integrationID, + method: { + id: methodID, + type: "oauth", + label: "Sign in with ChatGPT", + }, + authorize: () => + Effect.gen(function* () { + const storedHostID = yield* storage.get("chatgpt-agent-host-id") + const hostID = typeof storedHostID === "string" ? storedHostID : `urn:uuid:${crypto.randomUUID()}` + if (typeof storedHostID !== "string") yield* storage.set("chatgpt-agent-host-id", hostID) + const pkce = yield* Effect.promise(generatePKCE) + const state = randomValue() + const nonce = randomValue() + const savedID = savedClientID() + const received = yield* Deferred.make<{ code: string; clientID?: string; response: ServerResponse }, Error>() + // Lazy so runtimes without a loopback listener (workerd) never evaluate node:http. + const { createServer } = yield* Effect.promise(() => import("node:http")) + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1") + if (url.pathname !== "/auth/callback") { + response.writeHead(404).end("Not found") + return + } + const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") + const authorizationCode = url.searchParams.get("code") + if (error) { + Effect.runFork(Deferred.fail(received, new Error(error))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(error, { provider: "ChatGPT" })) + return + } + if (!authorizationCode || url.searchParams.get("state") !== state) { + const message = authorizationCode ? "Invalid OAuth state" : "Missing authorization code" + Effect.runFork(Deferred.fail(received, new Error(message))) + response + .writeHead(400, { "Content-Type": "text/html" }) + .end(OauthCallbackPage.error(message, { provider: "ChatGPT" })) + return + } + if ( + !Effect.runSync( + Deferred.succeed(received, { + code: authorizationCode, + clientID: url.searchParams.get("client_id") ?? undefined, + response, + }), + ) + ) + response.writeHead(409).end("OAuth callback already received") + }) + const port = yield* listen(server) + yield* Effect.addFinalizer(() => Effect.sync(() => server.close())) + const redirect = `http://127.0.0.1:${port}/auth/callback` + return { + mode: "auto" as const, + url: authorizeURL(redirect, pkce, state, nonce, savedID, hostID), + instructions: "Complete authorization in your browser. This window will close automatically.", + callback: Effect.gen(function* () { + const result = yield* Deferred.await(received) + const respond = (error?: string) => + Effect.sync(() => + result.response + .writeHead(error ? 400 : 200, { "Content-Type": "text/html" }) + .end( + error + ? OauthCallbackPage.error(error, { provider: "ChatGPT" }) + : OauthCallbackPage.success({ provider: "ChatGPT" }), + ), + ) + return yield* Effect.gen(function* () { + // Reauthorization callbacks may omit the client ID; reuse the one this attempt started with. + const clientID = result.clientID ?? savedID + if (!clientID) + return yield* Effect.fail(new Error("ChatGPT sign-in did not return a client ID. Connect again.")) + const tokens = yield* exchange(result.code, clientID, redirect, pkce, app) + if (!tokens.scope?.split(" ").includes(tokenSharingScope)) + return yield* Effect.fail( + new Error( + "ChatGPT sign-in finished without token sharing. Sign in again and allow token sharing, or connect OpenAI with an API key.", + ), + ) + if (!tokens.id_token) return yield* Effect.fail(new Error("ChatGPT sign-in did not return an ID token.")) + yield* verifyIDToken(tokens.id_token, clientID, nonce) + return credential(tokens, clientID) + }).pipe( + Effect.tap(() => respond()), + Effect.tapError((error) => respond(error instanceof Error ? error.message : "ChatGPT sign-in failed")), + Effect.onInterrupt(() => Effect.sync(() => result.response.destroy())), + ) + }), + } + }), + refresh: (value) => refresh(value, app), + }) satisfies IntegrationOAuthMethodRegistration + +function listen(server: Server) { + return Effect.callback((resume) => { + const onError = (error: Error) => resume(Effect.fail(error)) + server.once("error", onError) + server.listen(0, "127.0.0.1", () => { + server.off("error", onError) + const address = server.address() + if (!address || typeof address === "string") return resume(Effect.fail(new Error("Missing OAuth callback port"))) + resume(Effect.succeed(address.port)) + }) + }) +} + +export const ChatGPTPlugin = define({ + id: "opencode.provider.chatgpt", + effect: Effect.fn(function* (ctx) { + const bus = yield* Bus.Service + const credentials = yield* Credential.Service + const loading = Semaphore.makeUnsafe(1) + let chatgpt: Credential.OAuth | undefined + let available: ReadonlyArray | undefined + let source: Effect.Success> + + const load = Effect.fn("ChatGPTPlugin.load")(function* () { + const previous = IntegrationConnection.key(source) + const connection = yield* ctx.integration.connection.active(integrationID) + const credential = connection + ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined)) + : undefined + chatgpt = credential?.type === "oauth" && credential.methodID === methodID ? credential : undefined + source = chatgpt ? connection : undefined + if (previous !== IntegrationConnection.key(source)) { + const metadata = Option.getOrUndefined(decodeMetadata(chatgpt?.metadata)) + const stored = metadata ? yield* ctx.storage.get(modelCacheKey(metadata.clientID)) : undefined + available = Option.getOrUndefined(decodeCachedModels(stored)) + } + }) + + yield* ctx.integration.transform((editor) => { + editor.method.update( + signIn(ctx.app, () => Option.getOrUndefined(decodeMetadata(chatgpt?.metadata))?.clientID, ctx.storage), + ) + }) + yield* Effect.forEach( + yield* credentials.list(Integration.ID.make("openai")), + (entry) => { + const value = entry.value + if (value.type !== "oauth" || value.methodID !== Integration.MethodID.make("chatgpt-browser")) + return Effect.void + const metadata = Option.getOrUndefined(decodeMetadata(value.metadata)) + if (!metadata) return Effect.void + return Effect.gen(function* () { + const saved = yield* ctx.storage.get(modelCacheKey(metadata.clientID)) + if (!Option.isSome(decodeCachedModels(saved)) && metadata.models) + yield* ctx.storage.set(modelCacheKey(metadata.clientID), metadata.models) + const { models: _, ...rest } = value.metadata ?? {} + yield* credentials.update(entry.id, { + value: Credential.OAuth.make({ ...value, methodID, metadata: rest }), + }) + }) + }, + { discard: true }, + ) + yield* load() + yield* ctx.session.hook( + "retry", + (event) => + Effect.sync(() => { + if (!chatgpt || !nonRetryableSharingCodes.some((code) => event.error.response?.body.includes(code))) return + event.decision = { retry: false } + }), + { providerID }, + ) + yield* ctx.provider.transform((providers) => { + const item = providers.get(providerID) + if (!item) return + if (!chatgpt || !source) return + providers.update(providerID, (provider) => { + provider.settings = Provider.mergeOverlay(provider.settings, { + baseURL: resource, + transport: "http", + compaction: { type: "summary" }, + }) + }) + const updated = providers.get(providerID) + if (!updated) return + providers.add({ + info: updated.provider, + models: available + ? deriveModels(available, Array.from(item.models.values())) + : Array.from(item.models.values()).filter((model) => fallbackModels.has(model.id)), + sourceConnection: source, + }) + }) + yield* ctx.model.transform((models) => { + for (const model of models.list(providerID)) { + models.update(model.providerID, model.id, (draft) => { + if (!chatgpt) return + // Token sharing does not support native /responses/compact. + draft.settings = { ...draft.settings, compaction: { type: "summary" } } + if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) { + draft.enabled = false + return + } + if (available && !available.some((remote) => remote.slug === (draft.modelID ?? draft.id))) { + draft.enabled = false + return + } + draft.cost = [] + }) + } + }) + const refreshModels = Effect.fn("ChatGPTPlugin.refreshModels")(function* () { + const connection = source + const credential = + connection?.type === "credential" + ? (yield* credentials.get(Credential.ID.make(connection.id)))?.value + : undefined + if (credential?.type !== "oauth" || credential.methodID !== methodID || credential.expires <= Date.now() + 60_000) + return + const metadata = Option.getOrUndefined(decodeMetadata(credential.metadata)) + if (!metadata) return + const models = yield* fetchModels(credential.access, ctx.app).pipe( + Effect.timeout(15_000), + Effect.catch(() => Effect.logWarning("failed to refresh ChatGPT models").pipe(Effect.as(undefined))), + ) + if (!models) return + yield* loading.withPermit( + Effect.gen(function* () { + if ( + IntegrationConnection.key(connection) !== IntegrationConnection.key(source) || + IntegrationConnection.key(connection) !== + IntegrationConnection.key(yield* ctx.integration.connection.active(integrationID)) + ) + return + if (JSON.stringify(models) === JSON.stringify(available)) return + yield* ctx.storage.set(modelCacheKey(metadata.clientID), models) + available = models + yield* ctx.provider.reload() + }), + ) + }) + const reload = () => + loading + .withPermit(load().pipe(Effect.andThen(ctx.provider.reload()))) + .pipe(Effect.andThen(refreshModels().pipe(Effect.forkScoped, Effect.asVoid))) + yield* bus.subscribe(Credential.Event.Switched).pipe( + Stream.filter((event) => event.data.integrationID === integrationID), + Stream.runForEach(reload), + Effect.forkScoped({ startImmediately: true }), + ) + yield* refreshModels().pipe(Effect.forkScoped) + yield* Effect.sleep(Duration.minutes(15)).pipe(Effect.andThen(refreshModels), Effect.forever, Effect.forkScoped) + }), +} satisfies PluginInternal.InternalPlugin) + +function headers(app: App.Info) { + return { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": App.useragent(app) } +} + +function exchange(code: string, clientID: string, redirect: string, pkce: Pkce, app: App.Info) { + return request(tokenURL, { + method: "POST", + headers: headers(app), + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientID, + code, + code_verifier: pkce.verifier, + redirect_uri: redirect, + resource, + }).toString(), + }) +} + +function refresh(value: Credential.OAuth, app: App.Info) { + return Effect.gen(function* () { + const metadata = Option.getOrUndefined(decodeMetadata(value.metadata)) + if (!metadata) + return yield* Effect.fail(new Error("This ChatGPT connection has no registered client ID. Connect again.")) + const tokens = yield* request(tokenURL, { + method: "POST", + headers: headers(app), + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: metadata.clientID, + refresh_token: value.refresh, + resource, + }).toString(), + }) + return credential(tokens, metadata.clientID, metadata.scopes) + }) +} + +export function fetchModels(token: string, app: App.Info, baseURL = resource) { + return Effect.gen(function* () { + const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient) + const response = yield* http.execute( + HttpClientRequest.get(`${baseURL}/models`).pipe( + HttpClientRequest.bearerToken(token), + HttpClientRequest.setHeader("User-Agent", App.useragent(app)), + ), + ) + const data = yield* HttpClientResponse.schemaBodyJson(RemoteModels)(response) + const models = data.models.filter((model) => model.visibility === "list" && model.supported_in_api) + if (!models.length) return yield* Effect.fail(new Error("No ChatGPT models are available for this account.")) + return models + }) +} + +export function deriveModels(remote: ReadonlyArray, existing: ReadonlyArray) { + const byID = new Map(remote.map((model) => [model.slug, model])) + const known = new Set(existing.map((model) => model.id)) + return [ + ...existing.flatMap((model) => { + const found = byID.get(model.modelID) + return found ? [deriveModel(found, model)] : [] + }), + ...remote + .filter((model) => !known.has(Model.ID.make(model.slug))) + .map((model) => deriveModel(model, Model.Info.default(Provider.ID.openai, Model.ID.make(model.slug)))), + ] +} + +function deriveModel(remote: RemoteModel, previous: Model.Info): Model.Info { + return { + ...previous, + name: previous.id === previous.modelID ? remote.display_name : previous.name, + package: previous.package ?? "@opencode/ai/providers/openai", + capabilities: { + ...previous.capabilities, + input: remote.input_modalities.filter((modality) => modality === "text" || modality === "image"), + }, + variants: [ + ...previous.variants.filter((variant) => typeof variant.settings?.reasoningEffort !== "string"), + ...(remote.supported_reasoning_levels ?? []).map( + ({ effort }) => + previous.variants.find( + (variant) => variant.id === effort && variant.settings?.reasoningEffort === effort, + ) ?? { id: Model.VariantID.make(effort), settings: { reasoningEffort: effort } }, + ), + ], + limit: { context: remote.context_window, output: previous.limit.output }, + } +} + +export function verifyIDToken( + token: string, + clientID: string, + nonce: string, + jwksURL = new URL(`${issuer}/.well-known/jwks.json`), +) { + return Effect.tryPromise({ + try: async () => { + const { createRemoteJWKSet, jwtVerify } = await import("jose") + const { payload } = await jwtVerify(token, createRemoteJWKSet(jwksURL), { + issuer, + audience: clientID, + algorithms: ["RS256"], + requiredClaims: ["exp", "nonce", "sub"], + }) + if (typeof payload.sub !== "string" || !payload.sub.trim()) throw new Error("ID token subject is missing") + if (payload.nonce !== nonce) throw new Error("ID token nonce does not match this sign-in attempt") + }, + catch: (cause) => new Error("ChatGPT sign-in returned an invalid ID token.", { cause }), + }) +} + +function request(url: string, init: RequestInit) { + return Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(url, { ...init, signal }) + if (!response.ok) throw new Error(`Request failed: ${response.status}`) + return response.json() as Promise + }, + catch: (cause) => cause, + }) +} + +function credential(tokens: TokenResponse, clientID: string, scopes?: ReadonlyArray) { + return Credential.OAuth.make({ + type: "oauth", + methodID, + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + metadata: { clientID, scopes: tokens.scope?.split(" ").filter(Boolean) ?? scopes ?? [] }, + }) +} + +async function generatePKCE(): Promise { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("") + const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))) + return { verifier, challenge } +} + +function randomValue() { + return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) +} + +function base64UrlEncode(buffer: ArrayBuffer) { + return Buffer.from(buffer).toString("base64url") +} + +function authorizeURL( + redirect: string, + pkce: Pkce, + state: string, + nonce: string, + savedID: string | undefined, + hostID: string, +) { + return `${issuer}/api/accounts/authorize?${new URLSearchParams({ + client_id: savedID ?? registrationClientID, + ...(savedID ? {} : { agent_name_hint: agentName }), + ext_agent_host_id: hostID, + // Enable only for user-requested consent retries after OpenAI confirms deployment; + // ordinary sign-ins must not force reconsent. + // force_reconsent: "true", + response_type: "code", + redirect_uri: redirect, + scope: `openid profile email offline_access resource.invoke ${tokenSharingScope}`, + resource, + state, + nonce, + code_challenge_method: "S256", + code_challenge: pkce.challenge, + })}` +} diff --git a/packages/core/test/plugin/provider-chatgpt.test.ts b/packages/core/test/plugin/provider-chatgpt.test.ts new file mode 100644 index 00000000000..7d5965b2d46 --- /dev/null +++ b/packages/core/test/plugin/provider-chatgpt.test.ts @@ -0,0 +1,1057 @@ +import { Money } from "@opencode/schema/money" +import { Agent } from "@opencode/schema/agent" +import { Session } from "@opencode/core/session" +import { OpenAIResponses } from "@opencode/ai/protocols/openai-responses" +import { AIError, HttpContext, RateLimitError } from "@opencode/ai" +import { classifyProviderFailure } from "@opencode/ai/provider-error" +import { describe, expect } from "bun:test" +import { ConfigProvider, DateTime, Deferred, Effect, Schedule } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { exportJWK, generateKeyPair, SignJWT } from "jose" +import { App } from "@opencode/core/app" +import { Credential } from "@opencode/core/credential" +import { Integration } from "@opencode/core/integration" +import { KV } from "@opencode/core/kv" +import { Location } from "@opencode/core/location" +import { Model } from "@opencode/core/model" +import { ModelResolver } from "@opencode/core/model-resolver" +import { Plugin } from "@opencode/core/plugin" +import { PluginHost } from "@opencode/core/plugin/host" +import { PluginHooks } from "@opencode/core/plugin/hooks" +import { GithubCopilotPlugin } from "@opencode/core/plugin/provider/github-copilot" +import { ChatGPTPlugin, fetchModels, verifyIDToken } from "@opencode/core/plugin/provider/chatgpt" +import { OpenAIPlugin } from "@opencode/core/plugin/provider/openai" +import { Project } from "@opencode/core/project" +import { Provider } from "@opencode/core/provider" +import { AbsolutePath } from "@opencode/core/schema" +import { SessionModelRequest } from "@opencode/core/session/model-request" +import { SessionModelTransport } from "@opencode/core/session/model-transport" +import { SessionRunnerModel } from "@opencode/core/session/runner/model" +import { SessionRunnerRetry } from "@opencode/core/session/runner/retry" +import { toSessionError } from "@opencode/core/session/to-session-error" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const it = testEffect(PluginTestLayer) + +const unavailableModels = HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status: 503 }))), +) + +const addPlugin = Effect.fn(function* (http = unavailableModels) { + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin, ChatGPTPlugin.id) + yield* ChatGPTPlugin.effect(host).pipe(Effect.provideService(HttpClient.HttpClient, http)) +}) + +const addLegacyPlugin = Effect.fn(function* () { + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin, OpenAIPlugin.id) + yield* OpenAIPlugin.effect(host) +}) + +const addGithubCopilotPlugin = Effect.fn(function* () { + const plugin = yield* Plugin.Service + const host = yield* PluginHost.make(plugin) + yield* GithubCopilotPlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +const authorize = Effect.fn(function* () { + const integrations = yield* Integration.Service + const integrationID = Integration.ID.make("openai") + const attempt = yield* integrations.oauth.connect({ + integrationID, + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + }) + yield* integrations.oauth.cancel({ integrationID, attemptID: attempt.attemptID }) + return new URL(attempt.url) +}) + +const request = Effect.fn(function* (providerID: Provider.ID, baseURL: string) { + const hooks = yield* PluginHooks.Service + const event = yield* hooks.trigger("session", "model.request", { + sessionID: Session.ID.make("ses_test"), + agent: Agent.ID.make("build"), + model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }), + kind: "primary", + baseURL, + headers: {}, + }) + return { + baseURL: event.baseURL, + headers: event.headers, + hasHttpHooks: + (yield* hooks.has("session", "http.request", providerID)) || + (yield* hooks.has("session", "http.response", providerID)), + } +}) + +describe("ChatGPTPlugin", () => { + it.live("loads only visible API-supported ChatGPT models", () => + Effect.gen(function* () { + const server = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch: (request) => { + if (new URL(request.url).pathname === "/error/models") return new Response(null, { status: 503 }) + if (new URL(request.url).pathname === "/empty/models") return Response.json({ models: [] }) + expect(request.headers.get("authorization")).toBe("Bearer test-token") + expect(request.headers.has("x-openai-chatpass-test")).toBe(false) + return Response.json({ + models: [ + { + slug: "gpt-5.6-sol", + display_name: "GPT-5.6-Sol", + visibility: "list", + supported_in_api: true, + context_window: 272_000, + input_modalities: ["text", "image"], + supported_reasoning_levels: [{ effort: "low" }], + model_messages: { base_instructions: "not retained" }, + }, + { + slug: "gpt-reserve", + display_name: "GPT-Reserve", + visibility: "hide", + supported_in_api: true, + context_window: 272_000, + input_modalities: ["text"], + }, + { + slug: "not-in-api", + display_name: "Not in API", + visibility: "list", + supported_in_api: false, + context_window: 272_000, + input_modalities: ["text"], + }, + ], + }) + }, + }), + ), + (server) => Effect.sync(() => server.stop()), + ) + const baseURL = `http://localhost:${server.port}` + expect(yield* fetchModels("test-token", App.make(), baseURL)).toEqual([ + { + slug: "gpt-5.6-sol", + display_name: "GPT-5.6-Sol", + visibility: "list", + supported_in_api: true, + context_window: 272_000, + input_modalities: ["text", "image"], + supported_reasoning_levels: [{ effort: "low" }], + }, + ]) + expect(yield* Effect.flip(fetchModels("test-token", App.make(), `${baseURL}/empty`))).toBeInstanceOf(Error) + expect(yield* Effect.flip(fetchModels("test-token", App.make(), `${baseURL}/error`))).toBeInstanceOf(Error) + }), + ) + + it.live("verifies a signed ID token against OpenAI's issuer, client ID, expiry, and sign-in nonce", () => + Effect.gen(function* () { + const keys = yield* Effect.promise(() => generateKeyPair("RS256")) + const publicKey = yield* Effect.promise(() => exportJWK(keys.publicKey)) + const server = yield* Effect.acquireRelease( + Effect.sync(() => + Bun.serve({ + port: 0, + fetch: () => Response.json({ keys: [{ ...publicKey, kid: "sign-in", alg: "RS256", use: "sig" }] }), + }), + ), + (server) => Effect.sync(() => server.stop()), + ) + const jwksURL = new URL(`http://localhost:${server.port}/jwks`) + const sign = (nonce: string, audience: string, expires: number, key = keys.privateKey) => + new SignJWT({ nonce }) + .setProtectedHeader({ alg: "RS256", kid: "sign-in" }) + .setIssuer("https://auth.openai.com") + .setAudience(audience) + .setSubject("user_123") + .setExpirationTime(expires) + .sign(key) + const expires = Math.floor(Date.now() / 1000) + 60 + const valid = yield* Effect.promise(() => sign("expected", "oaiapp_issued", expires)) + yield* verifyIDToken(valid, "oaiapp_issued", "expected", jwksURL) + const missingSubject = yield* Effect.promise(() => + new SignJWT({ nonce: "expected" }) + .setProtectedHeader({ alg: "RS256", kid: "sign-in" }) + .setIssuer("https://auth.openai.com") + .setAudience("oaiapp_issued") + .setExpirationTime(expires) + .sign(keys.privateKey), + ) + expect(yield* Effect.flip(verifyIDToken(missingSubject, "oaiapp_issued", "expected", jwksURL))).toBeInstanceOf( + Error, + ) + const blankSubject = yield* Effect.promise(() => + new SignJWT({ nonce: "expected" }) + .setProtectedHeader({ alg: "RS256", kid: "sign-in" }) + .setIssuer("https://auth.openai.com") + .setAudience("oaiapp_issued") + .setSubject(" ") + .setExpirationTime(expires) + .sign(keys.privateKey), + ) + expect(yield* Effect.flip(verifyIDToken(blankSubject, "oaiapp_issued", "expected", jwksURL))).toBeInstanceOf( + Error, + ) + expect(yield* Effect.flip(verifyIDToken(valid, "oaiapp_issued", "wrong", jwksURL))).toBeInstanceOf(Error) + expect(yield* Effect.flip(verifyIDToken(valid, "wrong-client", "expected", jwksURL))).toBeInstanceOf(Error) + const wrongIssuer = yield* Effect.promise(() => + new SignJWT({ nonce: "expected" }) + .setProtectedHeader({ alg: "RS256", kid: "sign-in" }) + .setIssuer("https://someone-else.example") + .setAudience("oaiapp_issued") + .setSubject("user_123") + .setExpirationTime(expires) + .sign(keys.privateKey), + ) + expect(yield* Effect.flip(verifyIDToken(wrongIssuer, "oaiapp_issued", "expected", jwksURL))).toBeInstanceOf(Error) + const noExpiry = yield* Effect.promise(() => + new SignJWT({ nonce: "expected" }) + .setProtectedHeader({ alg: "RS256", kid: "sign-in" }) + .setIssuer("https://auth.openai.com") + .setAudience("oaiapp_issued") + .setSubject("user_123") + .sign(keys.privateKey), + ) + expect(yield* Effect.flip(verifyIDToken(noExpiry, "oaiapp_issued", "expected", jwksURL))).toBeInstanceOf(Error) + const expired = yield* Effect.promise(() => sign("expected", "oaiapp_issued", expires - 120)) + expect(yield* Effect.flip(verifyIDToken(expired, "oaiapp_issued", "expected", jwksURL))).toBeInstanceOf(Error) + const other = yield* Effect.promise(() => generateKeyPair("RS256")) + const forged = yield* Effect.promise(() => sign("expected", "oaiapp_issued", expires, other.privateKey)) + expect(yield* Effect.flip(verifyIDToken(forged, "oaiapp_issued", "expected", jwksURL))).toBeInstanceOf(Error) + }), + ) + + it.effect("registers the ChatGPT OAuth method", () => + Effect.gen(function* () { + yield* addPlugin() + const integrations = yield* Integration.Service + expect((yield* integrations.get(Integration.ID.make("openai")))?.methods).toEqual([ + { + id: Integration.MethodID.make("chatgpt-token-sharing"), + type: "oauth", + label: "Sign in with ChatGPT", + }, + ]) + }), + ) + + it.effect("keeps the Codex browser and headless methods alongside token sharing", () => + Effect.gen(function* () { + yield* addPlugin() + yield* addLegacyPlugin() + const integrations = yield* Integration.Service + expect((yield* integrations.get(Integration.ID.make("openai")))?.methods.map((method) => method.type === "oauth" && method.id)).toEqual([ + Integration.MethodID.make("chatgpt-token-sharing"), + Integration.MethodID.make("chatgpt-browser"), + Integration.MethodID.make("chatgpt-headless"), + ]) + }), + ) + + for (const source of ["http", "stream"] as const) { + it.effect(`stops retries for ChatGPT usage limits from ${source} failures`, () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + access: "chatgpt-token", + refresh: "refresh", + expires: Date.now() + 60 * 60_000, + metadata: { clientID: "oaiapp_issued" }, + }), + }) + yield* addPlugin() + const hooks = yield* PluginHooks.Service + const body = + source === "http" + ? JSON.stringify({ error: { code: "subscription_sharing_usage_limit_exceeded" } }) + : JSON.stringify({ + type: "response.failed", + response: { error: { code: "subscription_sharing_usage_limit_exceeded" } }, + }) + const cause = new AIError({ + reason: new RateLimitError({ + message: "Rate limit exceeded", + body, + ...(source === "http" + ? { http: new HttpContext({ url: "https://api.openai.com/v1/responses", status: 429, headers: {} }) } + : {}), + }), + }) + const decide = yield* SessionRunnerRetry.policy(Session.ID.make("ses_usage_limit")) + const input = { + cause, + error: toSessionError(cause), + agent: Agent.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }), + hook: (event: PluginHooks.Domains["session"]["retry"]) => + hooks.trigger("session", "retry", event).pipe(Effect.asVoid), + retry: SessionRunnerRetry.isRetryable(cause), + } + expect(input.error.response?.body).toBe(body) + expect(input.retry).toBe(true) + expect(yield* decide(input)).toEqual({ retry: false }) + + const other = new AIError({ reason: new RateLimitError({ message: "Rate limit exceeded", body: "{}" }) }) + expect(yield* decide({ ...input, cause: other, error: toSessionError(other) })).toMatchObject({ retry: true }) + }), + ) + } + + it.effect("leaves API-key OpenAI retries alone", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.Key.make({ type: "key", key: "sk-test" }), + }) + yield* addPlugin() + const hooks = yield* PluginHooks.Service + const event = yield* hooks.trigger("session", "retry", { + sessionID: Session.ID.make("ses_api_key_retry"), + agent: Agent.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }), + error: { + type: "provider.rate-limit", + message: "Rate limit exceeded", + status: 429, + response: { body: '{"error":{"code":"subscription_sharing_usage_limit_exceeded"}}' }, + }, + attempt: 2, + decision: { retry: true, delay: 1000 }, + }) + expect(event.decision).toEqual({ retry: true, delay: 1000 }) + }), + ) + + it.effect("stops deterministic ChatGPT stream failures while retrying temporary unavailability", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + access: "chatgpt-token", + refresh: "refresh", + expires: Date.now() + 60 * 60_000, + metadata: { clientID: "oaiapp_issued" }, + }), + }) + yield* addPlugin() + const hooks = yield* PluginHooks.Service + const decide = yield* SessionRunnerRetry.policy(Session.ID.make("ses_sharing_errors")) + const cases = [ + ["subscription_sharing_v2_user_not_eligible", false], + ["subscription_sharing_unsupported_capability", false], + ["subscription_sharing_v2_client_not_enabled", false], + ["subscription_sharing_v2_route_not_supported", false], + ["subscription_sharing_v2_invalid_user", false], + ["subscription_sharing_usage_unavailable", true], + ["subscription_sharing_v2_user_unavailable", true], + ] as const + for (const [code, retry] of cases) { + const cause = new AIError({ + reason: classifyProviderFailure({ + message: "Request failed", + rawBody: JSON.stringify({ type: "response.failed", response: { error: { code } } }), + }), + }) + expect(SessionRunnerRetry.isRetryable(cause)).toBe(true) + const decision = yield* decide({ + cause, + error: toSessionError(cause), + agent: Agent.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }), + hook: (event) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid), + retry: true, + }) + expect(decision.retry).toBe(retry) + } + }), + ) + + it.effect("registers a new ChatGPT agent on first sign-in with a loopback callback", () => + Effect.gen(function* () { + yield* addPlugin() + const url = yield* authorize() + const redirect = new URL(url.searchParams.get("redirect_uri") ?? "") + expect(`${url.origin}${url.pathname}`).toBe("https://auth.openai.com/api/accounts/authorize") + expect(Object.fromEntries(url.searchParams)).toMatchObject({ + client_id: "dynamic_agent_client", + agent_name_hint: "OpenCode", + response_type: "code", + scope: "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct", + resource: "https://api.openai.com/v1", + code_challenge_method: "S256", + }) + expect(url.searchParams.get("state")).toBeTruthy() + expect(url.searchParams.get("nonce")).toBeTruthy() + expect(url.searchParams.get("code_challenge")).toBeTruthy() + const hostID = url.searchParams.get("ext_agent_host_id") + expect(hostID).toMatch(/^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + const kv = yield* KV.Service + const storage = PluginHost.storage(kv, ChatGPTPlugin.id) + expect(yield* storage.get("chatgpt-agent-host-id")).toBe(hostID) + expect((yield* authorize()).searchParams.get("ext_agent_host_id")).toBe(hostID) + expect(redirect.hostname).toBe("127.0.0.1") + expect(redirect.pathname).toBe("/auth/callback") + expect(Number(redirect.port)).toBeGreaterThan(0) + }), + ) + + it.live("waits to show browser success until the sign-in callback succeeds", () => + Effect.gen(function* () { + yield* addPlugin() + const integrations = yield* Integration.Service + const attempt = yield* integrations.oauth.connect({ + integrationID: Integration.ID.make("openai"), + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + }) + const authorization = new URL(attempt.url) + const callback = new URL(authorization.searchParams.get("redirect_uri") ?? "") + callback.searchParams.set("code", "test-code") + callback.searchParams.set("state", authorization.searchParams.get("state") ?? "") + const response = yield* Effect.promise(() => fetch(callback)) + expect(response.status).toBe(400) + expect(yield* Effect.promise(() => response.text())).toContain("did not return a client ID") + }), + ) + + it.effect("reauthorizes with the client ID issued to an existing ChatGPT connection", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const kv = yield* KV.Service + const hostID = "urn:uuid:00000000-0000-4000-8000-000000000000" + yield* PluginHost.storage(kv, ChatGPTPlugin.id).set("chatgpt-agent-host-id", hostID) + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + access: "chatgpt-token", + refresh: "refresh", + expires: Date.now() + 60 * 60_000, + metadata: { clientID: "oaiapp_issued" }, + }), + }) + yield* addPlugin() + const url = yield* authorize() + expect(url.searchParams.get("client_id")).toBe("oaiapp_issued") + expect(url.searchParams.has("agent_name_hint")).toBe(false) + expect(url.searchParams.get("ext_agent_host_id")).toBe(hostID) + expect(new URL(url.searchParams.get("redirect_uri") ?? "").hostname).toBe("127.0.0.1") + }), + ) + + it.effect("moves only token-sharing credentials to the new method ID and caches their saved models", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + const kv = yield* KV.Service + const legacy = yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-browser"), + access: "codex-token", + refresh: "codex-refresh", + expires: Date.now() + 60 * 60_000, + metadata: { accountID: "acct_123" }, + }), + }) + const snapshot = [ + { + slug: "gpt-5.5", + display_name: "GPT-5.5", + visibility: "list", + supported_in_api: true, + context_window: 272_000, + input_modalities: ["text"], + }, + ] + const sharing = yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-browser"), + access: "sharing-token", + refresh: "sharing-refresh", + expires: Date.now() + 60 * 60_000, + metadata: { clientID: "oaiapp_issued", models: snapshot }, + }), + }) + + yield* addPlugin() + expect((yield* credentials.get(legacy.id))?.value).toMatchObject({ methodID: "chatgpt-browser" }) + expect((yield* credentials.get(sharing.id))?.value).toMatchObject({ + methodID: "chatgpt-token-sharing", + access: "sharing-token", + metadata: { clientID: "oaiapp_issued" }, + }) + expect(yield* PluginHost.storage(kv, ChatGPTPlugin.id).get("models:oaiapp_issued")).toEqual(snapshot) + }), + ) + + it.effect("merges account models with catalog metadata and leaves input budgeting to compaction", () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const models = yield* Model.Service + const credentials = yield* Credential.Service + const kv = yield* KV.Service + yield* catalog.transform((editor) => { + editor.update(Provider.ID.openai, (provider) => { + provider.package = "@opencode/ai/providers/openai" + }) + editor.models.update(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"), (model) => { + model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } + model.capabilities = { tools: true, input: ["text", "image", "pdf"], output: ["text"] } + model.variants = [ + { id: Model.VariantID.make("low"), settings: { reasoningEffort: "low" } }, + { id: Model.VariantID.make("high"), settings: { reasoningEffort: "high" } }, + ] + }) + editor.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5"), () => {}) + }) + yield* PluginHost.storage(kv, ChatGPTPlugin.id).set("models:oaiapp_issued", [ + { + slug: "gpt-5.6-sol", + display_name: "GPT-5.6-Sol", + visibility: "list", + supported_in_api: true, + context_window: 272_000, + input_modalities: ["text", "image"], + supported_reasoning_levels: [{ effort: "low" }, { effort: "ultra" }], + }, + { + slug: "gpt-6-future", + display_name: "GPT-6-Future", + visibility: "list", + supported_in_api: true, + context_window: 272_000, + input_modalities: ["text"], + supported_reasoning_levels: [], + }, + ]) + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + access: "chatgpt-token", + refresh: "refresh", + expires: Date.now() + 60 * 60_000, + metadata: { clientID: "oaiapp_issued" }, + }), + }) + yield* addPlugin() + + const available = (yield* models.available()).filter((model) => model.providerID === Provider.ID.openai) + expect(available.map((model) => model.id).sort()).toEqual([ + Model.ID.make("gpt-5.6-sol"), + Model.ID.make("gpt-6-future"), + ]) + const sol = required(available.find((model) => model.id === "gpt-5.6-sol")) + expect(sol.name).toBe("GPT-5.6-Sol") + expect(sol.capabilities.input).toEqual(["text", "image"]) + expect(sol.variants.map((variant) => variant.id)).toEqual([ + Model.VariantID.make("low"), + Model.VariantID.make("ultra"), + ]) + expect(sol.limit).toEqual({ context: 272_000, output: 128_000 }) + expect(sol.cost).toEqual([]) + expect(sol.settings?.compaction).toEqual({ type: "summary" }) + const future = required(available.find((model) => model.id === "gpt-6-future")) + expect(future.name).toBe("GPT-6-Future") + expect(future.package).toBe("@opencode/ai/providers/openai") + expect(future.limit).toEqual({ context: 272_000, output: 32_000 }) + }), + ) + + it.live("shows catalog models while account models refresh in the background and saves them to KV", () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const models = yield* Model.Service + const credentials = yield* Credential.Service + const kv = yield* KV.Service + yield* catalog.transform((editor) => { + editor.update(Provider.ID.openai, (provider) => { + provider.package = "@opencode/ai/providers/openai" + }) + for (const id of ["gpt-5.5", "gpt-6-astra", "gpt-6-sol"]) + editor.models.update(Provider.ID.openai, Model.ID.make(id), () => {}) + }) + const requested = yield* Deferred.make() + const release = yield* Deferred.make() + const remote = [ + { + slug: "gpt-6-future", + display_name: "GPT-6-Future", + visibility: "list", + supported_in_api: true, + context_window: 272_000, + input_modalities: ["text"], + }, + ] + const http = HttpClient.make((request) => + Effect.gen(function* () { + expect(request.url).toBe("https://api.openai.com/v1/models") + expect(request.headers.authorization).toBe("Bearer chatgpt-token") + yield* Deferred.succeed(requested, undefined) + yield* Deferred.await(release) + return HttpClientResponse.fromWeb(request, Response.json({ models: remote })) + }), + ) + yield* addPlugin(http) + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + access: "chatgpt-token", + refresh: "refresh", + expires: Date.now() + 60 * 60_000, + metadata: { clientID: "oaiapp_issued" }, + }), + }) + yield* Deferred.await(requested).pipe(Effect.timeout("2 seconds")) + expect((yield* models.available()).filter((model) => model.providerID === Provider.ID.openai).map((model) => model.id)).toEqual([ + Model.ID.make("gpt-5.5"), + Model.ID.make("gpt-6-astra"), + ]) + const storage = PluginHost.storage(kv, ChatGPTPlugin.id) + expect(yield* storage.get("models:oaiapp_issued")).toBeUndefined() + + yield* Deferred.succeed(release, undefined) + const available = yield* models.available().pipe( + Effect.repeat({ + until: (current) => current.some((model) => model.providerID === Provider.ID.openai && model.id === "gpt-6-future"), + schedule: Schedule.spaced("10 millis"), + }), + Effect.timeout("2 seconds"), + ) + expect(available.filter((model) => model.providerID === Provider.ID.openai).map((model) => model.id)).toEqual([ + Model.ID.make("gpt-6-future"), + ]) + expect(yield* storage.get("models:oaiapp_issued")).toEqual(remote) + }), + ) + + it.effect("routes ChatGPT over HTTP with only the listed catalog fallback models", () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const models = yield* Model.Service + const credentials = yield* Credential.Service + yield* catalog.transform((catalog) => { + catalog.update(Provider.ID.openai, (draft) => { + draft.package = "@opencode/ai/providers/openai" + draft.settings = { transport: "websocket" } + }) + catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5"), (model) => { + model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } + model.cost = [ + { + input: Money.USDPerMillionTokens.make(1), + output: Money.USDPerMillionTokens.make(2), + cache: { + read: Money.USDPerMillionTokens.make(0.1), + write: Money.USDPerMillionTokens.zero, + }, + }, + ] + }) + catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"), () => {}) + catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.4"), (model) => { + model.limit = { context: 1_050_000, input: 922_000, output: 64_000 } + }) + catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"), (model) => { + model.modelID = Model.ID.make("gpt-5.4") + model.body = { reasoning: { mode: "pro" } } + }) + catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.6"), () => {}) + catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"), (model) => { + model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } + }) + catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-4.1"), () => {}) + catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-6-astra"), (model) => { + model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } + }) + catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.10"), (model) => { + model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } + }) + catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5"), () => {}) + catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"), () => {}) + catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-4.99"), () => {}) + for (const id of [ + "gpt-5.5-fast", + "gpt-5.6-luna", + "gpt-5.6-luna-fast", + "gpt-5.6-sol-fast", + "gpt-5.6-terra", + "gpt-5.6-terra-fast", + "gpt-6-astra-fast", + "gpt-6-sol", + "gpt-6.1-sol", + ]) + catalog.models.update(Provider.ID.openai, Model.ID.make(id), () => {}) + }) + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + access: "chatgpt-token", + refresh: "refresh", + expires: Date.now() + 60_000, + }), + }) + yield* addPlugin() + + const direct = yield* request(Provider.ID.openai, "https://api.openai.com/v1") + + const provider = required(yield* catalog.get(Provider.ID.openai)) + expect(provider.package).toBe("@opencode/ai/providers/openai") + expect(provider.settings?.transport).toBe("http") + expect(provider.settings?.baseURL).toBe("https://api.openai.com/v1") + expect(provider.headers).not.toHaveProperty("x-openai-chatpass-test") + expect(direct.baseURL).toBe("https://api.openai.com/v1") + expect(direct.headers).toEqual({}) + expect(direct.hasHttpHooks).toBe(false) + const eligible = required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) + expect(eligible.package).toBe("@opencode/ai/providers/openai") + expect(eligible.headers).not.toHaveProperty("x-openai-chatpass-test") + expect(eligible.cost).toEqual([]) + expect(eligible.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) + expect(eligible.enabled).toBe(true) + const gpt56 = required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol"))) + expect(gpt56.enabled).toBe(true) + expect(gpt56.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) + expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-6-astra"))).enabled).toBe(true) + for (const id of [ + "gpt-5.5-pro", + "gpt-5.4-pro", + "gpt-5.4", + "gpt-5.6", + "gpt-4.1", + "gpt-5.10", + "gpt-5", + "gpt-5.04-astra", + "gpt-4.99", + "gpt-6-sol", + "gpt-6.1-sol", + ]) + expect(yield* models.get(Provider.ID.openai, Model.ID.make(id))).toBeUndefined() + expect((yield* models.available()).filter((model) => model.providerID === Provider.ID.openai).map((model) => model.id).sort()).toEqual( + [ + "gpt-5.5", + "gpt-5.5-fast", + "gpt-5.6-luna", + "gpt-5.6-luna-fast", + "gpt-5.6-sol", + "gpt-5.6-sol-fast", + "gpt-5.6-terra", + "gpt-5.6-terra-fast", + "gpt-6-astra", + "gpt-6-astra-fast", + ].map((id) => Model.ID.make(id)), + ) + }), + ) + + it.effect("keeps the full OpenAI catalog under an API key connection", () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const models = yield* Model.Service + const credentials = yield* Credential.Service + yield* catalog.transform((catalog) => { + catalog.update(Provider.ID.openai, (draft) => { + draft.package = "@opencode/ai/providers/openai" + }) + catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5"), (model) => { + model.limit = { context: 1_050_000, input: 922_000, output: 128_000 } + }) + catalog.models.update(Provider.ID.openai, Model.ID.make("gpt-4.1"), () => {}) + }) + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.Key.make({ type: "key", key: "sk-test" }), + }) + yield* addPlugin() + yield* addLegacyPlugin() + + const direct = yield* request(Provider.ID.openai, "https://api.openai.com/v1") + + const provider = required(yield* catalog.get(Provider.ID.openai)) + const model = required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.5"))) + expect(model.package).toBe("@opencode/ai/providers/openai") + expect(model.enabled).toBe(true) + expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 }) + expect(provider.settings?.transport).toBe("websocket") + expect(model.settings?.transport).toBeUndefined() + expect(direct.baseURL).toBe("https://api.openai.com/v1") + expect(direct.hasHttpHooks).toBe(false) + expect(provider.headers).not.toHaveProperty("x-openai-chatpass-test") + expect(required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true) + }), + ) + + it.live("switches between token-sharing and Codex credentials without mixing their routes", () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const credentials = yield* Credential.Service + const kv = yield* KV.Service + yield* catalog.transform((editor) => { + editor.update(Provider.ID.openai, (provider) => { + provider.package = "@opencode/ai/providers/openai" + }) + editor.models.update(Provider.ID.openai, Model.ID.make("gpt-5.5"), () => {}) + }) + const legacy = yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-browser"), + access: "codex-token", + refresh: "codex-refresh", + expires: Date.now() + 60 * 60_000, + metadata: { accountID: "acct_123" }, + }), + }) + yield* PluginHost.storage(kv, ChatGPTPlugin.id).set("models:oaiapp_issued", [ + { + slug: "gpt-5.5", + display_name: "GPT-5.5", + visibility: "list", + supported_in_api: true, + context_window: 272_000, + input_modalities: ["text"], + }, + ]) + const sharing = yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + access: "sharing-token", + refresh: "sharing-refresh", + expires: Date.now() + 60 * 60_000, + metadata: { clientID: "oaiapp_issued" }, + }), + }) + yield* addPlugin() + yield* addLegacyPlugin() + expect((yield* catalog.get(Provider.ID.openai))?.settings?.baseURL).toBe("https://api.openai.com/v1") + + yield* credentials.activate(legacy.id) + const codex = yield* catalog.get(Provider.ID.openai).pipe( + Effect.repeat({ + until: (item) => item?.settings?.baseURL === "https://chatgpt.com/backend-api/codex", + schedule: Schedule.spaced("10 millis"), + }), + Effect.timeout("2 seconds"), + ) + expect(codex?.headers?.["chatgpt-account-id"]).toBe("acct_123") + + yield* credentials.activate(sharing.id) + const direct = yield* catalog.get(Provider.ID.openai).pipe( + Effect.repeat({ + until: (item) => item?.settings?.baseURL === "https://api.openai.com/v1", + schedule: Schedule.spaced("10 millis"), + }), + Effect.timeout("2 seconds"), + ) + expect(direct?.settings?.transport).toBe("http") + expect(direct?.headers?.["chatgpt-account-id"]).toBeUndefined() + }), + ) + + it.effect("omits the default output limit from OpenAI steps and compaction", () => + Effect.gen(function* () { + yield* addPlugin() + yield* addLegacyPlugin() + const hooks = yield* PluginHooks.Service + const maxTokens = (providerID: Provider.ID) => + Effect.gen(function* () { + const draft = { + sessionID: Session.ID.make("ses_test"), + model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }), + system: [], + messages: [], + options: { maxTokens: 128_000 }, + } + const events = [ + yield* hooks.trigger("session", "context", { ...draft, agent: Agent.ID.make("build"), tools: {} }), + yield* hooks.trigger("session", "compaction", { ...draft, agent: Agent.ID.make("build"), tools: {} }), + ] + return events.map((event) => event.options.maxTokens) + }) + + expect(yield* maxTokens(Provider.ID.openai)).toEqual([undefined, undefined]) + expect(yield* maxTokens(Provider.ID.azure)).toEqual([128_000, 128_000]) + }), + ) + + for (const connection of ["chatgpt", "key"] as const) { + it.effect(`${connection} compaction uses plugin defaults but allows later model and variant overrides`, () => + Effect.gen(function* () { + const catalog = yield* Provider.Service + const models = yield* Model.Service + const credentials = yield* Credential.Service + const providerID = Provider.ID.openai + const baseID = Model.ID.make("gpt-5.5") + const modelID = Model.ID.make("gpt-5.5-model") + const variantID = Model.ID.make("gpt-5.5-variant") + const kv = yield* KV.Service + yield* catalog.transform((editor) => { + editor.update(providerID, (provider) => { + provider.package = "@opencode/ai/providers/openai/responses" + }) + for (const id of [baseID, modelID, variantID]) + editor.models.update(providerID, id, (model) => { + model.modelID = baseID + }) + editor.models.update(providerID, modelID, (model) => { + model.settings = { compaction: { type: "native" } } + }) + }) + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: + connection === "chatgpt" + ? Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + access: "chatgpt-token", + refresh: "refresh", + expires: Date.now() + 60_000, + metadata: { clientID: "oaiapp_issued" }, + }) + : Credential.Key.make({ type: "key", key: "sk-test" }), + }) + if (connection === "chatgpt") + yield* PluginHost.storage(kv, ChatGPTPlugin.id).set("models:oaiapp_issued", [ + { + slug: "gpt-5.5", + display_name: "GPT-5.5", + visibility: "list", + supported_in_api: true, + context_window: 272_000, + input_modalities: ["text"], + }, + ]) + yield* addPlugin() + yield* addLegacyPlugin() + const resolver = yield* ModelResolver.Service + const resolve = (id: Model.ID, variant?: Model.VariantID) => + models.get(providerID, id).pipe( + Effect.flatMap((model) => resolver.resolveModel(required(model), variant)), + Effect.map((result) => result.compaction), + ) + + expect(yield* resolve(baseID)).toEqual(connection === "chatgpt" ? { type: "summary" } : undefined) + expect(yield* resolve(modelID)).toEqual({ type: connection === "chatgpt" ? "summary" : "native" }) + + // A later provider default does not override the ChatGPT model policy. + yield* catalog.transform((editor) => { + editor.update(providerID, (provider) => { + provider.settings = { compaction: { type: "native" } } + }) + }) + expect(yield* resolve(baseID)).toEqual({ type: connection === "chatgpt" ? "summary" : "native" }) + + yield* catalog.transform((editor) => { + editor.update(providerID, (provider) => { + provider.settings = { compaction: { type: "summary" } } + }) + }) + yield* models.transform((editor) => { + editor.update(providerID, modelID, (model) => { + model.settings = { compaction: { type: "native" } } + }) + editor.update(providerID, variantID, (model) => { + model.settings = { compaction: { type: "summary" } } + model.variants = [{ id: Model.VariantID.make("high"), settings: { compaction: { type: "native" } } }] + }) + }) + expect(yield* resolve(baseID)).toEqual({ type: "summary" }) + expect(yield* resolve(modelID)).toEqual({ type: "native" }) + expect(yield* resolve(variantID, Model.VariantID.make("high"))).toEqual({ type: "native" }) + }).pipe(Effect.provide(ModelResolver.layer)), + ) + } + + it.effect("selects WebSocket only from explicit policy", () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.Key.make({ type: "key", key: "sk-test" }), + }) + yield* addPlugin() + yield* addGithubCopilotPlugin() + const executor = { execute: () => Effect.die("unused WebSocket execution") } + const transport = SessionModelTransport.Service.of({ + bind: () => executor, + close: () => Effect.void, + closeAll: Effect.void, + }) + const sessionID = Session.ID.make("ses_websocket_hooks") + const agentID = Agent.ID.make("build") + const route = OpenAIResponses.route.with({ + id: "deployment-responses", + provider: Provider.ID.azure, + }) + const prepare = (preference?: Provider.Transport) => + Effect.gen(function* () { + const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), { + capabilities: { tools: true, input: ["text"], output: ["text"] }, + cost: [], + limit: { context: 200_000, output: 32_000 }, + transport: preference, + }) + const requests = yield* SessionModelRequest.Service + return yield* requests.primary({ + session: Session.Info.make({ + id: sessionID, + projectID: Project.ID.global, + cost: Money.USD.zero, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) }, + location: Location.Ref.make({ directory: AbsolutePath.make("/project") }), + }), + agent: agentID, + model, + tools: { definitions: [], execute: () => Effect.die("unused tool execution") }, + system: [], + messages: [], + webSocket: "session", + }) + }).pipe( + Effect.provide(SessionModelRequest.layer), + Effect.provideService(SessionModelTransport.Service, transport), + ) + + const prepared = yield* prepare("websocket") + const defaulted = yield* prepare() + const disabled = yield* prepare("http") + + expect(prepared.options.webSocket).toBe(executor) + expect(prepared.options.http).toBeUndefined() + expect(defaulted.options.webSocket).toBeUndefined() + expect(disabled.options.webSocket).toBeUndefined() + }), + ) +})