fix(core): handle ChatGPT sharing failures and expose auth method

This commit is contained in:
Aiden Cline
2026-09-29 14:00:05 -05:00
parent 5f60e335b2
commit 1a26fdc297
12 changed files with 328 additions and 30 deletions
@@ -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
@@ -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" },
]),
}
@@ -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 (
<ModelSelectorPopoverView
@@ -231,8 +246,7 @@ export function ModelSelectorPopover(props: {
models={controller.models}
groups={controller.groups}
current={controller.current()}
// ConnectionInfo reports only "oauth", not the active method ID. Never infer SIWC from Codex OAuth.
chatgptPlan={false}
chatgptPlan={chatgptPlan()}
select={controller.select}
onManage={() => {
void import("./manage").then((module) => {
@@ -1472,6 +1472,7 @@ export type ConnectionCredentialInfo = {
id: string
label: string
method: "key" | "oauth"
methodID?: string
status?: ConnectionStatus
}
+1
View File
@@ -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 ?? [])
+20 -1
View File
@@ -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<unknown>(`${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)
+17 -1
View File
@@ -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 } }),
}
+31
View File
@@ -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", () => {
+157 -21
View File
@@ -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
+23
View File
@@ -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"),
+2
View File
@@ -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" })
+18
View File
@@ -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" })
})