feat: OpenAI ChatGPT Subscription provider (#241)

Add OpenAI Codex-specific provider config handling in local runtime
bootstrap by:
- building headers with originator/session metadata
- merging configured and stored headers
- setting ChatGPT-Account-Id from persisted OAuth data or deriving it
from the access token payload

Also update AI SDK provider options for `openai-codex` to send
`instructions`, disable storage, and remove duplicated system messages.

Includes regression tests to verify stored and token-derived Codex
account IDs are correctly applied to request headers.fix(runtime):
populate Codex headers and request options

Add OpenAI Codex-specific provider config handling in local runtime
bootstrap by:
- building headers with originator/session metadata
- merging configured and stored headers
- setting ChatGPT-Account-Id from persisted OAuth data or deriving it
from the access token payload

Also update AI SDK provider options for `openai-codex` to send
`instructions`, disable storage, and remove duplicated system messages.

Includes regression tests to verify stored and token-derived Codex
account IDs are correctly applied to request headers.

demo:
```
cline-packages on  bee/gpt [$+] via 🥟 v1.3.10 on ☁️  beatrix@cline.bot 
❯ bun run cli hub stop
bun run cli hub ensure
bun run cli "hey"
bun run cli "hey" --json

$ bun --conditions=development --cwd apps/cli dev hub stop
$ CLINE_BUILD_ENV=development bun --conditions=development ./src/index.ts hub stop
{"stopped":true}
$ bun --conditions=development --cwd apps/cli dev hub ensure
$ CLINE_BUILD_ENV=development bun --conditions=development ./src/index.ts hub ensure
ws://127.0.0.1:59068/hub
$ bun --conditions=development --cwd apps/cli dev hey
$ CLINE_BUILD_ENV=development bun --conditions=development ./src/index.ts hey
Plan:
- This is a simple greeting with no coding context, so I can answer directly without tools.

Hey! What can I help you with?

$ bun --conditions=development --cwd apps/cli dev hey --json
$ CLINE_BUILD_ENV=development bun --conditions=development ./src/index.ts hey --json
{"ts":"2026-04-24T05:50:42.408Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":"Plan"}}
{"ts":"2026-04-24T05:50:42.426Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":":\n"}}
{"ts":"2026-04-24T05:50:42.440Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":"-"}}
{"ts":"2026-04-24T05:50:42.453Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" This"}}
{"ts":"2026-04-24T05:50:42.469Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" is"}}
{"ts":"2026-04-24T05:50:42.485Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" a"}}
{"ts":"2026-04-24T05:50:42.505Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" simple"}}
{"ts":"2026-04-24T05:50:42.505Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" greeting"}}
{"ts":"2026-04-24T05:50:42.530Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" with"}}
{"ts":"2026-04-24T05:50:42.545Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" no"}}
{"ts":"2026-04-24T05:50:42.569Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" coding"}}
{"ts":"2026-04-24T05:50:42.579Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" context"}}
{"ts":"2026-04-24T05:50:42.620Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":","}}
{"ts":"2026-04-24T05:50:42.620Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" so"}}
{"ts":"2026-04-24T05:50:42.646Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" I"}}
{"ts":"2026-04-24T05:50:42.658Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" can"}}
{"ts":"2026-04-24T05:50:42.763Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" answer"}}
{"ts":"2026-04-24T05:50:42.763Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" directly"}}
{"ts":"2026-04-24T05:50:42.763Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" without"}}
{"ts":"2026-04-24T05:50:42.763Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" tools"}}
{"ts":"2026-04-24T05:50:42.763Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":".\n\n"}}
{"ts":"2026-04-24T05:50:42.763Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":"Hey"}}
{"ts":"2026-04-24T05:50:42.785Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":"!"}}
{"ts":"2026-04-24T05:50:42.796Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" What"}}
{"ts":"2026-04-24T05:50:42.825Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" can"}}
{"ts":"2026-04-24T05:50:42.838Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" I"}}
{"ts":"2026-04-24T05:50:42.857Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" help"}}
{"ts":"2026-04-24T05:50:42.874Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" you"}}
{"ts":"2026-04-24T05:50:42.900Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":" with"}}
{"ts":"2026-04-24T05:50:42.921Z","type":"agent_event","event":{"type":"content_start","contentType":"text","text":"?"}}
{"ts":"2026-04-24T05:50:43.066Z","type":"run_result","finishReason":"completed","iterations":1,"usage":{"inputTokens":1491,"outputTokens":34,"totalCost":0.0042375},"durationMs":1318,"text":"Plan:\n- This is a simple greeting with no coding context, so I can answer directly without tools.\n\nHey! What can I help you with?","model":{"id":"gpt-5.4","provider":"openai-codex","info":{"id":"gpt-5.4","name":"GPT-5.4","contextWindow":1050000,"maxTokens":128000,"capabilities":["images","files","tools","reasoning","structured_output","prompt-cache"],"pricing":{"input":2.5,"output":15,"cacheRead":0.25,"cacheWrite":0},"releaseDate":"2026-03-05","family":"gpt"}}}
```

---------

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Bee
2026-04-24 00:50:53 -07:00
committed by GitHub
co-authored by Copilot
parent 0c7fb537c3
commit caa8c1c66e
21 changed files with 674 additions and 73 deletions
+16
View File
@@ -169,6 +169,7 @@ export async function runAgent(
let reasoningChunkCount = 0;
let redactedReasoningChunkCount = 0;
const displayedErrorMessages = new Set<string>();
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;
}
+1 -1
View File
@@ -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"
},
@@ -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
+2
View File
@@ -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,
+43
View File
@@ -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<void>((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<void>((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({
+34 -8
View File
@@ -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<string, unknown> {
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<EnsuredHubWebSocketServerResult> => {
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 });
}
});
}
@@ -45,6 +45,8 @@ export async function startHubServer(
export async function ensureHubServer(
options: EnsureHubServerOptions,
): Promise<EnsureHubServerResult> {
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(),
});
}
@@ -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,
@@ -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<string, string>;
};
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",
});
});
});
@@ -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<string, string> | undefined {
const headers: Record<string, string> = {
...(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<ProviderSettings["modelCatalog"]>,
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,
@@ -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"),
+4 -3
View File
@@ -857,10 +857,11 @@ export class LocalRuntimeHost implements RuntimeHost {
): Promise<void> {
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,
});
}
+47 -54
View File
@@ -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<string, unknown> = {
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<string, unknown> | 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,
+13 -2
View File
@@ -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" },
+2 -1
View File
@@ -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;
@@ -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",
);
});
});
+61
View File
@@ -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);
}
+127 -1
View File
@@ -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<never>((_, 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([
+1
View File
@@ -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
@@ -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",
+1
View File
@@ -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,