diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index cb982f88e89..5ed1295c518 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -5014,6 +5014,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 error event details nested under response.error", () => Effect.gen(function* () { // Some OpenAI-compatible proxies and older SDK versions wrap the diff --git a/packages/app/src/providers/connect/dialog.stories.tsx b/packages/app/src/providers/connect/dialog.stories.tsx index 1619a1262b4..35faab6bfa6 100644 --- a/packages/app/src/providers/connect/dialog.stories.tsx +++ b/packages/app/src/providers/connect/dialog.stories.tsx @@ -64,10 +64,11 @@ export const OpenCodeZen = { render: renderConnection("opencode", [{ type: "key", label: "API key" }]), } -export const LoginMethods = { +// The existing Codex-only response remains usable when SIWC is not registered. +export const CodexWithoutSIWC = { render: renderConnection("openai", [ - { type: "oauth", label: "ChatGPT Pro/Plus (browser)" }, - { type: "oauth", label: "ChatGPT Pro/Plus (headless)" }, + { type: "oauth", id: "chatgpt-browser", label: "ChatGPT Pro/Plus (browser)" }, + { type: "oauth", id: "chatgpt-headless", label: "ChatGPT Pro/Plus (headless)" }, { type: "key", label: "API key" }, ]), } @@ -75,8 +76,8 @@ export const LoginMethods = { export const ChatGPTSignIn = { render: renderConnection("openai", [ { type: "oauth", id: "chatgpt-token-sharing", label: "Sign in with ChatGPT" }, + { type: "key", label: "API key" }, { type: "oauth", id: "chatgpt-browser", label: "ChatGPT Pro/Plus (browser)" }, { type: "oauth", id: "chatgpt-headless", label: "ChatGPT Pro/Plus (headless)" }, - { type: "key", label: "API key" }, ]), } diff --git a/packages/app/src/providers/models/select-dialog.tsx b/packages/app/src/providers/models/select-dialog.tsx index f269eec21c3..b65c523ac08 100644 --- a/packages/app/src/providers/models/select-dialog.tsx +++ b/packages/app/src/providers/models/select-dialog.tsx @@ -15,6 +15,8 @@ import { Menu } from "@opencode/ui/menu" import { TextInput } from "@opencode/ui/text-input" import { ModelTooltip } from "./tooltip" import { useLanguage } from "@/runtime/i18n/language" +import { useData } from "@/runtime/server/current" +import { useWorkspaceLocation } from "@/workspaces/location" import { ExternalLink } from "@/runtime/platform/external-link" import { decode64 } from "@/runtime/persistence/base64" import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown" @@ -22,7 +24,12 @@ import { createMenuDismissController } from "@/shell/commands/menu-dismiss" import { createEventListener } from "@solid-primitives/event-listener" import { matchesModelSearch } from "./search" import { SettingsList } from "@/settings/list" -import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelIcon, ProviderModelSections } from "@/providers/models/provider-group" +import { + CONSOLE_GROUP_KEY, + consoleModelGroup, + ProviderModelIcon, + ProviderModelSections, +} from "@/providers/models/provider-group" import "@/settings/settings.css" const isFree = (provider: string, cost: { input: number } | undefined) => @@ -219,11 +226,19 @@ export function ModelSelectorPopover(props: { onClose?: () => void }) { const dialog = useDialog() + const data = useData() + const location = useWorkspaceLocation() const controller = createModelSelectorController({ model: props.model, provider: () => props.provider, onSelect: () => props.onClose?.(), }) + const chatgptPlan = () => { + if (!controller.current()?.startsWith("openai:")) return false + const connection = data.location.integration.list(location().ref)?.find((item) => item.id === "openai") + ?.connections[0] + return connection?.type === "credential" && connection.methodID === "chatgpt-token-sharing" + } return ( { void import("./manage").then((module) => { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 1704e549c4d..66575504385 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -1472,6 +1472,7 @@ export type ConnectionCredentialInfo = { id: string label: string method: "key" | "oauth" + methodID?: string status?: ConnectionStatus } diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 39904eaed1b..de8a542b97f 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -373,6 +373,7 @@ const layer = Layer.effect( id: credential.id, label: credential.label, method: credential.value.type, + ...(credential.value.type === "oauth" ? { methodID: credential.value.methodID } : {}), })) .toReversed() const env = (entry?.methods ?? []) diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 747bbacfc29..d3f5e5a968c 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -28,6 +28,14 @@ const sharingTokenURL = `${issuer}/api/accounts/oauth/token` const sharingResource = "https://api.openai.com/v1" const sharingScope = "chatgpt.tokens.use.direct" const sharingMethodID = Integration.MethodID.make("chatgpt-token-sharing") +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", +] const browserMethodID = Integration.MethodID.make("chatgpt-browser") const headlessMethodID = Integration.MethodID.make("chatgpt-headless") // ChatGPT accounts lost gpt-5.4 and gpt-5.4-mini in Codex on 2026-08-31 (replacements: gpt-5.6-terra, gpt-5.6-luna). @@ -403,6 +411,15 @@ export const OpenAIPlugin = define({ editor.method.update(headless(ctx.app)) }) yield* load() + yield* ctx.session.hook( + "retry", + (event) => + Effect.sync(() => { + if (!sharing || !nonRetryableSharingCodes.some((code) => event.error.response?.body.includes(code))) return + event.decision = { retry: false } + }), + { providerID: Provider.ID.openai }, + ) yield* ctx.provider.transform((providers) => { const item = providers.get(Provider.ID.openai) if (!item) return @@ -505,6 +522,7 @@ export const OpenAIPlugin = define({ // The ChatGPT backend rejects a requested output limit, and OpenAI counts one against rate limits. const omitOutputLimit = (evt: SessionRequest) => Effect.sync(() => { + if (sharing) return delete evt.options.maxTokens }) for (const name of ["context", "compaction"] as const) @@ -653,7 +671,7 @@ function sharingExchange(code: string, clientID: string, redirect: string, pkce: redirect_uri: redirect, resource: sharingResource, }).toString(), - }) + }).pipe(Effect.mapError((cause) => new Error(`ChatGPT token exchange failed: ${String(cause)}`, { cause }))) } function sharingRefresh(value: Credential.OAuth, app: App.Info) { @@ -679,6 +697,7 @@ export function fetchSharingModels(token: string, app: App.Info, baseURL = shari return request(`${baseURL}/models`, { headers: { Authorization: `Bearer ${token}`, "User-Agent": App.useragent(app) }, }).pipe( + Effect.mapError((cause) => new Error(`ChatGPT model discovery failed: ${String(cause)}`, { cause })), Effect.flatMap(decodeModels), Effect.flatMap((response) => { const models = response.models.filter((model) => model.visibility === "list" && model.supported_in_api) diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index 61cae41132b..9837954e824 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -7,6 +7,22 @@ import { AgentNotFoundError, StepFailedError } from "./error.js" import { ModelResolver } from "../model-resolver.js" import { SessionRunnerModel } from "./runner/model.js" +const tokenSharingMessages = { + subscription_sharing_v2_user_not_eligible: + "ChatGPT token sharing isn't available for this account. Connect with an API key or choose another provider.", + subscription_sharing_usage_limit_exceeded: + "ChatGPT usage limit reached. Try again after your allowance resets; check ChatGPT Settings → Usage for details.", + subscription_sharing_usage_unavailable: "ChatGPT usage can't be checked right now. Try again later.", + subscription_sharing_unsupported_capability: + "This request uses a feature ChatGPT token sharing doesn't support. Remove the unsupported feature and try again.", + subscription_sharing_v2_client_not_enabled: + "This ChatGPT client isn't enabled for token sharing. Contact the app maintainer or choose another connection.", + subscription_sharing_v2_route_not_supported: + "ChatGPT token sharing doesn't support this API route. Check the configured endpoint and HTTP method.", + subscription_sharing_v2_invalid_user: "This ChatGPT connection is no longer valid. Reconnect to ChatGPT.", + subscription_sharing_v2_user_unavailable: "Your ChatGPT account is temporarily unavailable. Try again later.", +} + export function toSessionError(cause: unknown): SessionError.Error { if (cause instanceof AIError) { switch (cause.reason._tag) { @@ -71,7 +87,7 @@ function providerError(type: string, reason: AIError["reason"]): SessionError.Er const status = reason.http?.status return { type, - message: reason.message, + message: Object.entries(tokenSharingMessages).find(([code]) => reason.body?.includes(code))?.[1] ?? reason.message, ...(status === undefined ? {} : { status }), ...(reason.body === undefined ? {} : { response: { body: reason.body } }), } diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index f2b10246b10..a466e7541f9 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -679,6 +679,37 @@ describe("Integration", () => { }), ) }) + + it.effect("projects the OAuth method ID without exposing credentials", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const credentials = yield* Credential.Service + const integrationID = Integration.ID.make("openai") + yield* integrations.transform((editor) => + editor.update(integrationID, (ref) => { + ref.name = "OpenAI" + }), + ) + const credential = yield* credentials.create({ + integrationID, + label: "ChatGPT", + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + access: "test-access", + refresh: "test-refresh", + expires: Date.now() + 60_000, + }), + }) + expect((yield* integrations.get(integrationID))?.connections[0]).toEqual({ + type: "credential", + method: "oauth", + methodID: Integration.MethodID.make("chatgpt-token-sharing"), + id: credential.id, + label: "ChatGPT", + }) + }), + ) }) describe("AuthorizationError", () => { diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 46fa5f9f747..d436ea6e3a5 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -2,6 +2,7 @@ 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, RateLimitError } from "@opencode/ai" import { describe, expect } from "bun:test" import { ConfigProvider, DateTime, Effect } from "effect" import { exportJWK, generateKeyPair, SignJWT } from "jose" @@ -21,6 +22,8 @@ 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" @@ -91,6 +94,103 @@ describe("OpenAIPlugin", () => { }), ) + it.effect("stops deterministic SIWC retries for both HTTP and stream 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: "sharing-token", + refresh: "refresh", + expires: Date.now() + 60 * 60_000, + metadata: { clientID: "oaiapp_issued" }, + }), + }) + yield* addPlugin() + const hooks = yield* PluginHooks.Service + const codes = [ + "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", + ] + for (const code of codes) { + for (const body of [ + JSON.stringify({ error: { code } }), + JSON.stringify({ type: "response.failed", response: { error: { code } } }), + ]) { + const cause = new AIError({ reason: new RateLimitError({ message: "Rate limit exceeded", body }) }) + const decide = yield* SessionRunnerRetry.policy(Session.ID.make("ses_sharing_retry")) + expect( + 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: SessionRunnerRetry.isRetryable(cause), + }), + ).toEqual({ retry: false }) + } + } + for (const code of ["subscription_sharing_usage_unavailable", "subscription_sharing_v2_user_unavailable"]) { + const event = yield* hooks.trigger("session", "retry", { + sessionID: Session.ID.make("ses_sharing_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: "Temporary", + response: { body: JSON.stringify({ error: { code } }) }, + }, + attempt: 2, + decision: { retry: true, delay: 1000 }, + }) + expect(event.decision).toEqual({ retry: true, delay: 1000 }) + } + }), + ) + + for (const method of ["chatgpt-browser", "chatgpt-headless", "key"] as const) { + it.effect(`does not change ${method} retries for sharing errors`, () => + Effect.gen(function* () { + const credentials = yield* Credential.Service + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: + method === "key" + ? Credential.Key.make({ type: "key", key: "sk-test" }) + : Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make(method), + access: "codex-token", + refresh: "refresh", + expires: Date.now() + 60 * 60_000, + }), + }) + yield* addPlugin() + const hooks = yield* PluginHooks.Service + const event = yield* hooks.trigger("session", "retry", { + sessionID: Session.ID.make("ses_codex_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", + 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.live("registers a user-owned ChatGPT agent with a separate IPv4 callback", () => Effect.gen(function* () { yield* addPlugin() @@ -125,27 +225,29 @@ describe("OpenAIPlugin", () => { Effect.sync(() => Bun.serve({ port: 0, - fetch: () => - Response.json({ - models: [ - { - slug: "gpt-visible", - display_name: "Visible", - visibility: "list", - supported_in_api: true, - context_window: 272_000, - input_modalities: ["text"], - }, - { - slug: "gpt-hidden", - display_name: "Hidden", - visibility: "hide", - supported_in_api: true, - context_window: 272_000, - input_modalities: ["text"], - }, - ], - }), + fetch: (request) => + new URL(request.url).pathname === "/error/models" + ? new Response(null, { status: 500 }) + : Response.json({ + models: [ + { + slug: "gpt-visible", + display_name: "Visible", + visibility: "list", + supported_in_api: true, + context_window: 272_000, + input_modalities: ["text"], + }, + { + slug: "gpt-hidden", + display_name: "Hidden", + visibility: "hide", + supported_in_api: true, + context_window: 272_000, + input_modalities: ["text"], + }, + ], + }), }), ), (server) => Effect.sync(() => server.stop()), @@ -155,6 +257,11 @@ describe("OpenAIPlugin", () => { (model) => model.slug, ), ).toEqual(["gpt-visible"]) + expect( + String( + yield* Effect.flip(fetchSharingModels("test-token", App.make(), `http://127.0.0.1:${server.port}/error`)), + ), + ).toContain("ChatGPT model discovery failed: Error: Request failed: 500") }), ) @@ -405,6 +512,35 @@ describe("OpenAIPlugin", () => { }), ) + it.effect("keeps output limits for token-sharing Responses requests", () => + 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: "sharing-token", + refresh: "refresh", + expires: Date.now() + 60 * 60_000, + metadata: { clientID: "oaiapp_issued" }, + }), + }) + yield* addPlugin() + const hooks = yield* PluginHooks.Service + const draft = { + sessionID: Session.ID.make("ses_sharing_output"), + agent: Agent.ID.make("build"), + model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }), + system: [], + messages: [], + options: { maxTokens: 128_000 }, + } + expect((yield* hooks.trigger("session", "context", { ...draft, tools: {} })).options.maxTokens).toBe(128_000) + expect((yield* hooks.trigger("session", "compaction", { ...draft, tools: {} })).options.maxTokens).toBe(128_000) + }), + ) + it.effect("selects WebSocket only from explicit policy", () => Effect.gen(function* () { const credentials = yield* Credential.Service diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index 4c1d6ae1d76..4f5e7c9b7d4 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -136,6 +136,29 @@ describe("toSessionError", () => { }) }) + test("maps token-sharing failures while preserving HTTP and stream bodies", () => { + const cases = [ + ["subscription_sharing_v2_user_not_eligible", "token sharing isn't available for this account"], + ["subscription_sharing_usage_limit_exceeded", "ChatGPT usage limit reached"], + ["subscription_sharing_usage_unavailable", "Try again later"], + ["subscription_sharing_unsupported_capability", "Remove the unsupported feature"], + ["subscription_sharing_v2_client_not_enabled", "Contact the app maintainer"], + ["subscription_sharing_v2_route_not_supported", "Check the configured endpoint"], + ["subscription_sharing_v2_invalid_user", "Reconnect to ChatGPT"], + ["subscription_sharing_v2_user_unavailable", "Try again later"], + ] as const + for (const [code, guidance] of cases) { + for (const body of [ + JSON.stringify({ error: { code, message: "Request failed" } }), + JSON.stringify({ type: "response.failed", response: { error: { code, message: "Request failed" } } }), + ]) { + const error = toSessionError(llm(new UnknownProviderError({ message: "Request failed", body }))) + expect(error.message).toContain(guidance) + expect(error.response).toEqual({ body }) + } + } + }) + test("preserves unresolved provider endpoint errors", () => { const error = new ModelResolver.UnresolvedProviderVariablesError({ providerID: Provider.ID.make("cloudflare-workers-ai"), diff --git a/packages/schema/src/connection.ts b/packages/schema/src/connection.ts index bf024c4531f..2c25e8ec5bb 100644 --- a/packages/schema/src/connection.ts +++ b/packages/schema/src/connection.ts @@ -22,6 +22,8 @@ export const CredentialInfo = Schema.Struct({ label: Schema.String, /** How the credential was obtained: a stored key or an OAuth grant. */ method: Schema.Literals(["key", "oauth"]), + /** OAuth implementation that created this credential, when applicable. */ + methodID: optional(Schema.String), status: optional(Status), }).annotate({ identifier: "Connection.CredentialInfo" }) diff --git a/packages/schema/test/connection.test.ts b/packages/schema/test/connection.test.ts new file mode 100644 index 00000000000..f0663fc53f2 --- /dev/null +++ b/packages/schema/test/connection.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test" +import { Connection } from "@opencode/schema/connection" +import { Schema } from "effect" + +test("connection method ID is optional and omitted for API keys", () => { + const decode = Schema.decodeUnknownSync(Connection.CredentialInfo) + const encode = Schema.encodeSync(Connection.CredentialInfo) + const key = decode({ type: "credential", id: "cred_key", label: "API key", method: "key" }) + expect(encode(key)).toEqual({ type: "credential", id: "cred_key", label: "API key", method: "key" }) + const oauth = decode({ + type: "credential", + id: "cred_chatgpt", + label: "ChatGPT", + method: "oauth", + methodID: "chatgpt-token-sharing", + }) + expect(encode(oauth)).toMatchObject({ method: "oauth", methodID: "chatgpt-token-sharing" }) +})