@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
package/src/bridge.ts CHANGED
@@ -146,11 +146,13 @@ export function bridgeToResponsesSSE(
146
146
 
147
147
  return new ReadableStream<Uint8Array>({
148
148
  async start(controller) {
149
+ let emittedSinceYield = false;
149
150
  const emit = (name: string, data: Record<string, unknown>) => {
150
151
  if (closed) return;
151
152
  activity = true;
152
153
  try {
153
154
  controller.enqueue(encoder.encode(sseEvent(name, { type: name, sequence_number: seq++, ...data })));
155
+ emittedSinceYield = true;
154
156
  } catch {
155
157
  closed = true;
156
158
  }
@@ -264,7 +266,7 @@ export function bridgeToResponsesSSE(
264
266
  let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string } | null = null;
265
267
  // Open native web-search cell (between begin and end). Holds the output index allocated on
266
268
  // begin so the matching done reuses it; closed as `failed` if the stream terminates early.
267
- let currentWebSearch: { itemId: string; outputIndex: number } | null = null;
269
+ let currentWebSearch: { itemId: string; eventId: string; outputIndex: number } | null = null;
268
270
  // Sources from completed web searches, awaiting the next assistant message. Attached as
269
271
  // url_citation annotations on that message (the desktop app's Sources chip), then cleared so
270
272
  // they bind to exactly one message. Deduped by URL across multiple searches in the turn.
@@ -399,9 +401,19 @@ export function bridgeToResponsesSSE(
399
401
  // we synthesize response.completed below, so Codex never hits the parser's
400
402
  // "stream closed before response.completed" (responses.rs) -> ApiError::Stream.
401
403
  let terminated = false;
404
+ let macrotaskFired = true;
405
+ let macrotaskTimer: ReturnType<typeof setTimeout> | undefined;
402
406
 
403
407
  try {
404
408
  for await (const event of events) {
409
+ if (!macrotaskFired && emittedSinceYield) {
410
+ await new Promise<void>(r => setTimeout(r, 0));
411
+ macrotaskFired = true;
412
+ }
413
+ emittedSinceYield = false;
414
+ macrotaskFired = false;
415
+ if (macrotaskTimer !== undefined) clearTimeout(macrotaskTimer);
416
+ macrotaskTimer = setTimeout(() => { macrotaskFired = true; macrotaskTimer = undefined; }, 0);
405
417
  activity = true;
406
418
  stallTicks = 0;
407
419
  // Compaction turns emit ONLY the synthetic compaction item + response.completed. The
@@ -498,12 +510,12 @@ export function bridgeToResponsesSSE(
498
510
  if (currentRawReasoning) closeCurrentRawReasoning();
499
511
  flushHiddenRawReasoning();
500
512
  if (currentToolCall) closeCurrentToolCall();
501
- const itemId = `fc_${uuid()}`;
502
513
  const mapped = toolNsMap?.get(event.name);
503
514
  const realName = mapped?.name ?? event.name;
504
515
  const ns = mapped?.namespace;
505
516
  const toolSearch = toolSearchToolNames?.has(realName) ?? false;
506
517
  const freeform = !toolSearch && (freeformToolNames?.has(realName) ?? false);
518
+ const itemId = `${toolSearch ? "tsc" : freeform ? "ctc" : "fc"}_${uuid()}`;
507
519
  const item = toolSearch
508
520
  ? { type: "tool_search_call", id: itemId, call_id: event.id, execution: "client", arguments: {}, status: "in_progress" }
509
521
  : freeform
@@ -554,23 +566,25 @@ export function bridgeToResponsesSSE(
554
566
  flushHiddenRawReasoning();
555
567
  if (currentToolCall) closeCurrentToolCall();
556
568
  if (currentWebSearch) closeCurrentWebSearch("completed", []);
569
+ const wsItemId = `ws_${uuid()}`;
557
570
  emit("response.output_item.added", {
558
571
  output_index: outputIndex,
559
- item: { type: "web_search_call", id: event.id, status: "in_progress" },
572
+ item: { type: "web_search_call", id: wsItemId, status: "in_progress" },
560
573
  });
561
- currentWebSearch = { itemId: event.id, outputIndex };
574
+ currentWebSearch = { itemId: wsItemId, eventId: event.id, outputIndex };
562
575
  break;
563
576
  }
564
577
  case "web_search_call_end": {
565
578
  // The sidecar resolved — finalize the cell as "Searched <query>". If no begin opened
566
579
  // (defensive), synthesize the added frame first so the done has a matching item.
567
- if (!currentWebSearch || currentWebSearch.itemId !== event.id) {
580
+ if (!currentWebSearch || currentWebSearch.eventId !== event.id) {
568
581
  if (currentWebSearch) closeCurrentWebSearch("completed", []);
582
+ const wsItemId2 = `ws_${uuid()}`;
569
583
  emit("response.output_item.added", {
570
584
  output_index: outputIndex,
571
- item: { type: "web_search_call", id: event.id, status: "in_progress" },
585
+ item: { type: "web_search_call", id: wsItemId2, status: "in_progress" },
572
586
  });
573
- currentWebSearch = { itemId: event.id, outputIndex };
587
+ currentWebSearch = { itemId: wsItemId2, eventId: event.id, outputIndex };
574
588
  }
575
589
  closeCurrentWebSearch(event.status ?? "completed", event.queries, event.sources);
576
590
  // Queue this search's sources for the next assistant message (dedup by URL).
@@ -650,6 +664,7 @@ export function bridgeToResponsesSSE(
650
664
  }
651
665
 
652
666
  if (beat) clearInterval(beat);
667
+ if (macrotaskTimer !== undefined) clearTimeout(macrotaskTimer);
653
668
 
654
669
  if (!terminated) {
655
670
  // The adapter generator ended without an explicit done/error event. Mark as incomplete
@@ -782,13 +797,13 @@ export function buildResponseJSON(
782
797
  const freeform = !toolSearch && (options?.freeformToolNames?.has(realName) ?? false);
783
798
  if (toolSearch) {
784
799
  output.push({
785
- type: "tool_search_call", id: `fc_${uuid()}`,
800
+ type: "tool_search_call", id: `tsc_${uuid()}`,
786
801
  call_id: currentToolCallId, execution: "client",
787
802
  arguments: parseArgsObj(currentToolCallArgs), status: "completed",
788
803
  });
789
804
  } else if (freeform) {
790
805
  output.push({
791
- type: "custom_tool_call", id: `fc_${uuid()}`,
806
+ type: "custom_tool_call", id: `ctc_${uuid()}`,
792
807
  call_id: currentToolCallId, name: realName,
793
808
  input: freeformInput(currentToolCallArgs), status: "completed",
794
809
  });
@@ -862,7 +877,7 @@ export function buildResponseJSON(
862
877
  if (currentRawReasoning) flushRawReasoning();
863
878
  flushToolCall();
864
879
  output.push({
865
- type: "web_search_call", id: e.id, status: e.status ?? "completed",
880
+ type: "web_search_call", id: `ws_${uuid()}`, status: e.status ?? "completed",
866
881
  action: webSearchAction(e.queries),
867
882
  ...(e.sources && e.sources.length > 0 ? { sources: e.sources } : {}),
868
883
  });
package/src/cli/claude.ts CHANGED
@@ -54,6 +54,9 @@ export function buildClaudeEnv(config: OcxConfig, port: number, base: ClaudeLaun
54
54
  if ((config.apiKeys?.length ?? 0) > 0) {
55
55
  setDefault("ANTHROPIC_AUTH_TOKEN", config.apiKeys![0].key);
56
56
  }
57
+ if (!env.ANTHROPIC_AUTH_TOKEN && config.claudeCode?.authMode === "proxy") {
58
+ env.ANTHROPIC_AUTH_TOKEN = "opencodex-proxy";
59
+ }
57
60
  // NOTE: do NOT set _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL here. While it enables
58
61
  // Design/Remote Control, it DISABLES gateway model discovery (Claude Code's eligibility
59
62
  // check returns false when isFirstPartyBaseUrl() is true). Model routing through the
@@ -851,6 +851,17 @@ function deriveEntry(template: RawEntry | null, slug: string, desc: string, prio
851
851
  applyNativeOpenAiContextOverride(e);
852
852
  if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e);
853
853
  else ensureUltraReasoningLevel(e);
854
+ // Non-5.6 natives (5.5, 5.4, 5.4-mini, spark) do not support responses-lite;
855
+ // the template may carry the flag from a 5.6 entry — strip it so codex-rs does
856
+ // not inject reasoning.context: "all_turns" for models that reject it.
857
+ if (!isGpt56NativeSlug(slug)) {
858
+ // Spark NEEDS use_responses_lite: true — it controls the tool delivery format
859
+ // (AdditionalTools in input vs top-level tools). The reasoning params that
860
+ // use_responses_lite triggers (context: "all_turns", summary) are stripped
861
+ // separately in the passthrough adapter (stripUnsupportedReasoningParams).
862
+ if (!slug.includes("codex-spark")) delete e.use_responses_lite;
863
+ delete e.supports_websockets;
864
+ }
854
865
  }
855
866
  return ensureStrictCatalogFields(normalizeServiceTiers(e));
856
867
  }
@@ -94,11 +94,17 @@ export async function fetchWithAttemptDeadline(
94
94
  init: RequestInit,
95
95
  timeoutMs: number,
96
96
  abortSignal?: AbortSignal,
97
+ preferIdentityEncoding = false,
97
98
  ): Promise<Response> {
98
99
  const attemptTimeout = clearableDeadline(timeoutMs, abortSignal);
100
+ const headers = new Headers(init.headers);
101
+ if (preferIdentityEncoding && !headers.has("accept-encoding")) {
102
+ headers.set("accept-encoding", "identity");
103
+ }
99
104
  try {
100
105
  return await fetch(url, {
101
106
  ...init,
107
+ headers,
102
108
  signal: attemptTimeout.signal,
103
109
  });
104
110
  } finally {
@@ -153,6 +153,17 @@ const KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-hig
153
153
  const KIMI_API_MODEL_CONTEXT_WINDOWS: Record<string, number> = Object.fromEntries(
154
154
  KIMI_API_MODELS.map(id => [id, 262_144]),
155
155
  );
156
+
157
+ // 260715 NVIDIA NIM kimi family (issue #126): documented served ids on integrate
158
+ // chat/completions per docs.api.nvidia.com/nim/reference/llm-apis; live /v1/models
159
+ // currently lists only kimi-k2.6 but the list is dynamic, so carry the documented family.
160
+ const NVIDIA_NIM_KIMI_THINKING_MODELS = [
161
+ "moonshotai/kimi-k2.6", "moonshotai/kimi-k2.5", "moonshotai/kimi-k2-thinking",
162
+ ];
163
+ const NVIDIA_NIM_KIMI_MODELS = [
164
+ ...NVIDIA_NIM_KIMI_THINKING_MODELS,
165
+ "moonshotai/kimi-k2-instruct", "moonshotai/kimi-k2-instruct-0905",
166
+ ];
156
167
  const KIMI_CODING_MODEL_CONTEXT_WINDOWS: Record<string, number> = Object.fromEntries(
157
168
  KIMI_CODING_MODELS.map(id => [id, 262_144]),
158
169
  );
@@ -202,7 +213,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
202
213
  authKind: "oauth",
203
214
  featured: false,
204
215
  dashboardPreset: true,
205
- note: "Experimental Cursor bridge. Live transport and live model discovery are enabled after a standalone PKCE browser login via 'ocx login cursor'; native read/write/delete/shell/fetch execution stays disabled unless you set \"nativeLocalExec\": \"on\" (always) or \"codex-sandbox\" (only for requests declaring the Codex danger-full-access sandbox; the declaration is caller-controlled prose the proxy cannot verify, and the auth-free loopback bind admits any process on this host, including other local users — enable only where every data-plane client is trusted) — legacy \"unsafeAllowNativeLocalExec\": true still means \"on\" — on providers.cursor in ~/.opencodex/config.json (dashboard: Providers → Cursor → Edit JSON) for a trusted local experiment.",
216
+ note: "Experimental Cursor bridge. Live transport and live model discovery are enabled after a standalone PKCE browser login via 'ocx login cursor'; native read/write/delete/shell/fetch execution defaults to codex-sandbox mode (auto-enabled when the request declares Codex danger-full-access sandbox); override with \"nativeLocalExec\": \"on\" (always), \"off\" (never), or \"codex-sandbox\" (only for requests declaring the Codex danger-full-access sandbox; the declaration is caller-controlled prose the proxy cannot verify, and the auth-free loopback bind admits any process on this host, including other local users — enable only where every data-plane client is trusted) — legacy \"unsafeAllowNativeLocalExec\": true still means \"on\" — on providers.cursor in ~/.opencodex/config.json (dashboard: Providers → Cursor → Edit JSON) for a trusted local experiment.",
206
217
  models: cursorModelIds(CURSOR_STATIC_MODELS),
207
218
  liveModels: true,
208
219
  defaultModel: "auto",
@@ -497,7 +508,21 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
497
508
  preserveReasoningContentModels: KIMI_API_MODELS,
498
509
  },
499
510
  { id: "huggingface", label: "Hugging Face", baseUrl: "https://router.huggingface.co/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://huggingface.co/settings/tokens" },
500
- { id: "nvidia", label: "NVIDIA NIM", baseUrl: "https://integrate.api.nvidia.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://build.nvidia.com" },
511
+ // 260715 NIM hardening (issue #126, devlog/_plan/260715_issue126_nim_kimi):
512
+ // - NIM kimi rejects `parallel_tool_calls: true` with 400 "This model only supports single
513
+ // tool-calls at once!" (openclaw#37048). NVIDIA's own function-calling docs default the
514
+ // Boolean to false, so provider-wide `false` is the documented-safe wire value.
515
+ // - `reasoning_effort` is not portable on NIM (models use chat_template_kwargs); the kimi
516
+ // family is live-discovered with no capability metadata, so Codex would otherwise send
517
+ // reasoning_effort=medium. Exact-id lists per modelInList semantics; gpt-oss on NIM keeps
518
+ // its working reasoning_effort. Future kimi ids must be appended individually.
519
+ {
520
+ id: "nvidia", label: "NVIDIA NIM", baseUrl: "https://integrate.api.nvidia.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://build.nvidia.com",
521
+ parallelToolCalls: false,
522
+ noReasoningModels: NVIDIA_NIM_KIMI_MODELS,
523
+ modelReasoningEfforts: Object.fromEntries(NVIDIA_NIM_KIMI_MODELS.map(id => [id, []])),
524
+ preserveReasoningContentModels: NVIDIA_NIM_KIMI_THINKING_MODELS,
525
+ },
501
526
  { id: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://venice.ai/settings/api" },
502
527
  // 260710 GLM-5.2 context and path-specific ids: Tier-2 evidence in
503
528
  // devlog/_plan/260710_provider_hardening/002_research_cn.md.
@@ -7,6 +7,8 @@
7
7
  * unchanged. The Responses output (SSE or JSON) is converted back to Anthropic shape.
8
8
  */
9
9
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
10
+ import { enforceAnthropicImageLimits } from "../adapters/anthropic-image-guard";
11
+ import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize";
10
12
  import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound";
11
13
  import { stripOneMillionMarker } from "../claude/context-windows";
12
14
  import { captureClaudeInbound } from "../claude/inbound-debug";
@@ -187,6 +189,15 @@ async function anthropicNativePassthrough(
187
189
 
188
190
  const base = (config.claudeCode?.anthropicBaseUrl ?? "https://api.anthropic.com").replace(/\/$/, "");
189
191
  const search = new URL(req.url).search;
192
+ // Native passthrough bypasses the anthropic adapter, so the generous image pipeline
193
+ // (devlog/260714_image_normalization_pipeline/040) must run here: tier-normalize then
194
+ // guard the already-Anthropic-wire messages before serialization. Applies to
195
+ // count_tokens too — counts must match what the real send will contain, and the 32MB
196
+ // body cap applies to it equally. Non-message bodies pass through untouched.
197
+ if (Array.isArray(body.messages)) {
198
+ await normalizeAnthropicImages(body.messages);
199
+ enforceAnthropicImageLimits(body.messages);
200
+ }
190
201
  const headers = new Headers();
191
202
  req.headers.forEach((value, name) => {
192
203
  if (!PASSTHROUGH_STRIP_HEADERS.has(name.toLowerCase())) headers.set(name, value);
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Upstream-413 tightened-retry gate (devlog/260714_image_normalization_pipeline/030).
3
+ *
4
+ * When Anthropic still rejects a normalized request with 413 request_too_large (budget
5
+ * estimate missed: giant text share, tool schemas, ...), the proxy rebuilds the SAME
6
+ * request with `imageTierBias: 1` — every image one ladder position lower — and retries
7
+ * exactly once. The decision logic lives here so it is unit-testable; the fetch loop in
8
+ * responses.ts consumes it.
9
+ */
10
+
11
+ import type { OcxParsedRequest } from "../types";
12
+
13
+ /** True when the parsed request carries at least one inline (data-URL) image. */
14
+ export function parsedHasInlineImage(parsed: OcxParsedRequest): boolean {
15
+ const messages = (parsed as { context?: { messages?: unknown[] } }).context?.messages ?? [];
16
+ for (const message of messages) {
17
+ const content = (message as { content?: unknown }).content;
18
+ if (!Array.isArray(content)) continue;
19
+ for (const part of content) {
20
+ const imageUrl = (part as { imageUrl?: unknown })?.imageUrl;
21
+ if (typeof imageUrl === "string" && imageUrl.startsWith("data:")) return true;
22
+ }
23
+ }
24
+ return false;
25
+ }
26
+
27
+ /**
28
+ * One tier-biased rebuild per request (spiral guard), only for the anthropic adapter
29
+ * (others ignore imageTierBias — an identical retry would just duplicate cost), and only
30
+ * when the request actually carries inline images the bias can shrink.
31
+ */
32
+ export function shouldAttemptImageTierRetry(args: {
33
+ status: number;
34
+ adapterName: string;
35
+ parsed: OcxParsedRequest;
36
+ alreadyAttempted: boolean;
37
+ }): boolean {
38
+ return args.status === 413
39
+ && !args.alreadyAttempted
40
+ && args.adapterName === "anthropic"
41
+ && parsedHasInlineImage(args.parsed);
42
+ }
@@ -273,6 +273,33 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
273
273
  });
274
274
  }
275
275
 
276
+ if (url.pathname === "/api/shadow-call-settings" && req.method === "GET") {
277
+ const sci = config.shadowCallIntercept ?? {};
278
+ return jsonResponse({ enabled: sci.enabled === true, model: sci.model ?? "" });
279
+ }
280
+
281
+ if (url.pathname === "/api/shadow-call-settings" && req.method === "PUT") {
282
+ let raw: unknown;
283
+ try { raw = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
284
+ if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400);
285
+ const body = raw as { enabled?: unknown; model?: unknown };
286
+ if (body.enabled !== undefined && typeof body.enabled !== "boolean") {
287
+ return jsonResponse({ error: "enabled must be a boolean" }, 400);
288
+ }
289
+ if (body.model !== undefined && typeof body.model !== "string") {
290
+ return jsonResponse({ error: "model must be a string" }, 400);
291
+ }
292
+ config.shadowCallIntercept = { ...config.shadowCallIntercept };
293
+ if (typeof body.enabled === "boolean") config.shadowCallIntercept.enabled = body.enabled;
294
+ if (typeof body.model === "string") {
295
+ if (body.model === "") delete config.shadowCallIntercept.model;
296
+ else config.shadowCallIntercept.model = body.model;
297
+ }
298
+ saveConfig(config);
299
+ const sci = config.shadowCallIntercept;
300
+ return jsonResponse({ ok: true, enabled: sci.enabled === true, model: sci.model ?? "" });
301
+ }
302
+
276
303
  if (url.pathname === "/api/logs" && req.method === "GET") {
277
304
  return jsonResponse(filterRequestLogs(getRequestLogEntries(), url.searchParams));
278
305
  }
@@ -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, rotateProviderTransportOn429 } from "../providers/key-failover";
44
+ import { shouldAttemptImageTierRetry } from "./image-retry";
44
45
  import { resolveProviderTransport } from "../providers/xai-transport";
45
46
  import type { WsData } from "./ws-bridge";
46
47
  import { registerTurn, trackStreamLifetime, unregisterTurn } from "./lifecycle";
@@ -346,9 +347,10 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number {
346
347
  && (child as { type?: unknown }).type === "agent_message"
347
348
  && !hasEncryptedContentPart((child as { content?: unknown }).content)
348
349
  ) {
349
- const message = child as { type: string; role?: string; author?: unknown; recipient?: unknown };
350
+ const message = child as { type: string; role?: string; id?: unknown; author?: unknown; recipient?: unknown };
350
351
  message.type = "message";
351
352
  message.role = "user";
353
+ delete message.id;
352
354
  delete message.author;
353
355
  delete message.recipient;
354
356
  }
@@ -465,6 +467,22 @@ export async function handleResponses(
465
467
  logCtx.configuredServiceTier = readConfiguredCodexServiceTier();
466
468
  logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier);
467
469
 
470
+ // Shadow call intercept: rewrite Codex Desktop's hard-coded gpt-5.4-mini helper calls
471
+ const _sci = config.shadowCallIntercept;
472
+ if (_sci?.enabled && _sci.model && parsed.modelId.startsWith("gpt-5.4-mini")) {
473
+ const _sciOriginal = parsed.modelId;
474
+ parsed.modelId = _sci.model;
475
+ if (parsed._rawBody && typeof parsed._rawBody === "object") {
476
+ (parsed._rawBody as { model?: string }).model = _sci.model;
477
+ }
478
+ // Force effort to low for shadow/helper calls (matching upstream behavior)
479
+ parsed.options.reasoning = "low";
480
+ if (parsed._rawBody && typeof parsed._rawBody === "object") {
481
+ (parsed._rawBody as Record<string, unknown>).reasoning = { effort: "low" };
482
+ }
483
+ (logCtx as unknown as Record<string, unknown>).shadowCallRewrittenFrom = _sciOriginal;
484
+ }
485
+
468
486
  let route;
469
487
  try {
470
488
  route = routeModel(config, parsed.modelId);
@@ -923,7 +941,7 @@ export async function handleResponses(
923
941
  let upstreamResponse: Response;
924
942
  try {
925
943
  upstreamResponse = adapter.fetchResponse
926
- ? await adapter.fetchResponse(request, { abortSignal: upstream.signal, timeoutMs: connectMs })
944
+ ? await adapter.fetchResponse(request, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream })
927
945
  : await fetchWithResetRetry(
928
946
  () => fetchWithHeaderTimeout(request.url, {
929
947
  method: request.method, headers: request.headers, body: request.body,
@@ -940,29 +958,22 @@ export async function handleResponses(
940
958
  }
941
959
 
942
960
  if (!upstreamResponse.ok) {
943
- // Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the SAME
944
- // request once per remaining key. OAuth/forward providers and single-key pools return null
945
- // immediately, so this stays a no-op for them (src/providers/key-failover.ts).
946
- while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) {
947
- const rotated = rotateProviderTransportOn429(config, route.providerName, {
948
- retryAfter: upstreamResponse.headers.get("retry-after"),
949
- now: Date.now(),
950
- attemptedKey: route.provider.apiKey,
951
- promptCacheKey: parsed.options.promptCacheKey,
961
+ // Recovery loop: multi-key 429 failover + at most ONE anthropic 413 tightened retry
962
+ // (devlog/260714_image_normalization_pipeline/030). One mutable activeAdapter serves
963
+ // both paths so a 429→413 sequence never rebuilds against a stale pre-rotation
964
+ // adapter, and imageTierBias once armed — rides EVERY subsequent rebuild so a
965
+ // 413→429 rotation cannot silently undo the tightening.
966
+ let activeAdapter = adapter;
967
+ let imageTierBias = 0;
968
+ let imageRetryAttempted = false;
969
+ const rebuildAndRefetch = async (): Promise<Response | { failed: Response }> => {
970
+ const retryRequest = await activeAdapter.buildRequest(parsed, {
971
+ headers: selectedForwardHeaders,
972
+ ...(imageTierBias > 0 ? { imageTierBias } : {}),
952
973
  });
953
- if (!rotated) break;
954
- // Release the failed response's socket before retrying; unread bodies otherwise linger
955
- // until runtime cleanup (one per rotated key under a rate-limit storm).
956
- try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
957
- route.provider = rotated;
958
- const retryAdapter = resolveAdapter(
959
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
960
- config.cacheRetention,
961
- );
962
- const retryRequest = await retryAdapter.buildRequest(parsed, { headers: selectedForwardHeaders });
963
974
  try {
964
- upstreamResponse = retryAdapter.fetchResponse
965
- ? await retryAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs })
975
+ return activeAdapter.fetchResponse
976
+ ? await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream })
966
977
  : await fetchWithHeaderTimeout(retryRequest.url, {
967
978
  method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
968
979
  }, upstream.signal, connectMs, parsed.stream);
@@ -972,8 +983,50 @@ export async function handleResponses(
972
983
  const msg = err instanceof Error && err.name === "TimeoutError"
973
984
  ? `Provider connect timeout after ${connectMs}ms`
974
985
  : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
975
- return formatErrorResponse(502, "upstream_error", msg);
986
+ return { failed: formatErrorResponse(502, "upstream_error", msg) };
987
+ }
988
+ };
989
+ recovery: for (;;) {
990
+ // Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the
991
+ // SAME request once per remaining key. OAuth/forward providers and single-key pools
992
+ // return null immediately, so this stays a no-op for them (src/providers/key-failover.ts).
993
+ while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) {
994
+ const rotated = rotateProviderTransportOn429(config, route.providerName, {
995
+ retryAfter: upstreamResponse.headers.get("retry-after"),
996
+ now: Date.now(),
997
+ attemptedKey: route.provider.apiKey,
998
+ promptCacheKey: parsed.options.promptCacheKey,
999
+ });
1000
+ if (!rotated) break;
1001
+ // Release the failed response's socket before retrying; unread bodies otherwise linger
1002
+ // until runtime cleanup (one per rotated key under a rate-limit storm).
1003
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
1004
+ route.provider = rotated;
1005
+ activeAdapter = resolveAdapter(
1006
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
1007
+ config.cacheRetention,
1008
+ );
1009
+ const result = await rebuildAndRefetch();
1010
+ if ("failed" in result) return result.failed;
1011
+ upstreamResponse = result;
1012
+ }
1013
+ // Anthropic 413 request_too_large: rebuild once with every image one tier lower
1014
+ // (spiral guard: single attempt). The biased response re-enters the 429 check above.
1015
+ if (shouldAttemptImageTierRetry({
1016
+ status: upstreamResponse.status,
1017
+ adapterName: activeAdapter.name,
1018
+ parsed,
1019
+ alreadyAttempted: imageRetryAttempted,
1020
+ })) {
1021
+ imageRetryAttempted = true;
1022
+ imageTierBias = 1;
1023
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
1024
+ const result = await rebuildAndRefetch();
1025
+ if ("failed" in result) return result.failed;
1026
+ upstreamResponse = result;
1027
+ continue recovery;
976
1028
  }
1029
+ break;
977
1030
  }
978
1031
  if (!upstreamResponse.ok) {
979
1032
  const errorText = await upstreamResponse.text().catch(() => "unknown error");
@@ -25,13 +25,15 @@ function writeShellEnvFile(port: number, config: OcxConfig, modelEnv: Record<str
25
25
  `export ANTHROPIC_BASE_URL=${shellValue(`http://127.0.0.1:${port}`)}`,
26
26
  `export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=${shellValue("1")}`,
27
27
  ];
28
- if (config.apiKeys?.length) {
29
- lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`);
30
- }
31
28
  // New lever keys are CONDITIONAL exports (audit 139 R2#1): a value the user already
32
29
  // exported in their shell wins even though launchctl knows nothing about it.
33
30
  const conditional = (name: string, value: string) =>
34
31
  `[ -z "\${${name}+x}" ] && export ${name}=${shellValue(value)}`;
32
+ if (config.apiKeys?.length) {
33
+ lines.push(`export ANTHROPIC_AUTH_TOKEN=${shellValue(config.apiKeys[0].key)}`);
34
+ } else if (config.claudeCode?.authMode === "proxy") {
35
+ lines.push(conditional("ANTHROPIC_AUTH_TOKEN", "opencodex-proxy"));
36
+ }
35
37
  // Model slots (default + tiers + legacy small-fast) with [1m] applied (devlog 260712 B2).
36
38
  if (modelEnv.ANTHROPIC_MODEL) {
37
39
  lines.push(`export ANTHROPIC_MODEL=${shellValue(modelEnv.ANTHROPIC_MODEL)}`);
@@ -238,6 +240,8 @@ export async function injectSystemEnv(port: number, config: OcxConfig): Promise<
238
240
  inject("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", "1");
239
241
  if (config.apiKeys?.length) {
240
242
  inject("ANTHROPIC_AUTH_TOKEN", config.apiKeys[0].key);
243
+ } else if (config.claudeCode?.authMode === "proxy" && launchctlGetenv("ANTHROPIC_AUTH_TOKEN") === undefined) {
244
+ inject("ANTHROPIC_AUTH_TOKEN", "opencodex-proxy");
241
245
  }
242
246
  // Lever keys (devlog 136 B6): user-wins — skip any key the user already set in the
243
247
  // launchd domain, and track ONLY the keys we actually injected so revert cannot
package/src/types.ts CHANGED
@@ -270,6 +270,12 @@ export interface OcxClaudeCodeConfig {
270
270
  * on stop/shutdown. Default: false (opt-in). macOS only.
271
271
  */
272
272
  systemEnv?: boolean;
273
+ /**
274
+ * Auth mode for Claude Code inbound requests. "proxy" injects a dummy
275
+ * ANTHROPIC_AUTH_TOKEN so Claude Code routes through the proxy without a
276
+ * real Anthropic key. Default: undefined (no token injection).
277
+ */
278
+ authMode?: "proxy";
273
279
  /**
274
280
  * Context-window override for Claude Code/Desktop clients (devlog 136 B6):
275
281
  * injected as CLAUDE_CODE_MAX_CONTEXT_TOKENS + DISABLE_COMPACT=1 (the official
@@ -379,6 +385,17 @@ export interface OcxConfig {
379
385
  * are omitted from the bare /v1/models list.
380
386
  */
381
387
  disabledModels?: string[];
388
+ /**
389
+ * Shadow call intercept: redirect Codex Desktop's hard-coded gpt-5.4-mini helper calls
390
+ * (title generation, commit messages, skill orchestration) to a user-chosen model.
391
+ * Opt-in; disabled by default. When enabled, effort is forced to low.
392
+ */
393
+ shadowCallIntercept?: {
394
+ /** When true, all gpt-5.4-mini* requests are rewritten to the configured model. */
395
+ enabled?: boolean;
396
+ /** Replacement model id (e.g. "gpt-5.5"). */
397
+ model?: string;
398
+ };
382
399
  /**
383
400
  * 3-state multi-agent surface override:
384
401
  * - "v1": force ALL models to v1 surface (override upstream pins)
@@ -668,10 +685,11 @@ export interface OcxProviderConfig {
668
685
  unsafeAllowNativeLocalExec?: boolean;
669
686
  /**
670
687
  * Cursor adapter only: native local exec policy mode (exec-policy.ts).
671
- * "off" (default) rejects all server-driven local exec; "on" always allows
672
- * (same as legacy unsafeAllowNativeLocalExec:true); "codex-sandbox" allows only
673
- * when the request's instructions/developer text declares the Codex
674
- * danger-full-access sandbox. NOTE: the declaration is CALLER-CONTROLLED prose
688
+ * "codex-sandbox" (default) allows server-driven local exec only when the
689
+ * request's instructions/developer text declares the Codex danger-full-access
690
+ * sandbox (approves the normal full-access flow, denies undeclared requests);
691
+ * "off" rejects all server-driven local exec; "on" always allows (same as legacy
692
+ * unsafeAllowNativeLocalExec:true). NOTE: the declaration is CALLER-CONTROLLED prose —
675
693
  * the proxy cannot verify it. Enable "codex-sandbox" only where every client
676
694
  * that can reach the data plane is trusted: the default loopback bind admits
677
695
  * ANY process on this host without auth (including other local users on
@@ -97,13 +97,16 @@ export function findAnthropicSidecarProvider(config: OcxConfig): AnthropicSideca
97
97
  return undefined;
98
98
  }
99
99
 
100
- /** Precedence (audit F4/F7): explicit config wins; unset resolves to anthropic when a usable credential exists, else openai. */
100
+ /**
101
+ * Precedence: explicit config wins; unset defaults to "openai" (ChatGPT forward path). The
102
+ * anthropic backend (web_search_20250305) is only used when explicitly configured — auto-selecting
103
+ * it from credential availability caused the sidecar to send incompatible models (e.g. gpt-5.6-luna)
104
+ * to the Anthropic API.
105
+ */
101
106
  export function resolveSidecarBackend(
102
107
  explicit: "openai" | "anthropic" | undefined,
103
- anthropicSidecar: AnthropicSidecarProvider | undefined,
104
108
  ): "openai" | "anthropic" {
105
- if (explicit === "anthropic" || explicit === "openai") return explicit;
106
- return anthropicSidecar ? "anthropic" : "openai";
109
+ return explicit === "anthropic" ? "anthropic" : "openai";
107
110
  }
108
111
 
109
112
  export interface SidecarPlan {
@@ -145,7 +148,7 @@ export function planWebSearch(
145
148
  // Same `?? 200_000` default the server applies when threading connectTimeoutMs into the loop.
146
149
  const connectTimeoutMs = config.connectTimeoutMs ?? 200_000;
147
150
  const anthropicSidecar = findAnthropicSidecarProvider(config);
148
- const backend = resolveSidecarBackend(cfg.backend, anthropicSidecar);
151
+ const backend = resolveSidecarBackend(cfg.backend);
149
152
  const maxSearches = cfg.maxSearchesPerTurn ?? DEFAULT_MAX_SEARCHES;
150
153
  const stallTimeoutSec = webSearchStallTimeoutSec(
151
154
  config.stallTimeoutSec,
@@ -271,14 +271,19 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
271
271
  abortSignal: headerDeadline.signal,
272
272
  timeoutMs: connectTimeoutMs,
273
273
  returnRawErrors: true,
274
+ stream: true,
274
275
  })
275
276
  : await fetchWithResetRetry(
276
- () => fetch(request.url, {
277
- method: request.method,
278
- headers: request.headers,
279
- body: request.body,
280
- signal: headerDeadline.signal,
281
- }),
277
+ () => {
278
+ const h = new Headers(request.headers);
279
+ if (!h.has("accept-encoding")) h.set("accept-encoding", "identity");
280
+ return fetch(request.url, {
281
+ method: request.method,
282
+ headers: h,
283
+ body: request.body,
284
+ signal: headerDeadline.signal,
285
+ });
286
+ },
282
287
  { abortSignal: headerDeadline.signal, label: "web-search-loop" },
283
288
  );
284
289
  return { response, responseAdapter: requestAdapter };