diff --git a/sdk/apps/cli/src/runtime/run-agent.ts b/sdk/apps/cli/src/runtime/run-agent.ts index e8a77e3fff..ad28e25da9 100644 --- a/sdk/apps/cli/src/runtime/run-agent.ts +++ b/sdk/apps/cli/src/runtime/run-agent.ts @@ -169,6 +169,7 @@ export async function runAgent( let reasoningChunkCount = 0; let redactedReasoningChunkCount = 0; + const displayedErrorMessages = new Set(); const onAgentEvent = (event: AgentEvent): void => { if (event.type === "content_start" && event.contentType === "reasoning") { @@ -177,6 +178,13 @@ export async function runAgent( redactedReasoningChunkCount += 1; } } + if ( + event.type === "error" && + (!event.recoverable || config.verbose) && + event.error.message.trim() + ) { + displayedErrorMessages.add(event.error.message.trim()); + } handleEvent(event, config); }; const plannedSessionId = createSessionId(); @@ -353,6 +361,14 @@ export async function runAgent( } if (result.finishReason !== "completed") { + const errorText = result.text.trim(); + if ( + config.outputMode !== "json" && + errorText && + !displayedErrorMessages.has(errorText) + ) { + writeErr(errorText); + } process.exitCode = 1; return; } diff --git a/sdk/apps/cli/src/tests/fixtures/per-turn-metrics.json b/sdk/apps/cli/src/tests/fixtures/per-turn-metrics.json index a5dcd2920c..68dc38b195 100644 --- a/sdk/apps/cli/src/tests/fixtures/per-turn-metrics.json +++ b/sdk/apps/cli/src/tests/fixtures/per-turn-metrics.json @@ -4,7 +4,7 @@ "method": "POST", "path": "/api/v1/chat/completions", "status": 200, - "response": "data: {\"id\": \"gen_test_turn1_aabbccdd\", \"object\": \"chat.completion.chunk\", \"created\": 1774387732, \"model\": \"openai/gpt-5.3-codex\", \"choices\": [{\"index\": 0, \"delta\": {\"role\": \"assistant\"}, \"logprobs\": null, \"finish_reason\": null}], \"system_fingerprint\": \"fp_test\"}\n\ndata: {\"id\": \"gen_test_turn1_aabbccdd\", \"object\": \"chat.completion.chunk\", \"created\": 1774387732, \"model\": \"openai/gpt-5.3-codex\", \"choices\": [{\"index\": 0, \"delta\": {\"tool_calls\": [{\"index\": 0, \"id\": \"call_t1abc\", \"type\": \"function\", \"function\": {\"name\": \"execute_command\", \"arguments\": \"\"}}]}, \"logprobs\": null, \"finish_reason\": null}], \"system_fingerprint\": \"fp_test\"}\n\ndata: {\"id\": \"gen_test_turn1_aabbccdd\", \"object\": \"chat.completion.chunk\", \"created\": 1774387732, \"model\": \"openai/gpt-5.3-codex\", \"choices\": [{\"index\": 0, \"delta\": {\"tool_calls\": [{\"index\": 0, \"function\": {\"arguments\": \"{\\\"command\\\":\\\"echo hello\\\",\\\"requires_approval\\\":false}\"}}]}, \"logprobs\": null, \"finish_reason\": null}], \"system_fingerprint\": \"fp_test\"}\n\ndata: {\"id\": \"gen_test_turn1_aabbccdd\", \"object\": \"chat.completion.chunk\", \"created\": 1774387732, \"model\": \"openai/gpt-5.3-codex\", \"choices\": [{\"index\": 0, \"delta\": {}, \"logprobs\": null, \"finish_reason\": \"tool_calls\"}], \"system_fingerprint\": \"fp_test\", \"usage\": {\"prompt_tokens\": 1000, \"completion_tokens\": 25, \"total_tokens\": 1025, \"cost\": 0.0011, \"is_byok\": false, \"prompt_tokens_details\": {\"cached_tokens\": 200, \"audio_tokens\": 0, \"video_tokens\": 0}, \"cost_details\": {\"upstream_inference_cost\": null, \"upstream_inference_prompt_cost\": 0, \"upstream_inference_completions_cost\": 0}, \"completion_tokens_details\": {\"reasoning_tokens\": 0, \"image_tokens\": 0}, \"cache_creation_input_tokens\": 50, \"market_cost\": 0.0011}}\n\ndata: [DONE]\n", + "response": "data: {\"id\": \"gen_test_turn1_aabbccdd\", \"object\": \"chat.completion.chunk\", \"created\": 1774387732, \"model\": \"openai/gpt-5.3-codex\", \"choices\": [{\"index\": 0, \"delta\": {\"role\": \"assistant\"}, \"logprobs\": null, \"finish_reason\": null}], \"system_fingerprint\": \"fp_test\"}\n\ndata: {\"id\": \"gen_test_turn1_aabbccdd\", \"object\": \"chat.completion.chunk\", \"created\": 1774387732, \"model\": \"openai/gpt-5.3-codex\", \"choices\": [{\"index\": 0, \"delta\": {\"tool_calls\": [{\"index\": 0, \"id\": \"call_t1abc\", \"type\": \"function\", \"function\": {\"name\": \"run_commands\", \"arguments\": \"\"}}]}, \"logprobs\": null, \"finish_reason\": null}], \"system_fingerprint\": \"fp_test\"}\n\ndata: {\"id\": \"gen_test_turn1_aabbccdd\", \"object\": \"chat.completion.chunk\", \"created\": 1774387732, \"model\": \"openai/gpt-5.3-codex\", \"choices\": [{\"index\": 0, \"delta\": {\"tool_calls\": [{\"index\": 0, \"function\": {\"arguments\": \"{\\\"commands\\\":[\\\"echo hello\\\"]}\"}}]}, \"logprobs\": null, \"finish_reason\": null}], \"system_fingerprint\": \"fp_test\"}\n\ndata: {\"id\": \"gen_test_turn1_aabbccdd\", \"object\": \"chat.completion.chunk\", \"created\": 1774387732, \"model\": \"openai/gpt-5.3-codex\", \"choices\": [{\"index\": 0, \"delta\": {}, \"logprobs\": null, \"finish_reason\": \"tool_calls\"}], \"system_fingerprint\": \"fp_test\", \"usage\": {\"prompt_tokens\": 1000, \"completion_tokens\": 25, \"total_tokens\": 1025, \"cost\": 0.0011, \"is_byok\": false, \"prompt_tokens_details\": {\"cached_tokens\": 200, \"audio_tokens\": 0, \"video_tokens\": 0}, \"cost_details\": {\"upstream_inference_cost\": null, \"upstream_inference_prompt_cost\": 0, \"upstream_inference_completions_cost\": 0}, \"completion_tokens_details\": {\"reasoning_tokens\": 0, \"image_tokens\": 0}, \"cache_creation_input_tokens\": 50, \"market_cost\": 0.0011}}\n\ndata: [DONE]\n", "responseIsBinary": false, "contentType": "text/event-stream" }, diff --git a/sdk/apps/cli/src/tests/headless/per-turn-metrics.live.test.ts b/sdk/apps/cli/src/tests/headless/per-turn-metrics.live.test.ts index e7f61795f8..b433ccbb95 100644 --- a/sdk/apps/cli/src/tests/headless/per-turn-metrics.live.test.ts +++ b/sdk/apps/cli/src/tests/headless/per-turn-metrics.live.test.ts @@ -7,7 +7,7 @@ // each assistant message at append time → persisted to messages.json. // // The cassette has two API calls: -// Turn 1: tool call (execute_command) — usage: 1000 in / 25 out / 50 cacheWrite / 200 cacheRead +// Turn 1: tool call (run_commands) — usage: 1000 in / 25 out / 50 cacheWrite / 200 cacheRead // Turn 2: text completion — usage: 1500 in / 40 out / 30 cacheWrite / 900 cacheRead // // If the bug regresses (session total stamped on terminal message instead of diff --git a/sdk/apps/cli/src/tui/interactive-tui.ts b/sdk/apps/cli/src/tui/interactive-tui.ts index 405f939ee2..3b50ba2d72 100644 --- a/sdk/apps/cli/src/tui/interactive-tui.ts +++ b/sdk/apps/cli/src/tui/interactive-tui.ts @@ -910,6 +910,7 @@ export function InteractiveTui(props: InteractiveTuiProps): React.ReactElement { } } finally { if (!delivery) { + closeInlineStream(); setIsRunning(false); refreshRepoStatus(); } @@ -917,6 +918,7 @@ export function InteractiveTui(props: InteractiveTuiProps): React.ReactElement { }, [ appendEntry, + closeInlineStream, config.showUsage, onSubmit, onTurnErrorReported, diff --git a/sdk/packages/core/src/hub/server.test.ts b/sdk/packages/core/src/hub/server.test.ts index 01b691df37..b7aba2729d 100644 --- a/sdk/packages/core/src/hub/server.test.ts +++ b/sdk/packages/core/src/hub/server.test.ts @@ -1,3 +1,4 @@ +import { createServer as createHttpServer } from "node:http"; import { createConnection, createServer as createNetServer } from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocketServer } from "ws"; @@ -136,6 +137,48 @@ describe("hub server startup", () => { } }); + it("falls back to an ephemeral port when fallback is allowed", async () => { + const owner = createInMemoryHubOwnerContext( + "hub-server-test-port-fallback", + ); + const port = await reservePort(); + const blocker = createHttpServer((_req, res) => { + res.statusCode = 404; + res.end("not a hub"); + }); + await new Promise((resolve, reject) => { + blocker.once("error", reject); + blocker.listen({ host: "127.0.0.1", port }, () => resolve()); + }); + + try { + const result = await ensureHubWebSocketServer({ + owner, + host: "127.0.0.1", + port, + pathname: "/hub", + allowPortFallback: true, + runtimeHandlers: createLocalHubScheduleRuntimeHandlers(), + }); + + expect(result.action).toBe("started"); + expect(result.server).toBeDefined(); + expect(result.server?.port).not.toBe(port); + servers.add(result.server!); + } finally { + await new Promise((resolve, reject) => { + blocker.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + await clearHubDiscovery(owner.discoveryPath); + } + }); + it("shuts down active server through the shutdown endpoint", async () => { const owner = createInMemoryHubOwnerContext("hub-server-test-shutdown"); const result = await ensureHubWebSocketServer({ diff --git a/sdk/packages/core/src/hub/server.ts b/sdk/packages/core/src/hub/server.ts index 4c9d2f4e62..2d0dcb70dc 100644 --- a/sdk/packages/core/src/hub/server.ts +++ b/sdk/packages/core/src/hub/server.ts @@ -371,6 +371,14 @@ function formatHubStartupError( return wrapped; } +function isAddressInUseError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as Error & { code?: string }).code === "EADDRINUSE" + ); +} + function serializeToolContext(context: ToolContext): Record { return { agentId: context.agentId, @@ -1719,7 +1727,9 @@ export interface HubWebSocketServer { } export interface EnsureHubWebSocketServerOptions - extends HubWebSocketServerOptions {} + extends HubWebSocketServerOptions { + allowPortFallback?: boolean; +} export interface EnsuredHubWebSocketServerResult { server?: HubWebSocketServer; @@ -1917,7 +1927,10 @@ export async function ensureHubWebSocketServer( return await withHubStartupLock(owner.discoveryPath, async () => { const discovered = await readHubDiscovery(owner.discoveryPath); - if (discovered?.url === expectedUrl) { + const canReuseDiscovered = + discovered?.url && + (discovered.url === expectedUrl || options.allowPortFallback === true); + if (canReuseDiscovered) { const healthy = await probeHubServer(discovered.url); if (healthy?.url && (await verifyHubConnection(healthy.url))) { return { url: healthy.url, action: "reuse" }; @@ -1934,14 +1947,27 @@ export async function ensureHubWebSocketServer( await clearHubDiscovery(owner.discoveryPath); } - const serverPromise = startHubWebSocketServer({ ...options, owner }); - SHARED_SERVERS.set(sharedKey, serverPromise); + const start = async ( + startOptions: HubWebSocketServerOptions, + ): Promise => { + const serverPromise = startHubWebSocketServer({ ...startOptions, owner }); + SHARED_SERVERS.set(sharedKey, serverPromise); + try { + const server = await serverPromise; + return { server, url: server.url, action: "started" }; + } catch (error) { + SHARED_SERVERS.delete(sharedKey); + throw error; + } + }; + try { - const server = await serverPromise; - return { server, url: server.url, action: "started" }; + return await start(options); } catch (error) { - SHARED_SERVERS.delete(sharedKey); - throw error; + if (!options.allowPortFallback || !isAddressInUseError(error)) { + throw error; + } + return await start({ ...options, port: 0 }); } }); } diff --git a/sdk/packages/core/src/hub/start-shared-server.ts b/sdk/packages/core/src/hub/start-shared-server.ts index 0fcc80179c..edf8999d83 100644 --- a/sdk/packages/core/src/hub/start-shared-server.ts +++ b/sdk/packages/core/src/hub/start-shared-server.ts @@ -45,6 +45,8 @@ export async function startHubServer( export async function ensureHubServer( options: EnsureHubServerOptions, ): Promise { + const hasExplicitPort = + options.port !== undefined || !!process.env.CLINE_HUB_PORT?.trim(); const endpoint = resolveHubEndpointOptions({ host: options.host, port: options.port, @@ -53,6 +55,7 @@ export async function ensureHubServer( return await ensureHubWebSocketServer({ ...options, ...endpoint, + allowPortFallback: options.allowPortFallback ?? !hasExplicitPort, owner: resolveSharedHubOwnerContext(), }); } diff --git a/sdk/packages/core/src/runtime/session-runtime-orchestrator.ts b/sdk/packages/core/src/runtime/session-runtime-orchestrator.ts index fe9731854a..d7665b0913 100644 --- a/sdk/packages/core/src/runtime/session-runtime-orchestrator.ts +++ b/sdk/packages/core/src/runtime/session-runtime-orchestrator.ts @@ -1046,7 +1046,10 @@ export class SessionRuntime { const finishReason: AgentFinishReason = thrownError ? "error" : deriveFinishReason(runResult); - const text = runResult?.outputText ?? ""; + const text = + runResult?.outputText || + (runResult?.status === "failed" ? runResult.error?.message : undefined) || + ""; const usage: LegacyAgentUsage = runResult ? { inputTokens: runResult.usage.inputTokens, diff --git a/sdk/packages/core/src/services/local-runtime-bootstrap.test.ts b/sdk/packages/core/src/services/local-runtime-bootstrap.test.ts index 2e5d6a3811..a3bd6deba9 100644 --- a/sdk/packages/core/src/services/local-runtime-bootstrap.test.ts +++ b/sdk/packages/core/src/services/local-runtime-bootstrap.test.ts @@ -335,4 +335,159 @@ describe("prepareLocalRuntimeBootstrap", () => { expect(bootstrap.providerConfig.fetch).toBeUndefined(); }); + + it("adds Codex backend headers for openai-codex from stored OAuth settings", async () => { + const { prepareLocalRuntimeBootstrap } = await import( + "./local-runtime-bootstrap" + ); + + const input = createStartInput(); + input.config.providerId = "openai-codex"; + input.config.modelId = "gpt-5.4"; + input.config.apiKey = "oauth-access-token"; + + const bootstrap = await prepareLocalRuntimeBootstrap({ + input, + sessionId: "sess-codex", + providerSettingsManager: createProviderSettingsManager({ + provider: "openai-codex", + model: "gpt-5.4", + auth: { + accessToken: "oauth-access-token", + accountId: "acct-123", + }, + headers: { + "x-stored": "stored", + }, + }) as never, + defaultTelemetry: undefined, + defaultToolExecutors: undefined, + defaultToolPolicies: undefined, + defaultRequestToolApproval: undefined, + onPluginEvent: () => {}, + onTeamEvent: () => {}, + createSpawnTool, + readSessionMetadata: async () => undefined, + writeSessionMetadata: async () => {}, + }); + + expect(bootstrap.providerConfig.headers).toMatchObject({ + originator: "cline", + session_id: "sess-codex", + "ChatGPT-Account-Id": "acct-123", + "x-stored": "stored", + }); + }); + + it("keeps Codex-controlled headers from being overridden by stored or config headers", async () => { + const { prepareLocalRuntimeBootstrap } = await import( + "./local-runtime-bootstrap" + ); + + const input = createStartInput(); + input.config.providerId = "openai-codex"; + input.config.modelId = "gpt-5.4"; + input.config.apiKey = "oauth-access-token"; + const config = input.config as typeof input.config & { + headers: Record; + }; + config.headers = { + originator: "config-originator", + session_id: "config-session", + "User-Agent": "ConfigAgent/0", + "ChatGPT-Account-Id": "config-account", + "x-config": "config", + "x-shared": "config-wins", + }; + + const bootstrap = await prepareLocalRuntimeBootstrap({ + input, + sessionId: "sess-codex-invariants", + providerSettingsManager: createProviderSettingsManager({ + provider: "openai-codex", + model: "gpt-5.4", + auth: { + accessToken: "oauth-access-token", + accountId: "acct-stored", + }, + headers: { + originator: "stored-originator", + session_id: "stored-session", + "User-Agent": "StoredAgent/0", + "ChatGPT-Account-Id": "stored-account", + "x-stored": "stored", + "x-shared": "stored-loses", + }, + }) as never, + defaultTelemetry: undefined, + defaultToolExecutors: undefined, + defaultToolPolicies: undefined, + defaultRequestToolApproval: undefined, + onPluginEvent: () => {}, + onTeamEvent: () => {}, + createSpawnTool, + readSessionMetadata: async () => undefined, + writeSessionMetadata: async () => {}, + }); + + expect(bootstrap.providerConfig.headers).toMatchObject({ + originator: "cline", + session_id: "sess-codex-invariants", + "ChatGPT-Account-Id": "acct-stored", + "x-config": "config", + "x-stored": "stored", + "x-shared": "config-wins", + }); + expect(bootstrap.providerConfig.headers?.["User-Agent"]).toMatch( + /^Cline\//, + ); + }); + + it("derives Codex account id from the OAuth access token when not persisted", async () => { + const { prepareLocalRuntimeBootstrap } = await import( + "./local-runtime-bootstrap" + ); + + const payload = Buffer.from( + JSON.stringify({ + "https://api.openai.com/auth": { + chatgpt_account_id: "acct-derived", + }, + }), + "utf8", + ).toString("base64url"); + const token = `header.${payload}.sig`; + + const input = createStartInput(); + input.config.providerId = "openai-codex"; + input.config.modelId = "gpt-5.4"; + input.config.apiKey = token; + + const bootstrap = await prepareLocalRuntimeBootstrap({ + input, + sessionId: "sess-codex-derived", + providerSettingsManager: createProviderSettingsManager({ + provider: "openai-codex", + model: "gpt-5.4", + auth: { + accessToken: token, + }, + }) as never, + defaultTelemetry: undefined, + defaultToolExecutors: undefined, + defaultToolPolicies: undefined, + defaultRequestToolApproval: undefined, + onPluginEvent: () => {}, + onTeamEvent: () => {}, + createSpawnTool, + readSessionMetadata: async () => undefined, + writeSessionMetadata: async () => {}, + }); + + expect(bootstrap.providerConfig.headers).toMatchObject({ + originator: "cline", + session_id: "sess-codex-derived", + "ChatGPT-Account-Id": "acct-derived", + }); + }); }); diff --git a/sdk/packages/core/src/services/local-runtime-bootstrap.ts b/sdk/packages/core/src/services/local-runtime-bootstrap.ts index 791fcebcd0..8c99cb9a1a 100644 --- a/sdk/packages/core/src/services/local-runtime-bootstrap.ts +++ b/sdk/packages/core/src/services/local-runtime-bootstrap.ts @@ -7,6 +7,7 @@ import type { ToolApprovalResult, WorkspaceInfo, } from "@clinebot/shared"; +import { decodeJwtPayload } from "../auth/utils"; import { resolveAndLoadAgentPlugins } from "../extensions/plugin/plugin-config-loader"; import type { PluginInitializationFailure, @@ -89,8 +90,59 @@ function resolveReasoningSettings( }; } +function buildOpenAICodexHeaders(input: { + sessionId: string; + configHeaders: CoreSessionConfig["headers"]; + storedHeaders: ProviderSettings["headers"]; + accountId?: string; + accessToken?: string; +}): Record | undefined { + const headers: Record = { + ...(input.storedHeaders ?? {}), + ...(input.configHeaders ?? {}), + }; + const resolvedAccountId = + input.accountId?.trim() || deriveOpenAICodexAccountId(input.accessToken); + headers.originator = "cline"; + headers.session_id = input.sessionId; + headers["User-Agent"] = `Cline/${process.env.npm_package_version || "1.0.0"}`; + if (resolvedAccountId) { + headers["ChatGPT-Account-Id"] = resolvedAccountId; + } + return headers; +} + +function deriveOpenAICodexAccountId( + accessToken: string | undefined, +): string | undefined { + const trimmed = accessToken?.trim(); + if (!trimmed) { + return undefined; + } + const payload = decodeJwtPayload(trimmed) as { + "https://api.openai.com/auth"?: { chatgpt_account_id?: string }; + organizations?: Array<{ id?: string }>; + chatgpt_account_id?: string; + } | null; + const authAccountId = + payload?.["https://api.openai.com/auth"]?.chatgpt_account_id; + if (typeof authAccountId === "string" && authAccountId.length > 0) { + return authAccountId; + } + const orgAccountId = payload?.organizations?.[0]?.id; + if (typeof orgAccountId === "string" && orgAccountId.length > 0) { + return orgAccountId; + } + const rootAccountId = payload?.chatgpt_account_id; + if (typeof rootAccountId === "string" && rootAccountId.length > 0) { + return rootAccountId; + } + return undefined; +} + function buildProviderConfig( config: CoreSessionConfig, + sessionId: string, providerSettingsManager: ProviderSettingsManager, modelCatalogDefaults?: Partial, defaultFetch?: typeof fetch, @@ -109,7 +161,17 @@ function buildProviderConfig( model: config.modelId, apiKey: config.apiKey ?? stored?.apiKey, baseUrl: config.baseUrl ?? stored?.baseUrl, - headers: config.headers ?? stored?.headers, + headers: + config.providerId === "openai-codex" + ? buildOpenAICodexHeaders({ + sessionId, + configHeaders: config.headers, + storedHeaders: stored?.headers, + accountId: stored?.auth?.accountId, + accessToken: + config.apiKey ?? stored?.auth?.accessToken ?? stored?.apiKey, + }) + : (config.headers ?? stored?.headers), reasoning: resolveReasoningSettings(config, stored?.reasoning), modelCatalog, }; @@ -261,6 +323,7 @@ export async function prepareLocalRuntimeBootstrap( }; const providerConfig = buildProviderConfig( baseConfig, + sessionId, providerSettingsManager, localRuntime?.modelCatalogDefaults, defaultFetch, diff --git a/sdk/packages/core/src/transports/local.test.ts b/sdk/packages/core/src/transports/local.test.ts index f9a785b81d..869ce18095 100644 --- a/sdk/packages/core/src/transports/local.test.ts +++ b/sdk/packages/core/src/transports/local.test.ts @@ -2072,6 +2072,68 @@ describe("LocalRuntimeHost", () => { expect(runtimeShutdown).toHaveBeenCalledTimes(1); }); + it("marks a single-run error result as failed", async () => { + const sessionId = "sess-error-result"; + const manifest = createManifest(sessionId); + const sessionService = { + ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"), + createRootSessionWithArtifacts: vi.fn().mockResolvedValue({ + manifestPath: "/tmp/manifest-error-result.json", + messagesPath: "/tmp/messages-error-result.json", + manifest, + }), + persistSessionMessages: vi.fn(), + updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }), + writeSessionManifest: vi.fn(), + listSessions: vi.fn().mockResolvedValue([]), + deleteSession: vi.fn().mockResolvedValue({ deleted: true }), + }; + const run = vi.fn().mockResolvedValue( + createResult({ + finishReason: "error", + text: "", + }), + ); + const manager = new RuntimeHostUnderTest({ + distinctId, + sessionService: sessionService as never, + runtimeBuilder: { + build: vi.fn().mockReturnValue({ + tools: [], + shutdown: vi.fn(), + }), + }, + createAgent: () => + ({ + run, + continue: vi.fn(), + abort: vi.fn(), + subscribeEvents: vi.fn().mockReturnValue(() => {}), + canStartRun: vi.fn().mockReturnValue(true), + getAgentId: vi.fn().mockReturnValue("agent-root-1"), + getConversationId: vi.fn().mockReturnValue("conv-root-1"), + shutdown: vi.fn().mockResolvedValue(undefined), + getMessages: vi.fn().mockReturnValue([]), + messages: [], + }) as never, + }); + + const started = await manager.start( + normalizeStartInput({ + config: createConfig({ sessionId }), + prompt: "hello", + interactive: false, + }), + ); + + expect(started.result?.finishReason).toBe("error"); + expect(sessionService.updateSessionStatus).toHaveBeenCalledWith( + sessionId, + "failed", + 1, + ); + }); + it("does not persist or emit shutdown hooks when no prompt was submitted", async () => { const sessionService = { ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"), diff --git a/sdk/packages/core/src/transports/local.ts b/sdk/packages/core/src/transports/local.ts index fb676750ea..1099374c2a 100644 --- a/sdk/packages/core/src/transports/local.ts +++ b/sdk/packages/core/src/transports/local.ts @@ -857,10 +857,11 @@ export class LocalRuntimeHost implements RuntimeHost { ): Promise { if (hasPendingTeamRunWork(session)) return; const isAborted = finishReason === "aborted" || session.aborting; + const isError = finishReason === "error"; await this.shutdownSession(session, { - status: isAborted ? "cancelled" : "completed", - exitCode: 0, - shutdownReason: "session_complete", + status: isAborted ? "cancelled" : isError ? "failed" : "completed", + exitCode: isError ? 1 : 0, + shutdownReason: isError ? "session_error" : "session_complete", endReason: finishReason, }); } diff --git a/sdk/packages/llms/src/providers/ai-sdk.ts b/sdk/packages/llms/src/providers/ai-sdk.ts index 39a74aa27a..b5b2ef79a6 100644 --- a/sdk/packages/llms/src/providers/ai-sdk.ts +++ b/sdk/packages/llms/src/providers/ai-sdk.ts @@ -15,6 +15,7 @@ import { import { streamText } from "ai"; import { nanoid } from "nanoid"; import { z } from "zod"; +import { extractErrorMessage } from "./format"; import { applyPromptCacheToLastTextPart, buildAnthropicCompatibleReasoningOptions, @@ -288,6 +289,15 @@ function toAiSdkProviderOptions( // OpenAI specific ...(request.providerId === "openai-native" ? { truncation: "auto" } : {}), }; + const openAICodexOptions = + request.providerId === "openai-codex" + ? { + ...compatibleOptions, + instructions: request.systemPrompt, + store: false, + systemMessageMode: "remove" as const, + } + : undefined; const geminiCompatibleOptions = request.reasoning?.effort ? { thinkingConfig: { @@ -300,10 +310,15 @@ function toAiSdkProviderOptions( const providerOptions: Record = { anthropic: anthropicOptions, openaiCompatible: compatibleOptions, + ...(request.providerId === "openai-codex" && openAICodexOptions + ? { openai: openAICodexOptions } + : {}), }; if (request.providerId !== "anthropic") { providerOptions[request.providerId] = { - ...compatibleOptions, + ...(request.providerId === "openai-codex" + ? openAICodexOptions + : compatibleOptions), ...(request.providerId === "cline" && gatewayReasoning ? { reasoning: gatewayReasoning } : {}), @@ -316,11 +331,22 @@ function toAiSdkProviderOptions( providerOptionsKey !== request.providerId && providerOptionsKey !== "anthropic" ) { - providerOptions[providerOptionsKey] = compatibleOptions; + providerOptions[providerOptionsKey] = + request.providerId === "openai-codex" + ? openAICodexOptions + : compatibleOptions; } return providerOptions; } +function resolveAiSdkSystemPrompt( + request: GatewayStreamRequest, +): string | undefined { + return request.providerId === "openai-codex" + ? undefined + : request.systemPrompt; +} + function mapFinishReason( value: unknown, sawToolCalls: boolean, @@ -604,54 +630,6 @@ function suppressDanglingStreamPromises( } } -function extractErrorMessage(error: unknown): string { - if ( - error && - typeof error === "object" && - "statusCode" in error && - "responseBody" in error - ) { - const apiError = error as { - statusCode?: unknown; - responseBody?: unknown; - message?: unknown; - }; - if (typeof apiError.responseBody === "string") { - try { - const parsed = JSON.parse(apiError.responseBody) as { - error?: { message?: string } | string; - }; - if (typeof parsed.error === "string") { - return parsed.error; - } - if (typeof parsed.error?.message === "string") { - return parsed.error.message; - } - } catch { - // Fall through to other representations. - } - } - if (typeof apiError.message === "string" && apiError.message.trim()) { - return apiError.message; - } - } - - if ( - error && - typeof error === "object" && - "cause" in error && - (error as { cause?: unknown }).cause - ) { - return extractErrorMessage((error as { cause: unknown }).cause); - } - - if (error instanceof Error && error.message.trim()) { - return error.message; - } - - return String(error); -} - function extractGoogleThoughtMetadata( part: AiSdkStreamPart, ): Record | undefined { @@ -764,11 +742,16 @@ async function* emitAiSdkEvents( part.finishReason ?? part.rawFinishReason ?? part.reason; } - if (part.type === "error") { + if (part.type === "error" || part.type === "tool-error") { streamError = capturedError?.current ?? extractErrorMessage(part.error); break; } + + if (part.type === "abort") { + // abort + break; + } } } else if (stream.textStream) { for await (const text of stream.textStream) { @@ -785,7 +768,10 @@ async function* emitAiSdkEvents( // stream.usage may be undefined in mocked/test scenarios, fall back to finish part + its providerMetadata. let usageToEmit: unknown; let metadataToUse: unknown; - if (stream.usage) { + if (streamError) { + usageToEmit = finishUsage; + metadataToUse = finishProviderMetadata; + } else if (stream.usage) { try { usageToEmit = await stream.usage; } catch (error) { @@ -896,8 +882,15 @@ function createAiSdkProvider(kind: ProviderModuleKind): GatewayProviderFactory { stream = streamText({ model: provider.model(context.model.id) as never, messages: (shouldUseAnthropicPromptCache(request, context) - ? buildCachedAiSdkMessages(request, context, request.systemPrompt) - : toAiSdkMessages(request.messages, request.systemPrompt)) as never, + ? buildCachedAiSdkMessages( + request, + context, + resolveAiSdkSystemPrompt(request), + ) + : toAiSdkMessages( + request.messages, + resolveAiSdkSystemPrompt(request), + )) as never, tools: tools as never, temperature: request.temperature, maxOutputTokens: request.maxTokens, diff --git a/sdk/packages/llms/src/providers/builtins.ts b/sdk/packages/llms/src/providers/builtins.ts index 51612c1712..5655ecc51b 100644 --- a/sdk/packages/llms/src/providers/builtins.ts +++ b/sdk/packages/llms/src/providers/builtins.ts @@ -549,10 +549,21 @@ export const BUILTIN_SPECS: BuiltinSpec[] = [ }, { id: "openai-codex", - name: "OpenAI Codex", + name: "OpenAI ChatGPT Subscription", + description: + "OpenAI ChatGPT subscription access uses an OAuth device code flow.", + family: "openai", + capabilities: ["reasoning", "oauth", "provider-tools"], + defaultModelId: "gpt-5.4", + modelsProviderId: "openai", + defaults: { baseUrl: "https://chatgpt.com/backend-api/codex" }, + }, + { + id: "openai-codex-cli", + name: "OpenAI Codex CLI", description: "OpenAI Codex via the local Codex CLI provider", family: "openai-codex", - capabilities: ["reasoning", "oauth", "provider-tools"], + capabilities: ["reasoning", "provider-tools"], defaultModelId: "gpt-5.3-codex", modelsProviderId: "openai", defaults: { baseUrl: "https://chatgpt.com/backend-api/codex" }, diff --git a/sdk/packages/llms/src/providers/compat.ts b/sdk/packages/llms/src/providers/compat.ts index e2c00ff370..24a032fff4 100644 --- a/sdk/packages/llms/src/providers/compat.ts +++ b/sdk/packages/llms/src/providers/compat.ts @@ -108,6 +108,7 @@ function toGatewayModelDefinition( function resolveFactory(providerId: string): GatewayProviderFactory { const normalized = normalizeProviderId(providerId); switch (normalized) { + case "openai-codex": case "openai-native": return createOpenAIProvider; case "anthropic": @@ -123,7 +124,7 @@ function resolveFactory(providerId: string): GatewayProviderFactory { return createMistralProvider; case "claude-code": return createClaudeCodeProvider; - case "openai-codex": + case "openai-codex-cli": return createOpenAICodexProvider; case "opencode": return createOpenCodeProvider; diff --git a/sdk/packages/llms/src/providers/format.test.ts b/sdk/packages/llms/src/providers/format.test.ts new file mode 100644 index 0000000000..780d01e2f3 --- /dev/null +++ b/sdk/packages/llms/src/providers/format.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { extractErrorMessage } from "./format"; + +describe("extractErrorMessage", () => { + it("extracts structured provider errors without fallback branches", () => { + expect( + extractErrorMessage({ + statusCode: 400, + responseBody: { + error: { + message: "Bad request detail", + }, + }, + message: "Bad Request", + }), + ).toBe("Bad request detail"); + + expect( + extractErrorMessage({ + cause: new Error("Nested failure"), + }), + ).toBe("Nested failure"); + + expect(extractErrorMessage(new Error("Plain failure"))).toBe( + "Plain failure", + ); + }); +}); diff --git a/sdk/packages/llms/src/providers/format.ts b/sdk/packages/llms/src/providers/format.ts new file mode 100644 index 0000000000..846fbbb163 --- /dev/null +++ b/sdk/packages/llms/src/providers/format.ts @@ -0,0 +1,61 @@ +export function extractErrorMessage(error: unknown): string { + const extractStructuredMessage = (value: unknown): string | undefined => { + if (!value) { + return undefined; + } + if (typeof value === "string") { + try { + return extractStructuredMessage(JSON.parse(value)); + } catch { + return value.trim() || undefined; + } + } + if (typeof value !== "object") { + return undefined; + } + const payload = value as { + error?: { message?: string } | string; + detail?: string; + message?: string; + responseBody?: unknown; + cause?: unknown; + }; + if (typeof payload.error === "string" && payload.error.trim()) { + return payload.error; + } + if ( + payload.error && + typeof payload.error === "object" && + typeof payload.error.message === "string" && + payload.error.message.trim() + ) { + return payload.error.message; + } + if (typeof payload.detail === "string" && payload.detail.trim()) { + return payload.detail; + } + if ("responseBody" in payload && payload.responseBody !== value) { + const nested = extractStructuredMessage(payload.responseBody); + if (nested) { + return nested; + } + } + if ("cause" in payload && payload.cause !== value) { + const nested = extractStructuredMessage(payload.cause); + if (nested) { + return nested; + } + } + if (typeof payload.message === "string" && payload.message.trim()) { + return payload.message; + } + return undefined; + }; + + const structuredMessage = extractStructuredMessage(error); + if (structuredMessage) { + return structuredMessage; + } + + return String(error); +} diff --git a/sdk/packages/llms/src/providers/gateway.test.ts b/sdk/packages/llms/src/providers/gateway.test.ts index fa6bd4a3cd..3c4f636666 100644 --- a/sdk/packages/llms/src/providers/gateway.test.ts +++ b/sdk/packages/llms/src/providers/gateway.test.ts @@ -316,6 +316,83 @@ describe("sdk-gateway", () => { }); }); + it("does not wait for usage when an AI SDK stream emits an error part", async () => { + streamTextSpy.mockReturnValue({ + fullStream: makeStreamParts([ + { + type: "error", + error: new Error("Invalid API key"), + }, + ]), + usage: new Promise(() => {}), + }); + + const gateway = createGateway({ + providerConfigs: [ + { + providerId: "openai-native", + apiKey: "test", + }, + ], + }); + + const events = await Promise.race([ + collect( + await gateway.stream({ + providerId: "openai-native", + modelId: "gpt-5-mini", + messages: baseMessages, + }), + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error("timed out waiting for stream")), 50), + ), + ]); + + expect(events).toEqual([ + { + type: "finish", + reason: "error", + error: "Invalid API key", + }, + ]); + }); + + it("surfaces API detail fields from OpenAI-compatible error bodies", async () => { + const apiError = Object.assign(new Error("Bad Request"), { + statusCode: 400, + responseBody: JSON.stringify({ + detail: "Instructions are required", + }), + }); + streamTextSpy.mockReturnValue({ + fullStream: makeFailingStreamParts(apiError), + }); + + const gateway = createGateway({ + providerConfigs: [ + { + providerId: "openai-native", + apiKey: "test", + }, + ], + }); + + const events = await collect( + await gateway.stream({ + providerId: "openai-native", + modelId: "gpt-5-mini", + messages: baseMessages, + }), + ); + + expect(events.at(-1)).toEqual({ + type: "finish", + reason: "error", + error: "Instructions are required", + }); + }); + it("passes user file blocks through as text content", async () => { streamTextSpy.mockReturnValue({ fullStream: makeStreamParts([ @@ -1243,7 +1320,7 @@ describe("sdk-gateway", () => { }), ); - expect(codexExecSpy).toHaveBeenCalledWith("gpt-5-codex"); + expect(openaiResponsesSpy).toHaveBeenCalledWith("gpt-5-codex"); expect(streamTextSpy).toHaveBeenCalledWith( expect.objectContaining({ tools: undefined, @@ -1295,6 +1372,55 @@ describe("sdk-gateway", () => { }); }); + it("passes Codex instructions through provider options and removes the system message from messages", async () => { + streamTextSpy.mockReturnValue({ + fullStream: makeStreamParts([ + { type: "finish", usage: { inputTokens: 1, outputTokens: 1 } }, + ]), + }); + + const gateway = createGateway({ + providerConfigs: [{ providerId: "openai-codex" }], + }); + + await collect( + await gateway.stream({ + providerId: "openai-codex", + modelId: "gpt-5.4", + systemPrompt: "You are helpful.", + messages: baseMessages, + reasoning: { + effort: "high", + }, + }), + ); + + expect(streamTextSpy).toHaveBeenCalledWith( + expect.objectContaining({ + messages: [ + { + role: "user", + content: [{ type: "text", text: "Hello" }], + }, + ], + providerOptions: expect.objectContaining({ + openai: expect.objectContaining({ + instructions: "You are helpful.", + store: false, + }), + "openai-codex": expect.objectContaining({ + store: false, + reasoningEffort: "high", + reasoningSummary: "auto", + }), + openaiCodex: expect.objectContaining({ + store: false, + }), + }), + }), + ); + }); + it("passes reasoning effort through to Anthropic provider options", async () => { streamTextSpy.mockReturnValue({ fullStream: makeStreamParts([ diff --git a/sdk/packages/llms/src/providers/ids.ts b/sdk/packages/llms/src/providers/ids.ts index 01e8925b83..808cd1f7da 100644 --- a/sdk/packages/llms/src/providers/ids.ts +++ b/sdk/packages/llms/src/providers/ids.ts @@ -14,6 +14,7 @@ export enum BUILT_IN_PROVIDER { // OPENAI = "openai", // OpenAi Completions (deprecated - not a built-in pre-configured provider) OPENAI_NATIVE = "openai-native", OPENAI_CODEX = "openai-codex", + OPENAI_CODEX_CLI = "openai-codex-cli", // CLI / Subscription-based providers OPENCODE = "opencode", // Cloud providers diff --git a/sdk/packages/llms/src/providers/provider-keys.ts b/sdk/packages/llms/src/providers/provider-keys.ts index 1569a9f280..68d4497c54 100644 --- a/sdk/packages/llms/src/providers/provider-keys.ts +++ b/sdk/packages/llms/src/providers/provider-keys.ts @@ -14,6 +14,11 @@ const PROVIDER_IDS_MAP: ReadonlyArray<{ generatedProviderId: "openai-native", runtimeProviderId: "openai-codex", }, + { + modelsDevKey: "openai", + generatedProviderId: "openai-native", + runtimeProviderId: "openai-codex-cli", + }, { modelsDevKey: "anthropic", generatedProviderId: "anthropic" }, { modelsDevKey: "anthropic", diff --git a/sdk/packages/llms/src/providers/vendors/openai.ts b/sdk/packages/llms/src/providers/vendors/openai.ts index ead80f25c8..a7964e26ea 100644 --- a/sdk/packages/llms/src/providers/vendors/openai.ts +++ b/sdk/packages/llms/src/providers/vendors/openai.ts @@ -13,6 +13,7 @@ export async function createOpenAIProviderModule( const apiKey = await resolveApiKey(config); const provider = createOpenAI({ apiKey, + baseURL: config.baseUrl, headers: config.headers, fetch: config.fetch, name: context.provider.id,