@bitkyc08/opencodex 2.7.9-preview.20260712.2 → 2.7.9

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.
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-SnN_1Qr9.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-BAAFKwsh.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-Cq8maiJf.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.7.9-preview.20260712.2",
3
+ "version": "2.7.9",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
package/src/bridge.ts CHANGED
@@ -379,11 +379,14 @@ export function bridgeToResponsesSSE(
379
379
  // Finalize an open web-search cell. `status` is "completed" on a normal end, or "failed" when
380
380
  // the stream terminates (error/incomplete) while a search was still in flight, so Codex never
381
381
  // leaves a "Searching the web" spinner spinning forever.
382
- const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[]) => {
382
+ // `sources` rides on the done item (additive field; codex-rs serde ignores unknown fields) so
383
+ // downstream translators (claude outbound) can fill web_search_tool_result content.
384
+ const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[]) => {
383
385
  if (!currentWebSearch) return;
384
386
  const item = {
385
387
  type: "web_search_call", id: currentWebSearch.itemId, status,
386
388
  action: webSearchAction(queries),
389
+ ...(sources && sources.length > 0 ? { sources } : {}),
387
390
  };
388
391
  emit("response.output_item.done", { output_index: currentWebSearch.outputIndex, item });
389
392
  finishedItems.push(item as OutputItem);
@@ -569,7 +572,7 @@ export function bridgeToResponsesSSE(
569
572
  });
570
573
  currentWebSearch = { itemId: event.id, outputIndex };
571
574
  }
572
- closeCurrentWebSearch(event.status ?? "completed", event.queries);
575
+ closeCurrentWebSearch(event.status ?? "completed", event.queries, event.sources);
573
576
  // Queue this search's sources for the next assistant message (dedup by URL).
574
577
  if (event.sources) {
575
578
  const seen = new Set(pendingWebSources.map(s => s.url));
@@ -861,6 +864,7 @@ export function buildResponseJSON(
861
864
  output.push({
862
865
  type: "web_search_call", id: e.id, status: e.status ?? "completed",
863
866
  action: webSearchAction(e.queries),
867
+ ...(e.sources && e.sources.length > 0 ? { sources: e.sources } : {}),
864
868
  });
865
869
  if (e.sources) {
866
870
  const seen = new Set(pendingWebSources.map(s => s.url));
@@ -54,7 +54,7 @@ export function anthropicErrorResponse(status: number, message: string, type?: s
54
54
  * subtract the full cache detail (devlog 070 — subtracting reads only inflated the
55
55
  * non-cached input Claude Code displays by the write share).
56
56
  */
57
- export function anthropicUsage(usage: unknown): Rec {
57
+ export function anthropicUsage(usage: unknown, webSearchRequests = 0): Rec {
58
58
  const u = isRec(usage) ? usage : {};
59
59
  const details = isRec(u.input_tokens_details) ? u.input_tokens_details : {};
60
60
  const cached = typeof details.cached_tokens === "number" ? details.cached_tokens : 0;
@@ -66,6 +66,8 @@ export function anthropicUsage(usage: unknown): Rec {
66
66
  output_tokens: output,
67
67
  cache_read_input_tokens: cached,
68
68
  cache_creation_input_tokens: cacheWrite,
69
+ // Only successful searches are billed/counted (Anthropic contract; Claude Code cost accounting).
70
+ ...(webSearchRequests > 0 ? { server_tool_use: { web_search_requests: webSearchRequests } } : {}),
69
71
  };
70
72
  }
71
73
 
@@ -73,6 +75,37 @@ function sseFrame(name: string, data: Rec): string {
73
75
  return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
74
76
  }
75
77
 
78
+ /**
79
+ * Map a Responses `web_search_call` item to its Anthropic pair: the server_tool_use
80
+ * input (query/queries) and the web_search_tool_result content (hits, or the error
81
+ * object when the search failed). Shared by the SSE and JSON translation paths.
82
+ */
83
+ function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultContent: unknown; completed: boolean } {
84
+ const action = isRec(item.action) ? item.action : {};
85
+ const queries = Array.isArray(action.queries)
86
+ ? action.queries.filter((q): q is string => typeof q === "string" && q.length > 0)
87
+ : [];
88
+ const query = typeof action.query === "string" ? action.query : "";
89
+ const input: Rec = queries.length > 1 ? { queries } : { query: queries[0] ?? query };
90
+ const completed = item.status !== "failed";
91
+ let resultContent: unknown;
92
+ if (completed) {
93
+ const hits: Rec[] = [];
94
+ if (Array.isArray(item.sources)) {
95
+ for (const s of item.sources) {
96
+ if (isRec(s) && typeof s.url === "string" && s.url.length > 0) {
97
+ hits.push({ type: "web_search_result", title: typeof s.title === "string" ? s.title : "", url: s.url });
98
+ }
99
+ }
100
+ }
101
+ resultContent = hits;
102
+ } else {
103
+ resultContent = { type: "web_search_tool_result_error", error_code: "unavailable" };
104
+ }
105
+ const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`;
106
+ return { id, input, resultContent, completed };
107
+ }
108
+
76
109
  function messageSnapshot(model: string): Rec {
77
110
  return {
78
111
  id: `msg_${uuid()}`,
@@ -109,6 +142,7 @@ export function responsesSseToAnthropicSse(
109
142
  let blockIndex = 0;
110
143
  let open: OpenBlock | null = null;
111
144
  let sawToolUse = false;
145
+ let webSearchRequests = 0;
112
146
  let pingTimer: ReturnType<typeof setInterval> | undefined;
113
147
  let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
114
148
 
@@ -164,7 +198,7 @@ export function responsesSseToAnthropicSse(
164
198
  emit("message_delta", {
165
199
  type: "message_delta",
166
200
  delta: { stop_reason: stopReason, stop_sequence: null },
167
- usage: anthropicUsage(usage),
201
+ usage: anthropicUsage(usage, webSearchRequests),
168
202
  });
169
203
  emit("message_stop", { type: "message_stop" });
170
204
  };
@@ -234,7 +268,35 @@ export function responsesSseToAnthropicSse(
234
268
  }
235
269
  case "response.output_item.done": {
236
270
  const item = isRec(data.item) ? data.item : null;
237
- if (!open || !item) break;
271
+ if (!item) break;
272
+ // Server-side web search (native passthrough or sidecar bridge): translate the
273
+ // finished call into the Anthropic pair Claude Code natively parses —
274
+ // server_tool_use (query via input_json_delta) + web_search_tool_result.
275
+ // Never marks sawToolUse (stop_reason stays end_turn unless a real tool ran).
276
+ if (item.type === "web_search_call") {
277
+ ensureStarted();
278
+ closeOpenBlock();
279
+ const pair = webSearchPairFromItem(item);
280
+ const toolIndex = blockIndex++;
281
+ emit("content_block_start", {
282
+ type: "content_block_start", index: toolIndex,
283
+ content_block: { type: "server_tool_use", id: pair.id, name: "web_search" },
284
+ });
285
+ emit("content_block_delta", {
286
+ type: "content_block_delta", index: toolIndex,
287
+ delta: { type: "input_json_delta", partial_json: JSON.stringify(pair.input) },
288
+ });
289
+ emit("content_block_stop", { type: "content_block_stop", index: toolIndex });
290
+ const resultIndex = blockIndex++;
291
+ emit("content_block_start", {
292
+ type: "content_block_start", index: resultIndex,
293
+ content_block: { type: "web_search_tool_result", tool_use_id: pair.id, content: pair.resultContent },
294
+ });
295
+ emit("content_block_stop", { type: "content_block_stop", index: resultIndex });
296
+ if (pair.completed) webSearchRequests++;
297
+ break;
298
+ }
299
+ if (!open) break;
238
300
  // Close the matching open block (message/reasoning items close implicitly on
239
301
  // the next block; function_call items must close here so tool input parses).
240
302
  if (open.kind === "tool_use" && item.type === "function_call") closeOpenBlock();
@@ -322,6 +384,7 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R
322
384
  const output = Array.isArray(body.output) ? body.output : [];
323
385
  const content: Rec[] = [];
324
386
  let sawToolUse = false;
387
+ let webSearchRequests = 0;
325
388
 
326
389
  for (const raw of output) {
327
390
  if (!isRec(raw)) continue;
@@ -366,6 +429,14 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R
366
429
  });
367
430
  break;
368
431
  }
432
+ case "web_search_call": {
433
+ // Server-side search: emit the Anthropic pair. Does NOT set sawToolUse.
434
+ const pair = webSearchPairFromItem(raw);
435
+ content.push({ type: "server_tool_use", id: pair.id, name: "web_search", input: pair.input });
436
+ content.push({ type: "web_search_tool_result", tool_use_id: pair.id, content: pair.resultContent });
437
+ if (pair.completed) webSearchRequests++;
438
+ break;
439
+ }
369
440
  default:
370
441
  break;
371
442
  }
@@ -384,7 +455,7 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R
384
455
  model,
385
456
  stop_reason: stopReason,
386
457
  stop_sequence: null,
387
- usage: anthropicUsage(body.usage),
458
+ usage: anthropicUsage(body.usage, webSearchRequests),
388
459
  };
389
460
  }
390
461
 
@@ -407,7 +478,8 @@ export async function collectAnthropicMessage(stream: ReadableStream<Uint8Array>
407
478
 
408
479
  const closeBlock = () => {
409
480
  if (!openBlock) return;
410
- if (openBlock.type === "tool_use") {
481
+ // server_tool_use streams its query via input_json_delta exactly like tool_use (audit F3).
482
+ if (openBlock.type === "tool_use" || openBlock.type === "server_tool_use") {
411
483
  try { openBlock.input = toolJson.length > 0 ? JSON.parse(toolJson) : {}; } catch { openBlock.input = {}; }
412
484
  }
413
485
  content.push(openBlock);
@@ -12,6 +12,7 @@ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"
12
12
  import { loginCursor, refreshCursorToken } from "./cursor";
13
13
  import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
14
14
  import { effectiveGoogleMode } from "../providers/registry";
15
+ import { resolveProviderTransport } from "../providers/xai-transport";
15
16
 
16
17
  const REFRESH_SKEW_MS = 60_000;
17
18
  const tokenRefreshes = new Map<string, Promise<string>>();
@@ -254,27 +255,28 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf
254
255
  * response.
255
256
  */
256
257
  export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | undefined, providerName = ""): { url: string; headers: Record<string, string> } {
257
- const headers: Record<string, string> = { ...(prov.headers ?? {}) };
258
- if (effectiveGoogleMode(providerName, prov) === "ai-studio") {
258
+ const effectiveProvider = resolveProviderTransport(providerName, prov);
259
+ const headers: Record<string, string> = { ...(effectiveProvider.headers ?? {}) };
260
+ if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") {
259
261
  // Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer),
260
262
  // models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 —
261
263
  // enough to list everything without a pageToken loop. Vertex/antigravity keep the
262
264
  // generic branch (they fall back to their static model lists).
263
265
  if (apiKey) headers["x-goog-api-key"] = apiKey;
264
- return { url: `${prov.baseUrl}/v1beta/models?pageSize=1000`, headers };
266
+ return { url: `${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`, headers };
265
267
  }
266
- if (prov.adapter === "anthropic") {
268
+ if (effectiveProvider.adapter === "anthropic") {
267
269
  headers["anthropic-version"] = "2023-06-01";
268
- if (prov.authMode === "oauth") {
270
+ if (effectiveProvider.authMode === "oauth") {
269
271
  headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
270
272
  if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
271
273
  } else if (apiKey) {
272
274
  headers["x-api-key"] = apiKey;
273
275
  }
274
- return { url: `${prov.baseUrl}/v1/models?limit=1000`, headers };
276
+ return { url: `${effectiveProvider.baseUrl}/v1/models?limit=1000`, headers };
275
277
  }
276
278
  if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
277
- return { url: `${prov.baseUrl}/models`, headers };
279
+ return { url: `${effectiveProvider.baseUrl}/models`, headers };
278
280
  }
279
281
 
280
282
  /**
@@ -236,6 +236,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
236
236
  models: ["grok-4.5", "grok-4.3", "grok-4.20-multi-agent-0309", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
237
237
  defaultModel: "grok-4.5",
238
238
  noReasoningModels: ["grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
239
+ // Replay assistant reasoning_content for grok reasoning models: xAI documents dropped
240
+ // reasoning_content as the top cause of prompt-cache misses on multi-turn conversations
241
+ // (docs.x.ai prompt-caching/multi-turn, verified 2026-07-13 — devlog/_plan/260713_grok_caching).
242
+ // Models that never emit reasoning simply have no thinking parts to replay (no-op).
243
+ preserveReasoningContentModels: ["grok-4.5", "grok-4.3", "grok-4.20-multi-agent-0309", "grok-4.20-0309-reasoning"],
239
244
  // grok-4.5 reasoning is always-on with low/medium/high control (no off tier upstream).
240
245
  modelReasoningEfforts: { "grok-4.5": ["low", "medium", "high"] },
241
246
  modelContextWindows: {
@@ -0,0 +1,89 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { OcxProviderConfig } from "../types";
3
+
4
+ /**
5
+ * xAI account OAuth and xAI API keys share a bearer shape but not a billing
6
+ * transport. OAuth represents the Grok CLI subscription entitlement, while a
7
+ * key represents the API team. Keep the saved provider preset compatible with
8
+ * the dashboard's "Use an API key instead" switch and resolve the transport at
9
+ * request time.
10
+ */
11
+ export const XAI_GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
12
+
13
+ /** Minimum-compatible official Grok CLI wire version verified with the proxy. */
14
+ export const XAI_GROK_CLIENT_VERSION = "0.2.93";
15
+
16
+ const XAI_GROK_CLI_HEADERS: Readonly<Record<string, string>> = {
17
+ "x-grok-client-identifier": "opencodex",
18
+ "x-grok-client-version": XAI_GROK_CLIENT_VERSION,
19
+ "x-xai-token-auth": "xai-grok-cli",
20
+ };
21
+
22
+ /**
23
+ * Sticky-routing hint for xAI's automatic prefix cache. xAI routes requests
24
+ * carrying the same `x-grok-conv-id` to the same server, which is where the
25
+ * prompt cache lives (docs.x.ai prompt-caching best-practices; verified
26
+ * 2026-07-13, devlog/_plan/260713_grok_caching). Codex clients send a stable
27
+ * per-conversation `prompt_cache_key`; hash it so the raw session id never
28
+ * leaves the proxy.
29
+ */
30
+ export const XAI_CONV_ID_HEADER = "x-grok-conv-id";
31
+
32
+ function hasHeaderCaseInsensitive(headers: Record<string, string> | undefined, name: string): boolean {
33
+ if (!headers) return false;
34
+ const target = name.toLowerCase();
35
+ return Object.keys(headers).some(key => key.toLowerCase() === target);
36
+ }
37
+
38
+ /** Drop default entries the user already overrides under any header-name casing. */
39
+ function withoutUserOverridden(defaults: Readonly<Record<string, string>>, userHeaders: Record<string, string> | undefined): Record<string, string> {
40
+ if (!userHeaders) return { ...defaults };
41
+ const out: Record<string, string> = {};
42
+ for (const [key, value] of Object.entries(defaults)) {
43
+ if (!hasHeaderCaseInsensitive(userHeaders, key)) out[key] = value;
44
+ }
45
+ return out;
46
+ }
47
+
48
+ export function deriveXaiConvId(promptCacheKey: string): string {
49
+ return createHash("sha256").update(promptCacheKey).digest("hex").slice(0, 32);
50
+ }
51
+
52
+ /**
53
+ * Resolve the effective xAI transport without mutating persisted config.
54
+ * User-provided headers are preserved and may advance the compatibility
55
+ * version without waiting for an opencodex release.
56
+ *
57
+ * `promptCacheKey` (the client's stable conversation key) additionally pins
58
+ * cache-affinity routing via `x-grok-conv-id` in BOTH auth modes. Blank or
59
+ * whitespace-only keys are ignored so unrelated requests can never collapse
60
+ * onto one shared conv id, and any user-configured header (any case) wins.
61
+ */
62
+ export function resolveProviderTransport(
63
+ providerName: string,
64
+ provider: OcxProviderConfig,
65
+ promptCacheKey?: string,
66
+ ): OcxProviderConfig {
67
+ if (providerName !== "xai") return provider;
68
+ const cacheKey = promptCacheKey?.trim();
69
+ const convIdHeaders: Record<string, string> =
70
+ cacheKey && !hasHeaderCaseInsensitive(provider.headers, XAI_CONV_ID_HEADER)
71
+ ? { [XAI_CONV_ID_HEADER]: deriveXaiConvId(cacheKey) }
72
+ : {};
73
+ if (provider.authMode !== "oauth") {
74
+ if (Object.keys(convIdHeaders).length === 0) return provider;
75
+ return {
76
+ ...provider,
77
+ headers: { ...convIdHeaders, ...(provider.headers ?? {}) },
78
+ };
79
+ }
80
+ return {
81
+ ...provider,
82
+ baseUrl: XAI_GROK_CLI_BASE_URL,
83
+ headers: {
84
+ ...withoutUserOverridden(XAI_GROK_CLI_HEADERS, provider.headers),
85
+ ...convIdHeaders,
86
+ ...(provider.headers ?? {}),
87
+ },
88
+ };
89
+ }
@@ -31,6 +31,21 @@ function isRec(v: unknown): v is Rec {
31
31
  return !!v && typeof v === "object" && !Array.isArray(v);
32
32
  }
33
33
 
34
+ /** Resolve Claude-only sidecar overrides without mutating the shared server config. */
35
+ export function buildClaudeReplayConfig(config: OcxConfig): OcxConfig {
36
+ return {
37
+ ...config,
38
+ webSearchSidecar: {
39
+ ...config.webSearchSidecar,
40
+ ...config.claudeCode?.webSearchSidecar,
41
+ },
42
+ visionSidecar: {
43
+ ...config.visionSidecar,
44
+ ...config.claudeCode?.visionSidecar,
45
+ },
46
+ };
47
+ }
48
+
34
49
  function claudeInboundDisabled(config: OcxConfig): Response | null {
35
50
  if (config.claudeCode?.enabled === false) {
36
51
  return anthropicErrorResponse(403, "Claude inbound is disabled (GUI: Claude ON toggle / config.claudeCode.enabled)", "permission_error");
@@ -332,6 +347,15 @@ export async function handleClaudeMessages(
332
347
  const value = req.headers.get(name);
333
348
  if (value) headers.set(name, value);
334
349
  }
350
+ if (!nativeRoute) {
351
+ // Routed replays need main ChatGPT auth so OpenAI-backed sidecars remain reachable.
352
+ const { getMainAccountToken } = await import("../codex/main-account");
353
+ const token = getMainAccountToken();
354
+ if (token) {
355
+ headers.set("authorization", `Bearer ${token.accessToken}`);
356
+ headers.set("chatgpt-account-id", token.chatgptAccountId);
357
+ }
358
+ }
335
359
  if (nativeRoute) {
336
360
  // No forwarded ChatGPT auth exists on this surface. Attach the main codex login
337
361
  // (read-only auth.json token); account-pool rotation still overrides downstream.
@@ -368,7 +392,7 @@ export async function handleClaudeMessages(
368
392
  nativeLogged = true;
369
393
  addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta);
370
394
  };
371
- const upstream = await handleResponses(internalReq, config, logCtx, {
395
+ const upstream = await handleResponses(internalReq, buildClaudeReplayConfig(config), logCtx, {
372
396
  abortSignal: req.signal,
373
397
  onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
374
398
  onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),
@@ -37,7 +37,7 @@ import {
37
37
  setDebugSettings,
38
38
  type DebugFlag,
39
39
  } from "../lib/debug-settings";
40
- import type { OcxConfig, OcxProviderConfig } from "../types";
40
+ import type { OcxClaudeCodeConfig, OcxConfig, OcxProviderConfig } from "../types";
41
41
  import { drainAndShutdown } from "./lifecycle";
42
42
  import { filterRequestLogs, getRequestLogEntries } from "./request-log";
43
43
  import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "./auth-cors";
@@ -58,6 +58,11 @@ export interface ManagementApiDeps {
58
58
  refreshCodexCatalog?: () => Promise<void>;
59
59
  }
60
60
 
61
+ /** Narrow an unknown JSON value to a plain (non-array) object for strict request-body validation. */
62
+ function isPlainRecord(v: unknown): v is Record<string, unknown> {
63
+ return typeof v === "object" && v !== null && !Array.isArray(v);
64
+ }
65
+
61
66
  function parseDebugLogQuery(url: URL): { after: number; limit: number } {
62
67
  const after = Number(url.searchParams.get("after") ?? url.searchParams.get("since") ?? "0");
63
68
  const limit = Number(url.searchParams.get("limit") ?? "500");
@@ -193,30 +198,78 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
193
198
  const ws = config.webSearchSidecar ?? {};
194
199
  const vs = config.visionSidecar ?? {};
195
200
  return jsonResponse({
196
- webSearch: { model: ws.model ?? "gpt-5.6-luna", reasoning: ws.reasoning ?? "low" },
197
- vision: { model: vs.model ?? "gpt-5.6-luna" },
201
+ webSearch: { model: ws.model ?? "gpt-5.6-luna", backend: ws.backend },
202
+ vision: {
203
+ model: vs.model ?? "gpt-5.6-luna",
204
+ backend: vs.backend,
205
+ maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn,
206
+ },
198
207
  });
199
208
  }
200
209
 
201
210
  if (url.pathname === "/api/sidecar-settings" && req.method === "PUT") {
202
- let body: { webSearch?: { model?: string; reasoning?: string }; vision?: { model?: string } };
203
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
211
+ let raw: unknown;
212
+ try { raw = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
213
+ // Strict shape (review F2): reject non-object bodies and non-object sections instead of throwing
214
+ // on `null` or silently accepting arrays/strings as no-op updates.
215
+ if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400);
216
+ if (raw.webSearch !== undefined && !isPlainRecord(raw.webSearch)) return jsonResponse({ error: "webSearch must be an object" }, 400);
217
+ if (raw.vision !== undefined && !isPlainRecord(raw.vision)) return jsonResponse({ error: "vision must be an object" }, 400);
218
+ const body = raw as {
219
+ webSearch?: { model?: unknown; backend?: unknown; reasoning?: unknown };
220
+ vision?: { model?: unknown; backend?: unknown; maxDescriptionsPerTurn?: unknown };
221
+ };
222
+ if (body.webSearch && body.webSearch.backend !== undefined && body.webSearch.backend !== null
223
+ && body.webSearch.backend !== "openai" && body.webSearch.backend !== "anthropic") {
224
+ return jsonResponse({ error: "webSearch.backend must be openai, anthropic, or null" }, 400);
225
+ }
226
+ if (body.vision && body.vision.backend !== undefined
227
+ && body.vision.backend !== null && body.vision.backend !== "openai" && body.vision.backend !== "anthropic") {
228
+ return jsonResponse({ error: "vision.backend must be openai, anthropic, or null" }, 400);
229
+ }
230
+ if (body.vision && body.vision.maxDescriptionsPerTurn !== undefined
231
+ && (typeof body.vision.maxDescriptionsPerTurn !== "number"
232
+ || !Number.isInteger(body.vision.maxDescriptionsPerTurn)
233
+ || body.vision.maxDescriptionsPerTurn <= 0)) {
234
+ return jsonResponse({ error: "vision.maxDescriptionsPerTurn must be a positive integer" }, 400);
235
+ }
204
236
  if (body.webSearch) {
205
237
  config.webSearchSidecar = { ...config.webSearchSidecar };
206
- if (typeof body.webSearch.model === "string") config.webSearchSidecar.model = body.webSearch.model;
238
+ if (typeof body.webSearch.model === "string") {
239
+ if (body.webSearch.model === "") delete config.webSearchSidecar.model;
240
+ else config.webSearchSidecar.model = body.webSearch.model;
241
+ }
242
+ if (body.webSearch.backend === null) delete config.webSearchSidecar.backend;
243
+ else if (body.webSearch.backend === "openai" || body.webSearch.backend === "anthropic") {
244
+ config.webSearchSidecar.backend = body.webSearch.backend;
245
+ }
207
246
  if (typeof body.webSearch.reasoning === "string") config.webSearchSidecar.reasoning = body.webSearch.reasoning;
208
247
  }
209
248
  if (body.vision) {
210
249
  config.visionSidecar = { ...config.visionSidecar };
211
- if (typeof body.vision.model === "string") config.visionSidecar.model = body.vision.model;
250
+ if (typeof body.vision.model === "string") {
251
+ if (body.vision.model === "") delete config.visionSidecar.model;
252
+ else config.visionSidecar.model = body.vision.model;
253
+ }
254
+ if (body.vision.backend === null) delete config.visionSidecar.backend;
255
+ else if (body.vision.backend === "openai" || body.vision.backend === "anthropic") {
256
+ config.visionSidecar.backend = body.vision.backend;
257
+ }
258
+ if (typeof body.vision.maxDescriptionsPerTurn === "number") {
259
+ config.visionSidecar.maxDescriptionsPerTurn = body.vision.maxDescriptionsPerTurn;
260
+ }
212
261
  }
213
262
  saveConfig(config);
214
263
  const ws = config.webSearchSidecar ?? {};
215
264
  const vs = config.visionSidecar ?? {};
216
265
  return jsonResponse({
217
266
  ok: true,
218
- webSearch: { model: ws.model ?? "gpt-5.6-luna", reasoning: ws.reasoning ?? "low" },
219
- vision: { model: vs.model ?? "gpt-5.6-luna" },
267
+ webSearch: { model: ws.model ?? "gpt-5.6-luna", backend: ws.backend },
268
+ vision: {
269
+ model: vs.model ?? "gpt-5.6-luna",
270
+ backend: vs.backend,
271
+ maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn,
272
+ },
220
273
  });
221
274
  }
222
275
 
@@ -707,6 +760,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
707
760
  aliases.push({ id: claudeCodeAlias(m.provider, m.id), display_name: `${m.id} (${m.provider})` });
708
761
  }
709
762
  const contextWindows = buildClaudeContextWindows([...visibleNativeSlugs(config)], models);
763
+ const webSearchOverride = config.claudeCode?.webSearchSidecar;
764
+ const visionOverride = config.claudeCode?.visionSidecar;
710
765
  return jsonResponse({
711
766
  enabled: config.claudeCode?.enabled !== false,
712
767
  model: config.claudeCode?.model ?? "",
@@ -720,6 +775,12 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
720
775
  autoCompactWindow: config.claudeCode?.autoCompactWindow ?? null,
721
776
  blockedSkills: config.claudeCode?.blockedSkills ?? null,
722
777
  injectAgents: config.claudeCode?.injectAgents !== false,
778
+ ...(webSearchOverride && Object.keys(webSearchOverride).length > 0
779
+ ? { webSearchSidecar: { backend: webSearchOverride.backend, model: webSearchOverride.model } }
780
+ : {}),
781
+ ...(visionOverride && Object.keys(visionOverride).length > 0
782
+ ? { visionSidecar: { backend: visionOverride.backend, model: visionOverride.model } }
783
+ : {}),
723
784
  fastMode: config.fastMode,
724
785
  contextWindows,
725
786
  effectiveModelEnv: effectiveModelEnv(config.claudeCode, contextWindows),
@@ -743,8 +804,36 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
743
804
  return prototype === Object.prototype || prototype === null;
744
805
  };
745
806
  if (!isPlainObject(parsedBody)) return jsonResponse({ error: "body must be an object" }, 400);
746
- const body = parsedBody as { enabled?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; blockedSkills?: unknown; injectAgents?: unknown };
807
+ const body = parsedBody as { enabled?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; blockedSkills?: unknown; injectAgents?: unknown; webSearchSidecar?: unknown; visionSidecar?: unknown };
808
+ for (const field of ["webSearchSidecar", "visionSidecar"] as const) {
809
+ const section = body[field];
810
+ if (section === undefined || section === null) continue;
811
+ if (!isPlainObject(section)) return jsonResponse({ error: `${field} must be an object or null` }, 400);
812
+ if (section.backend !== undefined && section.backend !== null
813
+ && section.backend !== "openai" && section.backend !== "anthropic") {
814
+ return jsonResponse({ error: `${field}.backend must be openai, anthropic, or null` }, 400);
815
+ }
816
+ if (section.model !== undefined && typeof section.model !== "string") {
817
+ return jsonResponse({ error: `${field}.model must be a string` }, 400);
818
+ }
819
+ }
747
820
  const next = { ...(config.claudeCode ?? {}) };
821
+ for (const field of ["webSearchSidecar", "visionSidecar"] as const) {
822
+ const section = body[field];
823
+ if (section === undefined) continue;
824
+ if (section === null || Object.keys(section as Record<string, unknown>).length === 0) {
825
+ delete next[field];
826
+ continue;
827
+ }
828
+ const requested = section as { backend?: "openai" | "anthropic" | null; model?: string };
829
+ const override: NonNullable<OcxClaudeCodeConfig[typeof field]> = { ...next[field] };
830
+ if (requested.backend === null) delete override.backend;
831
+ else if (requested.backend !== undefined) override.backend = requested.backend;
832
+ if (requested.model === "") delete override.model;
833
+ else if (requested.model !== undefined) override.model = requested.model;
834
+ if (Object.keys(override).length > 0) next[field] = override;
835
+ else delete next[field];
836
+ }
748
837
  if (body.enabled !== undefined) {
749
838
  if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
750
839
  next.enabled = body.enabled;
@@ -41,6 +41,7 @@ import { isUsageDebugEnabled } from "../usage/debug";
41
41
  import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "./request-decompress";
42
42
  import { resolveAdapter, resolveWireProtocolOverride } from "./adapter-resolve";
43
43
  import { hasKeyPoolFailover, rotateKeyOn429 } from "../providers/key-failover";
44
+ import { resolveProviderTransport } from "../providers/xai-transport";
44
45
  import type { WsData } from "./ws-bridge";
45
46
  import { registerTurn, trackStreamLifetime, unregisterTurn } from "./lifecycle";
46
47
  import { redactSecretString } from "../lib/redact";
@@ -612,14 +613,15 @@ export async function handleResponses(
612
613
  return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
613
614
  }
614
615
  }
616
+ route.provider = resolveProviderTransport(route.providerName, route.provider, parsed.options.promptCacheKey);
615
617
 
616
- // Vision sidecar: the routed model can't see images (provider.noVisionModels). Give it "eyes" —
617
- // describe each attached image with a gpt vision model via the ChatGPT passthrough and replace it
618
- // with text BEFORE the main call, so the text-only model can reason about it.
618
+ // Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each
619
+ // attached image through the selected sidecar backend and replace it with text BEFORE the main
620
+ // call, so the text-only model can reason about it.
619
621
  const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, selectedForwardHeaders, authCtx);
620
622
  const recordSidecarOutcome = sidecarOutcomeRecorder(config, authCtx);
621
623
  if (visionPlan) {
622
- await describeImagesInPlace(parsed, visionPlan.forwardProvider, selectedForwardHeaders, visionPlan.settings, options.abortSignal, recordSidecarOutcome);
624
+ await describeImagesInPlace(parsed, visionPlan, selectedForwardHeaders, options.abortSignal, recordSidecarOutcome);
623
625
  } else if (modelInList(route.provider.noVisionModels, route.modelId)) {
624
626
  // Sidecar-covered model but NO plan (no forward provider / missing forwarded auth / sidecar
625
627
  // disabled): fail closed — never forward raw images to a text-only upstream.
@@ -870,7 +872,9 @@ export async function handleResponses(
870
872
  parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
871
873
  const wsResponse = await runWithWebSearch({
872
874
  parsed, adapter,
875
+ backend: wsPlan.backend,
873
876
  forwardProvider: wsPlan.forwardProvider,
877
+ anthropicSidecar: wsPlan.anthropicSidecar,
874
878
  hostedTool: wsPlan.hostedTool,
875
879
  selectedForwardHeaders,
876
880
  settings: wsPlan.settings,
@@ -884,9 +888,11 @@ export async function handleResponses(
884
888
  on429: retryAfter => {
885
889
  const rotated = rotateKeyOn429(config, route.providerName, retryAfter, Date.now(), route.provider.apiKey);
886
890
  if (!rotated) return null;
887
- route.provider = rotated;
891
+ // Re-resolve the auth-mode transport so the conv-id / subscription headers derived at
892
+ // line ~616 survive the key rotation (rotated providers come from raw config).
893
+ route.provider = resolveProviderTransport(route.providerName, rotated, parsed.options.promptCacheKey);
888
894
  return resolveAdapter(
889
- resolveWireProtocolOverride(route.providerName, route.modelId, rotated),
895
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
890
896
  config.cacheRetention,
891
897
  );
892
898
  },
@@ -940,9 +946,11 @@ export async function handleResponses(
940
946
  // Release the failed response's socket before retrying; unread bodies otherwise linger
941
947
  // until runtime cleanup (one per rotated key under a rate-limit storm).
942
948
  try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
943
- route.provider = rotated;
949
+ // Same transport re-resolution as the streaming on429 path: keep conv-id + subscription
950
+ // headers on the retried request instead of silently reverting to the raw config provider.
951
+ route.provider = resolveProviderTransport(route.providerName, rotated, parsed.options.promptCacheKey);
944
952
  const retryAdapter = resolveAdapter(
945
- resolveWireProtocolOverride(route.providerName, route.modelId, rotated),
953
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
946
954
  config.cacheRetention,
947
955
  );
948
956
  const retryRequest = await retryAdapter.buildRequest(parsed, { headers: selectedForwardHeaders });