mirror of
https://github.com/cline/cline.git
synced 2026-09-21 05:10:09 +08:00
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:
committed by
GitHub
co-authored by
Copilot
parent
0c7fb537c3
commit
caa8c1c66e
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user