mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(core): handle ChatGPT sharing failures and expose auth method
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?? [])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } }),
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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" })
|
||||
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
Reference in New Issue
Block a user