@bitkyc08/opencodex 2.7.13 → 2.7.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/gui/dist/assets/index-BUBsQALh.css +1 -0
  2. package/gui/dist/assets/index-DEbBFENM.js +40 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/anthropic-image-guard.ts +63 -7
  6. package/src/adapters/anthropic-image-normalize.ts +383 -0
  7. package/src/adapters/anthropic.ts +7 -2
  8. package/src/adapters/base.ts +8 -0
  9. package/src/adapters/cursor/exec-policy.ts +10 -2
  10. package/src/adapters/cursor/live-transport.ts +19 -11
  11. package/src/adapters/cursor/native-exec-fs.ts +1 -1
  12. package/src/adapters/cursor/native-exec-network.ts +1 -1
  13. package/src/adapters/cursor/native-exec-shell.ts +1 -1
  14. package/src/adapters/cursor/protobuf-request.ts +7 -6
  15. package/src/adapters/cursor/tool-definitions.ts +3 -0
  16. package/src/adapters/google-http.ts +1 -1
  17. package/src/adapters/kiro-images.ts +94 -0
  18. package/src/adapters/kiro-retry.ts +1 -1
  19. package/src/adapters/kiro.ts +6 -2
  20. package/src/adapters/openai-chat.ts +102 -5
  21. package/src/adapters/openai-responses.ts +177 -2
  22. package/src/bridge.ts +25 -10
  23. package/src/cli/claude.ts +3 -0
  24. package/src/codex/catalog.ts +11 -0
  25. package/src/lib/upstream-retry.ts +6 -0
  26. package/src/providers/registry.ts +27 -2
  27. package/src/server/claude-messages.ts +11 -0
  28. package/src/server/image-retry.ts +42 -0
  29. package/src/server/management-api.ts +27 -0
  30. package/src/server/responses.ts +77 -24
  31. package/src/server/system-env.ts +7 -3
  32. package/src/types.ts +22 -4
  33. package/src/web-search/index.ts +8 -5
  34. package/src/web-search/loop.ts +11 -6
  35. package/gui/dist/assets/index-BNySqP9I.js +0 -40
  36. package/gui/dist/assets/index-Cq8maiJf.css +0 -1
@@ -15,14 +15,14 @@ import {
15
15
  CreatePlanResultSchema,
16
16
  CreatePlanSuccessSchema,
17
17
  ExaFetchRequestResponseSchema,
18
- ExaFetchRequestResponse_RejectedSchema,
18
+ ExaFetchRequestResponse_ApprovedSchema,
19
19
  ExaSearchRequestResponseSchema,
20
- ExaSearchRequestResponse_RejectedSchema,
20
+ ExaSearchRequestResponse_ApprovedSchema,
21
21
  InteractionResponseSchema,
22
22
  SwitchModeRequestResponseSchema,
23
23
  SwitchModeRequestResponse_RejectedSchema,
24
24
  WebSearchRequestResponseSchema,
25
- WebSearchRequestResponse_RejectedSchema,
25
+ WebSearchRequestResponse_ApprovedSchema,
26
26
  type AgentServerMessage,
27
27
  type ExecServerMessage,
28
28
  type InteractionQuery,
@@ -181,8 +181,16 @@ export function planMcpArgsHandling(
181
181
  * Codex as visible output so the user still sees it.
182
182
  * - askQuestion: reject with a reason — the agent must proceed autonomously; there is no human to
183
183
  * answer mid-turn. (Future: bridge to a Codex user-input request.)
184
- * - switchMode / webSearch / exaSearch / exaFetch: reject (deterministic default; web search has
185
- * its own sidecar path outside this transport).
184
+ * - webSearch / exaSearch / exaFetch: APPROVE (empty approval). These are approve/reject
185
+ * permission gates, not client-run requests the response schema has no result field, so
186
+ * approval delegates the search to Cursor's SERVER, which runs it and injects results into the
187
+ * model server-side (the answer then streams back as textDelta; the display-plane
188
+ * web_search_tool_call/exa_*_tool_call result frames are native, non-mcp, and safely dropped by
189
+ * the event mapper). Rejecting them (the old default) killed the model's web capability on the
190
+ * Cursor path. Tradeoff: approval consumes the user's Cursor web-search/Exa quota. The synthetic
191
+ * web_search sidecar (src/web-search) is an orthogonal proxy-side path used only when the client
192
+ * sends a hosted web_search tool; it does not cover Cursor-native web search.
193
+ * - switchMode: reject (deterministic default; no non-interactive mode switch).
186
194
  * - setupVmEnvironment: the result schema has no error case — reply success so the agent is not
187
195
  * left waiting; the command itself was never run locally.
188
196
  * Pure (no I/O) for unit testing; `handleServerMessage` writes the frame and emits liveness.
@@ -240,10 +248,10 @@ export function planInteractionQueryReply(query: InteractionQuery): { response:
240
248
  response: respond({
241
249
  case: "webSearchRequestResponse",
242
250
  value: create(WebSearchRequestResponseSchema, {
243
- result: { case: "rejected", value: create(WebSearchRequestResponse_RejectedSchema, { reason: NON_INTERACTIVE_REASON }) },
251
+ result: { case: "approved", value: create(WebSearchRequestResponse_ApprovedSchema, {}) },
244
252
  }),
245
253
  }),
246
- replyCase: "webSearchRequestResponse:rejected",
254
+ replyCase: "webSearchRequestResponse:approved",
247
255
  };
248
256
  }
249
257
  if (q.case === "exaSearchRequestQuery") {
@@ -251,10 +259,10 @@ export function planInteractionQueryReply(query: InteractionQuery): { response:
251
259
  response: respond({
252
260
  case: "exaSearchRequestResponse",
253
261
  value: create(ExaSearchRequestResponseSchema, {
254
- result: { case: "rejected", value: create(ExaSearchRequestResponse_RejectedSchema, { reason: NON_INTERACTIVE_REASON }) },
262
+ result: { case: "approved", value: create(ExaSearchRequestResponse_ApprovedSchema, {}) },
255
263
  }),
256
264
  }),
257
- replyCase: "exaSearchRequestResponse:rejected",
265
+ replyCase: "exaSearchRequestResponse:approved",
258
266
  };
259
267
  }
260
268
  if (q.case === "exaFetchRequestQuery") {
@@ -262,10 +270,10 @@ export function planInteractionQueryReply(query: InteractionQuery): { response:
262
270
  response: respond({
263
271
  case: "exaFetchRequestResponse",
264
272
  value: create(ExaFetchRequestResponseSchema, {
265
- result: { case: "rejected", value: create(ExaFetchRequestResponse_RejectedSchema, { reason: NON_INTERACTIVE_REASON }) },
273
+ result: { case: "approved", value: create(ExaFetchRequestResponse_ApprovedSchema, {}) },
266
274
  }),
267
275
  }),
268
- replyCase: "exaFetchRequestResponse:rejected",
276
+ replyCase: "exaFetchRequestResponse:approved",
269
277
  };
270
278
  }
271
279
  if (q.case === "setupVmEnvironmentArgs") {
@@ -44,7 +44,7 @@ function codexNativeMutationRefusal(operation: "write" | "delete"): string {
44
44
  }
45
45
 
46
46
  const NATIVE_LOCAL_EXEC_DISABLED =
47
- "Cursor native local filesystem execution is disabled by default because it bypasses Codex approval and sandbox enforcement. Set provider.unsafeAllowNativeLocalExec=true only for trusted local experiments that may read or mutate local files directly.";
47
+ "Cursor native local filesystem execution is not available for this request. Use the exec_command tool with equivalent shell commands (cat, head, ls, rg, grep) for file reads and searches, or apply_patch for file edits.";
48
48
 
49
49
  export function rejectReadExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
50
50
  if (execMsg.message.case !== "readArgs") throw new Error("invalid read exec");
@@ -7,7 +7,7 @@ export interface CursorNativeNetworkDeps {
7
7
  }
8
8
 
9
9
  const NATIVE_FETCH_DISABLED =
10
- "Cursor native fetch execution is disabled by default because it bypasses Codex approval and sandbox enforcement. Set provider.unsafeAllowNativeLocalExec=true only for trusted local experiments that may make local network requests directly.";
10
+ "Cursor native fetch execution is not available for this request. Use the exec_command tool with curl or wget to make network requests instead.";
11
11
 
12
12
  export function rejectFetchExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
13
13
  if (execMsg.message.case !== "fetchArgs") throw new Error("invalid fetch exec");
@@ -24,7 +24,7 @@ const backgroundShells = new Map<number, { child: ChildProcessWithoutNullStreams
24
24
  let nextShellId = 1;
25
25
 
26
26
  const NATIVE_SHELL_DISABLED =
27
- "Cursor native shell execution is disabled by default because it bypasses Codex approval and sandbox enforcement. Set provider.unsafeAllowNativeLocalExec=true only for trusted local experiments that may run local commands directly.";
27
+ "Cursor native shell execution is not available for this request. Use the exec_command tool to run shell commands instead.";
28
28
 
29
29
  function rejectedShellResult(command: string, cwd: string, started: number) {
30
30
  return create(ShellResultSchema, {
@@ -108,12 +108,13 @@ function rootPromptMessages(request: CursorRunRequest): Uint8Array[] {
108
108
  } else if (message.role === "assistant") {
109
109
  const text = assistantRootText(message).trim();
110
110
  if (text.length > 0) entries.push(storeCursorBlob(jsonBlob({ role: "assistant", content: [{ type: "text", text }] })));
111
- for (const part of message.content) {
112
- if (typeof part === "string" || part.type !== "toolCall") continue;
113
- const toolName = namespacedToolName(part.namespace, part.name);
114
- const callText = `[Tool Call]\ncall_id: ${part.id}\nname: ${toolName}\narguments:\n${JSON.stringify(part.arguments ?? {})}`;
115
- entries.push(storeCursorBlob(jsonBlob({ role: "assistant", content: [{ type: "text", text: callText }] })));
116
- }
111
+ // Assistant tool CALLS are intentionally NOT replayed as visible "[Tool Call]" text here.
112
+ // rootPromptMessagesJson is the model-visible prompt, so a synthetic "[Tool Call]" marker in an
113
+ // assistant turn gets few-shot-mimicked: the model then emits later (esp. parallel/mixed) tool
114
+ // calls as inert text instead of real tool frames, halting multi-tool continuations. The paired
115
+ // tool result below ([Tool Result]/[Tool Error]) carries the call id/name/output Cursor needs to
116
+ // continue, and conversationTurns replays the native mcpToolCall step. Mirrors request-builder.ts
117
+ // contentPartToText() which returns undefined for toolCall for the same reason.
117
118
  } else if (message.role === "toolResult") {
118
119
  const prefix = message.isError ? "[Tool Error]" : "[Tool Result]";
119
120
  const text = `${prefix}\n${toolResultToText(message)}`;
@@ -272,6 +272,9 @@ export function buildCursorToolGuidanceSystemNote(
272
272
  ? `Use ${discoveryTools} only for explicit discovery/resource tasks, not generic tool-count demos.`
273
273
  : undefined,
274
274
  "Do not count or report a tool call unless a tool result was actually returned.",
275
+ hasBareExec
276
+ ? "If a built-in file read, directory listing, grep, or shell operation is rejected by the runtime, use \`exec_command\` with the equivalent shell command instead (e.g. \`cat\`, \`ls\`, \`rg\`, \`grep\`). For file edits, use \`apply_patch\` when available."
277
+ : undefined,
275
278
  ].filter((note): note is string => typeof note === "string");
276
279
  return notes.join(" ");
277
280
  }
@@ -36,7 +36,7 @@ export async function fetchGoogleWithRetry(label: string, request: AdapterReques
36
36
  method: request.method,
37
37
  headers: request.headers,
38
38
  body: request.body,
39
- }, timeoutMs, ctx.abortSignal);
39
+ }, timeoutMs, ctx.abortSignal, ctx.stream);
40
40
  if (!retryableGoogleStatus(res.status) || attempt === GOOGLE_RETRY_ATTEMPTS - 1) {
41
41
  return ctx.returnRawErrors ? res : normalizeFinalGoogleError(label, res, ctx.abortSignal);
42
42
  }
@@ -1,4 +1,5 @@
1
1
  import type { OcxContentPart } from "../types";
2
+ import { normalizeImageTargets, type NormalizeOptions, type NormalizeTarget } from "./anthropic-image-normalize";
2
3
 
3
4
  // CodeWhisperer native image part (matches Kiro IDE wire format): the base64 bytes live directly in
4
5
  // userInputMessage.images, NOT in userInputMessageContext. Verified against kiro-gateway.
@@ -33,3 +34,96 @@ export function extractKiroImages(content: string | OcxContentPart[]): KiroImage
33
34
  }
34
35
  return out;
35
36
  }
37
+
38
+ /**
39
+ * Conservative POLICY caps for the CodeWhisperer GenerateAssistantResponse payload,
40
+ * whose limits are undocumented. Derived from adjacent AWS surfaces
41
+ * (devlog/260714_image_normalization_pipeline/050): Bedrock `Message` allows 20 images
42
+ * per message (Converse), and `InvokeModel` caps requests at 25,000,000 bytes — 18MiB
43
+ * bounds the IMAGE share of the body with headroom for text/tools.
44
+ */
45
+ export const KIRO_IMAGE_BASE64_BUDGET = 18 * 1024 * 1024;
46
+ export const KIRO_MAX_IMAGES_PER_MESSAGE = 20;
47
+
48
+ const COUNT_CAP_NOTE = "[image omitted: exceeded the 20-image per-message cap; oldest images in this message were dropped]";
49
+
50
+ /** A kiro wire message that can carry images (history userInputMessage or currentMessage). */
51
+ interface KiroImageCarrier {
52
+ content?: string;
53
+ images?: KiroImage[];
54
+ }
55
+
56
+ function isCarrier(v: unknown): v is KiroImageCarrier {
57
+ return typeof v === "object" && v !== null;
58
+ }
59
+
60
+ /** Collect image-bearing userInputMessages in wire order (history oldest-first, then current). */
61
+ function collectKiroImageCarriers(payload: unknown): KiroImageCarrier[] {
62
+ const state = (payload as { conversationState?: { history?: unknown[]; currentMessage?: { userInputMessage?: unknown } } })?.conversationState;
63
+ if (!state) return [];
64
+ const carriers: KiroImageCarrier[] = [];
65
+ for (const entry of state.history ?? []) {
66
+ const uim = (entry as { userInputMessage?: unknown })?.userInputMessage;
67
+ if (isCarrier(uim)) carriers.push(uim);
68
+ }
69
+ const current = state.currentMessage?.userInputMessage;
70
+ if (isCarrier(current)) carriers.push(current);
71
+ return carriers;
72
+ }
73
+
74
+ function appendNote(carrier: KiroImageCarrier, note: string): void {
75
+ carrier.content = carrier.content ? `${carrier.content}\n${note}` : note;
76
+ }
77
+
78
+ /**
79
+ * Apply the generous image pipeline to a built CodeWhisperer payload (mutates in
80
+ * place): per-message 20-image cap first (oldest dropped), then the shared tier
81
+ * machinery with the kiro budget and terminal-overflow DROP (kiro has no downstream
82
+ * guard). Test seams (encode/validate) forward into the core.
83
+ */
84
+ export async function normalizeKiroImages(
85
+ payload: unknown,
86
+ opts?: Pick<NormalizeOptions, "encode" | "validate">,
87
+ ): Promise<void> {
88
+ const carriers = collectKiroImageCarriers(payload);
89
+ if (carriers.length === 0) return;
90
+
91
+ // Pre-pass: per-message count cap (drop oldest within the message).
92
+ for (const carrier of carriers) {
93
+ const images = carrier.images;
94
+ if (!images || images.length <= KIRO_MAX_IMAGES_PER_MESSAGE) continue;
95
+ images.splice(0, images.length - KIRO_MAX_IMAGES_PER_MESSAGE);
96
+ appendNote(carrier, COUNT_CAP_NOTE);
97
+ }
98
+
99
+ // Targets over the survivors, oldest→newest across carriers. Drops resolve the image
100
+ // by OBJECT IDENTITY at execution time (indices go stale after earlier splices) and
101
+ // delete an emptied images field per the builder's omission contract.
102
+ const targets: NormalizeTarget[] = [];
103
+ for (const carrier of carriers) {
104
+ for (const img of carrier.images ?? []) {
105
+ targets.push({
106
+ base64: typeof img.source?.bytes === "string" && img.source.bytes.length > 0 ? img.source.bytes : null,
107
+ mediaType: `image/${(img.format || "jpeg").toLowerCase()}`,
108
+ replace: (data: string, mediaType: string) => {
109
+ img.source.bytes = data;
110
+ img.format = (mediaType.split("/")[1] ?? "jpeg").toLowerCase();
111
+ },
112
+ drop: (note: string) => {
113
+ const arr = carrier.images;
114
+ if (arr) {
115
+ const idx = arr.indexOf(img);
116
+ if (idx !== -1) arr.splice(idx, 1);
117
+ if (arr.length === 0) delete carrier.images;
118
+ }
119
+ appendNote(carrier, note);
120
+ },
121
+ });
122
+ }
123
+ }
124
+ await normalizeImageTargets(targets, {
125
+ budget: KIRO_IMAGE_BASE64_BUDGET,
126
+ overflowAction: "drop",
127
+ ...(opts ?? {}),
128
+ });
129
+ }
@@ -39,7 +39,7 @@ export async function fetchKiroWithRetry(request: AdapterRequest, ctx: AdapterFe
39
39
  method: request.method,
40
40
  headers: request.headers,
41
41
  body: request.body,
42
- }, timeoutMs, ctx.abortSignal);
42
+ }, timeoutMs, ctx.abortSignal, ctx.stream);
43
43
  if (!retryableKiroStatus(res.status) || attempt === KIRO_RETRY_ATTEMPTS - 1) {
44
44
  return ctx.returnRawErrors ? res : normalizeFinalKiroHttpError(res, ctx.abortSignal);
45
45
  }
@@ -25,7 +25,7 @@ import type {
25
25
  } from "../types";
26
26
  import type { ProviderAdapter } from "./base";
27
27
  import type { AdapterFetchContext, AdapterRequest } from "./base";
28
- import { extractKiroImages, type KiroImage } from "./kiro-images";
28
+ import { extractKiroImages, normalizeKiroImages, type KiroImage } from "./kiro-images";
29
29
  import { fetchKiroWithRetry } from "./kiro-retry";
30
30
  import { convertKiroToolContext } from "./kiro-tools";
31
31
  import { neutralizeIdentity } from "./identity";
@@ -497,7 +497,7 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
497
497
  let toolNameMap: Map<string, string> | undefined;
498
498
  return {
499
499
  name: "kiro",
500
- buildRequest(parsed: OcxParsedRequest) {
500
+ async buildRequest(parsed: OcxParsedRequest) {
501
501
  if (typeof provider.apiKey !== "string" || provider.apiKey.trim() === "") {
502
502
  throw new Error("kiro token missing — run ocx login kiro");
503
503
  }
@@ -520,6 +520,10 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
520
520
  // fake-reasoning contract by injecting effort-derived thinking tags into only the current user turn.
521
521
  const built = buildKiroPayload(parsed, profileArn);
522
522
  toolNameMap = built.nameMap;
523
+ // Generous image pipeline (devlog/260714_image_normalization_pipeline/050):
524
+ // tier-normalize + cap images before serialization so bodyBytes below reflects
525
+ // the normalized size.
526
+ await normalizeKiroImages(built.payload);
523
527
  const body = JSON.stringify(built.payload);
524
528
  debugProviderDiagnostic("kiro", "request", {
525
529
  region,
@@ -2,6 +2,7 @@ import type { ProviderAdapter } from "./base";
2
2
  import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types";
3
3
  import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolAllowedByChoice } from "../types";
4
4
  import { mapReasoningEffort } from "../reasoning-effort";
5
+ import { redactSecretString } from "../lib/redact";
5
6
  import { contentPartsToText } from "./image";
6
7
  import { neutralizeIdentity } from "./identity";
7
8
  import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
@@ -13,6 +14,52 @@ export function stripBracketedModelSuffix(modelId: string): string {
13
14
  return modelId.replace(/\[[^\]]*\]\s*$/, "");
14
15
  }
15
16
 
17
+ // 260715 (issue #126): surface upstream error detail through the web-search sidecar loop.
18
+ // loop.ts only appends a suffix to "Provider error N" when the adapter exposes
19
+ // formatErrorBody; without it, strict OpenAI-compatible backends (NVIDIA NIM pydantic
20
+ // validation, "This model only supports single tool-calls at once!", etc.) were reduced
21
+ // to a bare status code. JSON-only extraction: recognized string fields are returned,
22
+ // HTML/non-JSON bodies yield "" so raw markup is never echoed to the client.
23
+ export function formatOpenAIChatErrorBody(status: number, _headers: Headers, payloadText: string): string {
24
+ let parsed: unknown;
25
+ try {
26
+ parsed = JSON.parse(payloadText);
27
+ } catch {
28
+ return "";
29
+ }
30
+ const detail = extractErrorDetail(parsed);
31
+ if (!detail) return "";
32
+ return redactSecretString(detail).slice(0, 400);
33
+ }
34
+
35
+ function extractErrorDetail(parsed: unknown): string | undefined {
36
+ if (typeof parsed === "string") return parsed.trim() || undefined;
37
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
38
+ const obj = parsed as Record<string, unknown>;
39
+ // OpenAI shape: { error: { message } } or { error: "..." }
40
+ const err = obj.error;
41
+ if (typeof err === "string" && err.trim()) return err.trim();
42
+ if (err !== null && typeof err === "object" && !Array.isArray(err)) {
43
+ const msg = (err as Record<string, unknown>).message;
44
+ if (typeof msg === "string" && msg.trim()) return msg.trim();
45
+ }
46
+ // FastAPI/pydantic shape (NVIDIA NIM): { detail: "..." } or { detail: [{ msg, loc }, ...] }
47
+ const det = obj.detail;
48
+ if (typeof det === "string" && det.trim()) return det.trim();
49
+ if (Array.isArray(det)) {
50
+ const msgs = det
51
+ .map(item => (item !== null && typeof item === "object" && typeof (item as Record<string, unknown>).msg === "string"
52
+ ? ((item as Record<string, unknown>).msg as string).trim()
53
+ : ""))
54
+ .filter(m => m.length > 0);
55
+ if (msgs.length > 0) return msgs.join("; ");
56
+ }
57
+ // Generic fallbacks: { message } / RFC7807 { title }
58
+ if (typeof obj.message === "string" && obj.message.trim()) return obj.message.trim();
59
+ if (typeof obj.title === "string" && obj.title.trim()) return obj.title.trim();
60
+ return undefined;
61
+ }
62
+
16
63
  function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
17
64
  const out: unknown[] = [];
18
65
  const { context, options } = parsed;
@@ -125,7 +172,49 @@ function safeToolName(name: string | undefined): string {
125
172
  return sanitized;
126
173
  }
127
174
 
128
- function toolsToChatFormat(parsed: OcxParsedRequest): unknown[] | undefined {
175
+ const XAI_SCHEMA_BASE_URLS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]);
176
+
177
+ function isXaiSchemaTarget(provider: OcxProviderConfig): boolean {
178
+ try {
179
+ return XAI_SCHEMA_BASE_URLS.has(new URL(provider.baseUrl).hostname);
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ function expandXaiRootObjectSchemas(schema: unknown): Record<string, unknown>[] | undefined {
186
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
187
+ const obj = schema as Record<string, unknown>;
188
+ const compositionKey = ["oneOf", "anyOf"].find(key => Array.isArray(obj[key]));
189
+ if (!compositionKey) {
190
+ if (obj.type !== undefined && obj.type !== "object") return undefined;
191
+ return [{ ...obj, type: "object" }];
192
+ }
193
+
194
+ const siblings = Object.fromEntries(Object.entries(obj).filter(([key]) => key !== compositionKey));
195
+ const branches = obj[compositionKey];
196
+ if (!Array.isArray(branches)) return undefined;
197
+ const expanded: Record<string, unknown>[] = [];
198
+ for (const branch of branches) {
199
+ const variants = expandXaiRootObjectSchemas(branch);
200
+ if (!variants) return undefined;
201
+ for (const variant of variants) expanded.push({ ...siblings, ...variant });
202
+ }
203
+ return expanded.length > 0 ? expanded : undefined;
204
+ }
205
+
206
+ function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown> | undefined {
207
+ const variants = expandXaiRootObjectSchemas(parameters);
208
+ if (!variants) return undefined;
209
+ if (variants.length === 1) return variants[0];
210
+ const root = parameters && typeof parameters === "object" && !Array.isArray(parameters)
211
+ ? parameters as Record<string, unknown>
212
+ : {};
213
+ const metadata = Object.fromEntries(Object.entries(root).filter(([key]) => key !== "oneOf" && key !== "anyOf" && key !== "type"));
214
+ return { ...metadata, oneOf: variants };
215
+ }
216
+
217
+ function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
129
218
  if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
130
219
  const allowed = isAllowedToolChoice(parsed.options.toolChoice)
131
220
  ? new Set(parsed.options.toolChoice.allowedTools)
@@ -134,15 +223,21 @@ function toolsToChatFormat(parsed: OcxParsedRequest): unknown[] | undefined {
134
223
  ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed))
135
224
  : parsed.context.tools;
136
225
  if (tools.length === 0) return undefined;
137
- return tools.map(t => ({
226
+ const xaiTarget = isXaiSchemaTarget(provider);
227
+ const formatted = tools.flatMap(t => {
228
+ const parameters = xaiTarget ? normalizeXaiToolParameters(t.parameters) : t.parameters;
229
+ if (parameters === undefined) return [];
230
+ return [{
138
231
  type: "function",
139
232
  function: {
140
233
  name: namespacedToolName(t.namespace, t.name),
141
234
  description: t.description,
142
- parameters: t.parameters,
235
+ parameters,
143
236
  ...(t.strict !== undefined ? { strict: t.strict } : {}),
144
237
  },
145
- }));
238
+ }];
239
+ });
240
+ return formatted.length > 0 ? formatted : undefined;
146
241
  }
147
242
 
148
243
  function toolChoiceToChatFormat(tc: OcxParsedRequest["options"]["toolChoice"], tools: OcxParsedRequest["context"]["tools"]): unknown {
@@ -183,6 +278,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
183
278
  return {
184
279
  name: "openai-chat",
185
280
 
281
+ formatErrorBody: formatOpenAIChatErrorBody,
282
+
186
283
  buildRequest(parsed: OcxParsedRequest) {
187
284
  const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0;
188
285
  if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) {
@@ -190,7 +287,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
190
287
  }
191
288
 
192
289
  const messages = messagesToChatFormat(parsed, provider);
193
- const tools = toolsToChatFormat(parsed);
290
+ const tools = toolsToChatFormat(parsed, provider);
194
291
  const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools);
195
292
 
196
293
  const body: Record<string, unknown> = {
@@ -52,6 +52,55 @@ function sanitizeReasoningInputContent(body: unknown): unknown {
52
52
  return changed ? { ...raw, input } : body;
53
53
  }
54
54
 
55
+ function stripInvalidItemIds(body: unknown): unknown {
56
+ if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
57
+
58
+ const validPrefixes: Record<string, string> = {
59
+ message: "msg_",
60
+ reasoning: "rs_",
61
+ function_call: "fc_",
62
+ custom_tool_call: "ctc_",
63
+ tool_search_call: "tsc_",
64
+ web_search_call: "ws_",
65
+ };
66
+ let changed = false;
67
+ const input = body.input.map(item => {
68
+ if (!isPlainObject(item) || typeof item.type !== "string") return item;
69
+ const validPrefix = validPrefixes[item.type];
70
+ if (!validPrefix) return item;
71
+ if (typeof item.id === "string" && item.id.startsWith(validPrefix)) return item;
72
+ if (!("id" in item)) return item;
73
+ changed = true;
74
+ const next = { ...item };
75
+ delete next.id;
76
+ return next;
77
+ });
78
+
79
+ return changed ? { ...body, input } : body;
80
+ }
81
+
82
+ /**
83
+ * When `store` is false, the upstream API does not persist response items. Any item ID
84
+ * forwarded in `input` is then interpreted as a reference to a stored item that does not
85
+ * exist, producing a 404. Strip all item IDs in this case — `call_id` pairing is unaffected.
86
+ * Matches codex-rs behavior (core/src/client.rs:918-925).
87
+ */
88
+ function stripItemIdsWhenUnstored(body: unknown): unknown {
89
+ if (!isPlainObject(body) || body.store !== false) return body;
90
+ if (!Array.isArray(body.input)) return body;
91
+
92
+ let changed = false;
93
+ const input = body.input.map(item => {
94
+ if (!isPlainObject(item) || !("id" in item)) return item;
95
+ changed = true;
96
+ const next = { ...item };
97
+ delete next.id;
98
+ return next;
99
+ });
100
+
101
+ return changed ? { ...body, input } : body;
102
+ }
103
+
55
104
  /**
56
105
  * Replace proxy-minted compaction items (`encrypted_content` starting with `ocx1:`) with plain
57
106
  * user messages before forwarding to the ChatGPT backend. Our envelope is transparent base64, not
@@ -87,9 +136,135 @@ function scrubOcxCompactionItems(body: unknown): unknown {
87
136
  * Extend this when another native slug rejects a hosted tool (e.g. `code_interpreter`).
88
137
  */
89
138
  const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{ match: (model: string) => boolean; tools: ReadonlySet<string> }> = [
90
- { match: model => model.includes("codex-spark"), tools: new Set(["image_generation"]) },
139
+ { match: model => model.includes("codex-spark"), tools: new Set(["image_generation", "tool_search"]) },
91
140
  ];
92
141
 
142
+ /**
143
+ * Strip unsupported `reasoning` sub-parameters for native slugs that reject them (e.g. Spark).
144
+ * codex-rs injects `reasoning.context` and `reasoning.summary` based on catalog flags; Spark's
145
+ * backend rejects both. The catalog fix prevents `use_responses_lite` from being set, but this
146
+ * is a defense-in-depth guard so stale on-disk catalogs don't break until the user runs `ocx sync`.
147
+ */
148
+ function stripUnsupportedReasoningParams(body: unknown): unknown {
149
+ if (!isPlainObject(body)) return body;
150
+ const model = typeof body.model === "string" ? body.model : "";
151
+ if (!model.includes("codex-spark")) return body;
152
+ if (!isPlainObject(body.reasoning)) return body;
153
+ const reasoning = body.reasoning as Record<string, unknown>;
154
+ // Spark supports reasoning.effort but rejects context, summary, and generate_summary.
155
+ const { context: _ctx, summary: _sum, generate_summary: _gs, ...rest } = reasoning;
156
+ if (_ctx === undefined && _sum === undefined && _gs === undefined) return body;
157
+ return { ...body, reasoning: Object.keys(rest).length > 0 ? rest : undefined };
158
+ }
159
+
160
+ /**
161
+ * Comprehensive Spark compatibility layer. codex-rs emits five tool types (function,
162
+ * namespace, tool_search, web_search, custom) plus extensions (defer_loading,
163
+ * parallel_tool_calls, tool_search_call/output items). Spark's serving path only
164
+ * supports flat function tools and hosted web_search. This function:
165
+ * - Flattens namespace tools → promotes inner functions to top level
166
+ * - Drops unsupported tool types (tool_search, custom)
167
+ * - Strips defer_loading from function tools
168
+ * - Strips namespace from input items
169
+ * - Drops tool_search_call/tool_search_output input items
170
+ * - Sets parallel_tool_calls to false
171
+ */
172
+ function stripSparkCompatibility(body: unknown): unknown {
173
+ if (!isPlainObject(body)) return body;
174
+ const model = typeof body.model === "string" ? body.model : "";
175
+ if (!model.includes("codex-spark")) return body;
176
+
177
+ let changed = false;
178
+
179
+ const SPARK_SAFE_TOOL_TYPES = new Set(["function", "web_search", "web_search_preview"]);
180
+
181
+ let tools = body.tools;
182
+ if (Array.isArray(tools)) {
183
+ const flattened: unknown[] = [];
184
+ for (const t of tools) {
185
+ if (isPlainObject(t) && t.type === "namespace") {
186
+ changed = true;
187
+ if (Array.isArray(t.tools)) {
188
+ for (const inner of t.tools) flattened.push(inner);
189
+ }
190
+ } else if (isPlainObject(t) && typeof t.type === "string" && !SPARK_SAFE_TOOL_TYPES.has(t.type)) {
191
+ changed = true;
192
+ } else {
193
+ flattened.push(t);
194
+ }
195
+ }
196
+ // Strip defer_loading from promoted/remaining function tools.
197
+ tools = flattened.map(t => {
198
+ if (isPlainObject(t) && t.type === "function" && "defer_loading" in t) {
199
+ const { defer_loading: _, ...rest } = t;
200
+ changed = true;
201
+ return rest;
202
+ }
203
+ return t;
204
+ });
205
+ }
206
+
207
+ // Clean input items: strip namespace, drop tool_search_call/tool_search_output.
208
+ const SPARK_UNSUPPORTED_INPUT_TYPES = new Set([
209
+ "tool_search_call", "tool_search_output",
210
+ "custom_tool_call", "custom_tool_call_output",
211
+ ]);
212
+ let input = body.input;
213
+ if (Array.isArray(input)) {
214
+ const cleaned: unknown[] = [];
215
+ for (const item of input) {
216
+ if (isPlainObject(item) && typeof item.type === "string" && SPARK_UNSUPPORTED_INPUT_TYPES.has(item.type)) {
217
+ changed = true;
218
+ continue;
219
+ }
220
+ // Process additional_tools items: filter their inner tools array the same way.
221
+ if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) {
222
+ const innerTools = item.tools as unknown[];
223
+ const filteredInner: unknown[] = [];
224
+ for (const t of innerTools) {
225
+ if (isPlainObject(t) && t.type === "namespace") {
226
+ changed = true;
227
+ if (Array.isArray(t.tools)) {
228
+ for (const fn of t.tools) filteredInner.push(fn);
229
+ }
230
+ } else if (isPlainObject(t) && typeof t.type === "string" && !SPARK_SAFE_TOOL_TYPES.has(t.type)) {
231
+ changed = true; // drop custom, tool_search, etc.
232
+ } else {
233
+ filteredInner.push(t);
234
+ }
235
+ }
236
+ // Strip defer_loading from remaining function tools.
237
+ const cleanedInner = filteredInner.map(t => {
238
+ if (isPlainObject(t) && t.type === "function" && "defer_loading" in t) {
239
+ const { defer_loading: _, ...rest } = t;
240
+ changed = true;
241
+ return rest;
242
+ }
243
+ return t;
244
+ });
245
+ cleaned.push({ ...item, tools: cleanedInner });
246
+ continue;
247
+ }
248
+ if (isPlainObject(item) && "namespace" in item) {
249
+ const { namespace: _, ...rest } = item;
250
+ changed = true;
251
+ cleaned.push(rest);
252
+ } else {
253
+ cleaned.push(item);
254
+ }
255
+ }
256
+ if (changed) input = cleaned;
257
+ }
258
+
259
+ // Force parallel_tool_calls off for Spark.
260
+ const extraOverrides: Record<string, unknown> = {};
261
+ if (body.parallel_tool_calls === true) { extraOverrides.parallel_tool_calls = false; changed = true; }
262
+
263
+ return changed
264
+ ? { ...body, ...(tools !== body.tools ? { tools } : {}), ...(input !== body.input ? { input } : {}), ...extraOverrides }
265
+ : body;
266
+ }
267
+
93
268
  function isPlainObject(v: unknown): v is Record<string, unknown> {
94
269
  return !!v && typeof v === "object" && !Array.isArray(v);
95
270
  }
@@ -282,7 +457,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
282
457
  url,
283
458
  method: "POST",
284
459
  headers,
285
- body: JSON.stringify(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody)))),
460
+ body: JSON.stringify(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody)))))))),
286
461
  };
287
462
  },
288
463