@bitkyc08/opencodex 2.7.17 → 2.7.19

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-D8ODGlXj.js +40 -0
  2. package/gui/dist/assets/index-DbIT5GLo.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +4 -2
  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 +6 -0
  9. package/src/adapters/cursor/exec-policy.ts +9 -1
  10. package/src/adapters/cursor/live-transport.ts +19 -11
  11. package/src/adapters/cursor/protobuf-request.ts +7 -6
  12. package/src/adapters/kiro-images.ts +94 -0
  13. package/src/adapters/kiro.ts +6 -2
  14. package/src/adapters/mimo-free.ts +228 -0
  15. package/src/adapters/openai-chat.ts +153 -1
  16. package/src/adapters/openai-responses.ts +128 -2
  17. package/src/cli/claude.ts +3 -0
  18. package/src/codex/catalog.ts +25 -1
  19. package/src/oauth/callback-server.ts +17 -7
  20. package/src/oauth/index.ts +93 -0
  21. package/src/oauth/types.ts +1 -1
  22. package/src/providers/derive.ts +4 -0
  23. package/src/providers/registry.ts +64 -3
  24. package/src/server/adapter-resolve.ts +3 -0
  25. package/src/server/auth-cors.ts +4 -0
  26. package/src/server/claude-messages.ts +11 -0
  27. package/src/server/image-retry.ts +42 -0
  28. package/src/server/management-api.ts +57 -1
  29. package/src/server/relay.ts +6 -2
  30. package/src/server/request-log.ts +4 -1
  31. package/src/server/responses.ts +74 -22
  32. package/src/server/system-env.ts +7 -3
  33. package/src/types.ts +22 -4
  34. package/src/web-search/index.ts +8 -5
  35. package/gui/dist/assets/index-Cq8maiJf.css +0 -1
  36. package/gui/dist/assets/index-m4o3xsSn.js +0 -40
@@ -23,6 +23,8 @@ export interface ProviderRegistryEntry {
23
23
  allowPrivateNetworkByDefault?: boolean;
24
24
  keyOptional?: boolean;
25
25
  allowBaseUrlOverride?: boolean;
26
+ /** Static headers merged into every upstream request for this provider. */
27
+ staticHeaders?: Record<string, string>;
26
28
  modelSuffixBracketStrip?: boolean;
27
29
  featured?: boolean;
28
30
  dashboardPreset?: boolean;
@@ -66,7 +68,7 @@ export type ProviderConfigSeed = Pick<
66
68
  | "reasoningEfforts" | "modelReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap"
67
69
  | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels"
68
70
  | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames"
69
- | "googleMode" | "project" | "location"
71
+ | "googleMode" | "project" | "location" | "headers"
70
72
  >;
71
73
 
72
74
  // Shared between the OAuth (Claude account) and API-key Anthropic entries so both expose the
@@ -133,6 +135,7 @@ const THINKING_BUDGET_MODELS = [
133
135
  ];
134
136
  const OPENCODE_GO_THINKING_BUDGET_MODELS = ["qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus"];
135
137
  const DEEPSEEK_THINKING_MODELS = ["deepseek-v4-pro", "deepseek-v4-flash"];
138
+ const OPENCODE_FREE_DEEPSEEK_MODELS = ["deepseek-v4-flash-free"];
136
139
  // "max" is advertised too: the wire map routes xhigh->max and max->max, so the picker
137
140
  // should surface the max tier instead of hiding it behind xhigh.
138
141
  const DEEPSEEK_THINKING_EFFORTS = ["high", "xhigh", "max"];
@@ -153,6 +156,17 @@ const KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-hig
153
156
  const KIMI_API_MODEL_CONTEXT_WINDOWS: Record<string, number> = Object.fromEntries(
154
157
  KIMI_API_MODELS.map(id => [id, 262_144]),
155
158
  );
159
+
160
+ // 260715 NVIDIA NIM kimi family (issue #126): documented served ids on integrate
161
+ // chat/completions per docs.api.nvidia.com/nim/reference/llm-apis; live /v1/models
162
+ // currently lists only kimi-k2.6 but the list is dynamic, so carry the documented family.
163
+ const NVIDIA_NIM_KIMI_THINKING_MODELS = [
164
+ "moonshotai/kimi-k2.6", "moonshotai/kimi-k2.5", "moonshotai/kimi-k2-thinking",
165
+ ];
166
+ const NVIDIA_NIM_KIMI_MODELS = [
167
+ ...NVIDIA_NIM_KIMI_THINKING_MODELS,
168
+ "moonshotai/kimi-k2-instruct", "moonshotai/kimi-k2-instruct-0905",
169
+ ];
156
170
  const KIMI_CODING_MODEL_CONTEXT_WINDOWS: Record<string, number> = Object.fromEntries(
157
171
  KIMI_CODING_MODELS.map(id => [id, 262_144]),
158
172
  );
@@ -202,7 +216,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
202
216
  authKind: "oauth",
203
217
  featured: false,
204
218
  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 defaults to codex-sandbox mode (auto-enabled when the request declares Codex danger-full-access sandbox); override with \"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.",
219
+ 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
220
  models: cursorModelIds(CURSOR_STATIC_MODELS),
207
221
  liveModels: true,
208
222
  defaultModel: "auto",
@@ -497,7 +511,21 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
497
511
  preserveReasoningContentModels: KIMI_API_MODELS,
498
512
  },
499
513
  { 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" },
514
+ // 260715 NIM hardening (issue #126, devlog/_plan/260715_issue126_nim_kimi):
515
+ // - NIM kimi rejects `parallel_tool_calls: true` with 400 "This model only supports single
516
+ // tool-calls at once!" (openclaw#37048). NVIDIA's own function-calling docs default the
517
+ // Boolean to false, so provider-wide `false` is the documented-safe wire value.
518
+ // - `reasoning_effort` is not portable on NIM (models use chat_template_kwargs); the kimi
519
+ // family is live-discovered with no capability metadata, so Codex would otherwise send
520
+ // reasoning_effort=medium. Exact-id lists per modelInList semantics; gpt-oss on NIM keeps
521
+ // its working reasoning_effort. Future kimi ids must be appended individually.
522
+ {
523
+ id: "nvidia", label: "NVIDIA NIM", baseUrl: "https://integrate.api.nvidia.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://build.nvidia.com",
524
+ parallelToolCalls: false,
525
+ noReasoningModels: NVIDIA_NIM_KIMI_MODELS,
526
+ modelReasoningEfforts: Object.fromEntries(NVIDIA_NIM_KIMI_MODELS.map(id => [id, []])),
527
+ preserveReasoningContentModels: NVIDIA_NIM_KIMI_THINKING_MODELS,
528
+ },
501
529
  { id: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://venice.ai/settings/api" },
502
530
  // 260710 GLM-5.2 context and path-specific ids: Tier-2 evidence in
503
531
  // devlog/_plan/260710_provider_hardening/002_research_cn.md.
@@ -583,8 +611,41 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
583
611
  },
584
612
  { id: "opencode-zen", label: "opencode zen", baseUrl: "https://opencode.ai/zen/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://opencode.ai/auth" },
585
613
  { id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" },
614
+ {
615
+ id: "opencode-free",
616
+ label: "OpenCode Free",
617
+ adapter: "openai-chat",
618
+ baseUrl: "https://opencode.ai/zen/v1",
619
+ authKind: "key",
620
+ keyOptional: true,
621
+ featured: true,
622
+ liveModels: true,
623
+ note: "No key needed — public desktop tier. OpenCode currently advertises about 200 Big Pickle/free-model requests per 5 hours. Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.",
624
+ dashboardUrl: "https://opencode.ai",
625
+ staticHeaders: {
626
+ "x-opencode-client": "desktop",
627
+ },
628
+ modelReasoningEfforts: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, DEEPSEEK_THINKING_EFFORTS])),
629
+ modelReasoningEffortMap: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, DEEPSEEK_THINKING_REASONING_MAP])),
630
+ preserveReasoningContentModels: OPENCODE_FREE_DEEPSEEK_MODELS,
631
+ noVisionModels: OPENCODE_FREE_DEEPSEEK_MODELS,
632
+ },
586
633
  { id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.5-pro" },
587
634
  { id: "kilo", label: "Kilo", baseUrl: "https://api.kilo.ai/api/gateway", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://kilo.ai" },
635
+ {
636
+ id: "mimo-free",
637
+ label: "MiMo Free",
638
+ adapter: "mimo-free",
639
+ baseUrl: "https://api.xiaomimimo.com/api/free-ai/openai/chat",
640
+ authKind: "key",
641
+ keyOptional: true,
642
+ featured: true,
643
+ liveModels: true,
644
+ dashboardUrl: "https://xiaomimimo.com",
645
+ defaultModel: "mimo-auto",
646
+ models: ["mimo-auto"],
647
+ note: "No key needed — uses Xiaomi MiMo's free public tier (limited-time offer). A JWT is bootstrapped automatically with an anonymous random client id stored locally. The endpoint contract mirrors the official MiMoCode client and is not publicly documented — Xiaomi may change or restrict it at any time. Prompts may be processed/retained by Xiaomi; do not send confidential material.",
648
+ },
588
649
  { id: "cloudflare-ai-gateway", label: "Cloudflare AI Gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/ai-gateway" },
589
650
  // FREEZE 2026-07-10: /models is auth-gated, so ids remain unverified. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md.
590
651
  { id: "github-copilot", label: "GitHub Copilot", baseUrl: "https://api.githubcopilot.com", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://github.com/settings/copilot" },
@@ -3,6 +3,7 @@ import { createAzureAdapter } from "../adapters/azure";
3
3
  import { createCursorAdapter } from "../adapters/cursor";
4
4
  import { createGoogleAdapter } from "../adapters/google";
5
5
  import { createKiroAdapter } from "../adapters/kiro";
6
+ import { createMimoFreeAdapter } from "../adapters/mimo-free";
6
7
  import { createOpenAIChatAdapter } from "../adapters/openai-chat";
7
8
  import { createResponsesPassthroughAdapter } from "../adapters/openai-responses";
8
9
  import type { OcxProviderConfig } from "../types";
@@ -40,6 +41,8 @@ export function resolveAdapter(providerConfig: OcxProviderConfig, cacheRetention
40
41
  return createAzureAdapter(providerConfig);
41
42
  case "cursor":
42
43
  return createCursorAdapter(providerConfig);
44
+ case "mimo-free":
45
+ return createMimoFreeAdapter(providerConfig);
43
46
  default:
44
47
  throw new Error(`Unknown adapter: ${providerConfig.adapter}`);
45
48
  }
@@ -6,6 +6,7 @@ import {
6
6
  providerHeadersConfigError,
7
7
  } from "../config";
8
8
  import { providerDestinationConfigError } from "../lib/destination-policy";
9
+ import { getProviderRegistryEntry } from "../providers/registry";
9
10
  import type { OcxConfig, OcxProviderConfig } from "../types";
10
11
 
11
12
  let _corsOrigin = "http://localhost:10100";
@@ -210,6 +211,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
210
211
  "disabled",
211
212
  "allowPrivateNetwork",
212
213
  "authMode",
214
+ "keyOptional",
213
215
  "liveModels",
214
216
  "models",
215
217
  "contextWindow",
@@ -227,6 +229,8 @@ export function safeConfigDTO(config: OcxConfig): unknown {
227
229
  ] as const) {
228
230
  copyIfDefined(dto, provider, key);
229
231
  }
232
+ const registryNote = getProviderRegistryEntry(name)?.note;
233
+ if (typeof registryNote === "string" && registryNote.trim()) dto.note = registryNote;
230
234
  providers[name] = dto;
231
235
  }
232
236
  return {
@@ -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
+ }
@@ -16,6 +16,7 @@ import {
16
16
  isOAuthProvider,
17
17
  listOAuthProviders,
18
18
  startLoginFlow,
19
+ submitManualLoginCode,
19
20
  upsertOAuthProvider,
20
21
  } from "../oauth";
21
22
  import { removeCredential } from "../oauth/store";
@@ -28,6 +29,7 @@ import { readUsageEntries } from "../usage/log";
28
29
  import { getUsageDebugLogEntries } from "../usage/debug";
29
30
  import { parseRange, summarizeUsage } from "../usage/summary";
30
31
  import { stripCodexRuntimeProviderFields } from "../codex/auth-context";
32
+ import { getProviderRegistryEntry } from "../providers/registry";
31
33
  import { getDebugLogEntries } from "../lib/debug-log-buffer";
32
34
  import { getInjectionDebugLogEntries } from "../lib/injection-debug-log";
33
35
  import {
@@ -273,6 +275,33 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
273
275
  });
274
276
  }
275
277
 
278
+ if (url.pathname === "/api/shadow-call-settings" && req.method === "GET") {
279
+ const sci = config.shadowCallIntercept ?? {};
280
+ return jsonResponse({ enabled: sci.enabled === true, model: sci.model ?? "" });
281
+ }
282
+
283
+ if (url.pathname === "/api/shadow-call-settings" && req.method === "PUT") {
284
+ let raw: unknown;
285
+ try { raw = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
286
+ if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400);
287
+ const body = raw as { enabled?: unknown; model?: unknown };
288
+ if (body.enabled !== undefined && typeof body.enabled !== "boolean") {
289
+ return jsonResponse({ error: "enabled must be a boolean" }, 400);
290
+ }
291
+ if (body.model !== undefined && typeof body.model !== "string") {
292
+ return jsonResponse({ error: "model must be a string" }, 400);
293
+ }
294
+ config.shadowCallIntercept = { ...config.shadowCallIntercept };
295
+ if (typeof body.enabled === "boolean") config.shadowCallIntercept.enabled = body.enabled;
296
+ if (typeof body.model === "string") {
297
+ if (body.model === "") delete config.shadowCallIntercept.model;
298
+ else config.shadowCallIntercept.model = body.model;
299
+ }
300
+ saveConfig(config);
301
+ const sci = config.shadowCallIntercept;
302
+ return jsonResponse({ ok: true, enabled: sci.enabled === true, model: sci.model ?? "" });
303
+ }
304
+
276
305
  if (url.pathname === "/api/logs" && req.method === "GET") {
277
306
  return jsonResponse(filterRequestLogs(getRequestLogEntries(), url.searchParams));
278
307
  }
@@ -403,7 +432,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
403
432
  // let the (possibly new) apiKey join the pool as the active entry.
404
433
  const existingPool = config.providers[name]?.apiKeyPool;
405
434
  if (existingPool && !prov.apiKeyPool) prov.apiKeyPool = existingPool;
406
- config.providers[name] = prov;
435
+ config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov);
407
436
  if (body.setDefault) config.defaultProvider = name;
408
437
  save(config);
409
438
  if (prov.apiKey && prov.apiKeyPool) {
@@ -1016,6 +1045,21 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1016
1045
  }
1017
1046
  }
1018
1047
 
1048
+ // Manual fallback for browser OAuth: paste the final redirect URL (or authorization code)
1049
+ // when the browser cannot reach the loopback callback (remote/SSH/blocked localhost).
1050
+ if (url.pathname === "/api/oauth/login/code" && req.method === "POST") {
1051
+ const body = await req.json().catch(() => ({})) as { provider?: string; input?: string; code?: string };
1052
+ const provider = (body.provider ?? "").trim().toLowerCase();
1053
+ if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
1054
+ const input = typeof body.input === "string" ? body.input : typeof body.code === "string" ? body.code : "";
1055
+ // Authorization responses are measured in hundreds of bytes; never accept the
1056
+ // generic management-body allowance here.
1057
+ if (input.length > 4096) return jsonResponse({ error: "input too long" }, 400);
1058
+ const result = submitManualLoginCode(provider, input);
1059
+ if (!result.ok) return jsonResponse({ error: result.error }, 409);
1060
+ return jsonResponse({ ok: true });
1061
+ }
1062
+
1019
1063
  if (url.pathname === "/api/oauth/status" && req.method === "GET") {
1020
1064
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
1021
1065
  return jsonResponse(getLoginStatus(provider));
@@ -1180,3 +1224,15 @@ export async function fetchAllModels(config: OcxConfig): Promise<CatalogModel[]>
1180
1224
  const { gatherRoutedModels } = await import("../codex/catalog");
1181
1225
  return gatherRoutedModels(config);
1182
1226
  }
1227
+
1228
+ function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProviderConfig): OcxProviderConfig {
1229
+ const entry = getProviderRegistryEntry(name);
1230
+ if (!entry?.staticHeaders || !provider.headers) return provider;
1231
+ const headerEntries = Object.entries(provider.headers);
1232
+ const staticEntries = Object.entries(entry.staticHeaders);
1233
+ if (headerEntries.length !== staticEntries.length) return provider;
1234
+ const matchesRegistryStaticHeaders = staticEntries.every(([key, value]) => provider.headers?.[key] === value);
1235
+ if (!matchesRegistryStaticHeaders) return provider;
1236
+ const { headers: _headers, ...rest } = provider;
1237
+ return rest;
1238
+ }
@@ -212,10 +212,14 @@ export function responseWithDeferredRequestLog(
212
212
  return response;
213
213
  }
214
214
  if (!response.body || !contentType.includes("text/event-stream")) {
215
- if (response.body && contentType.includes("application/json")) {
215
+ if (response.body && (contentType.includes("application/json") || response.status >= 400)) {
216
216
  const finalizeJsonLog = async () => {
217
217
  const text = await response.text();
218
- inspectResponseLogJson(logCtx, text);
218
+ // Non-JSON error bodies: inspect/log only a bounded prefix (the stored
219
+ // upstreamError is 500 chars anyway); the FULL text is still forwarded to the
220
+ // client below, unchanged. JSON bodies keep full inspection (usage parsing).
221
+ const isJson = contentType.includes("application/json");
222
+ inspectResponseLogJson(logCtx, isJson ? text : text.slice(0, 8192));
219
223
  addFinalRequestLog(requestId, start, logCtx, response.status, { closeReason: "non_stream" }, addLog);
220
224
  return text;
221
225
  };
@@ -294,7 +294,10 @@ function captureUpstreamError(logCtx: RequestLogContext, text: string | null): v
294
294
  logCtx.upstreamError = redactSecretString(incompleteReasonLabel(reason.trim())).slice(0, 500);
295
295
  }
296
296
  } catch {
297
- /* not JSON; nothing to capture */
297
+ const trimmed = text.trim();
298
+ if (trimmed) {
299
+ logCtx.upstreamError = redactSecretString(trimmed).slice(0, 500);
300
+ }
298
301
  }
299
302
  }
300
303
 
@@ -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";
@@ -466,6 +467,22 @@ export async function handleResponses(
466
467
  logCtx.configuredServiceTier = readConfiguredCodexServiceTier();
467
468
  logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier);
468
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
+
469
486
  let route;
470
487
  try {
471
488
  route = routeModel(config, parsed.modelId);
@@ -941,29 +958,22 @@ export async function handleResponses(
941
958
  }
942
959
 
943
960
  if (!upstreamResponse.ok) {
944
- // Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the SAME
945
- // request once per remaining key. OAuth/forward providers and single-key pools return null
946
- // immediately, so this stays a no-op for them (src/providers/key-failover.ts).
947
- while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) {
948
- const rotated = rotateProviderTransportOn429(config, route.providerName, {
949
- retryAfter: upstreamResponse.headers.get("retry-after"),
950
- now: Date.now(),
951
- attemptedKey: route.provider.apiKey,
952
- 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 } : {}),
953
973
  });
954
- if (!rotated) break;
955
- // Release the failed response's socket before retrying; unread bodies otherwise linger
956
- // until runtime cleanup (one per rotated key under a rate-limit storm).
957
- try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
958
- route.provider = rotated;
959
- const retryAdapter = resolveAdapter(
960
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
961
- config.cacheRetention,
962
- );
963
- const retryRequest = await retryAdapter.buildRequest(parsed, { headers: selectedForwardHeaders });
964
974
  try {
965
- upstreamResponse = retryAdapter.fetchResponse
966
- ? await retryAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream })
975
+ return activeAdapter.fetchResponse
976
+ ? await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream })
967
977
  : await fetchWithHeaderTimeout(retryRequest.url, {
968
978
  method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
969
979
  }, upstream.signal, connectMs, parsed.stream);
@@ -973,8 +983,50 @@ export async function handleResponses(
973
983
  const msg = err instanceof Error && err.name === "TimeoutError"
974
984
  ? `Provider connect timeout after ${connectMs}ms`
975
985
  : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
976
- 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;
977
1028
  }
1029
+ break;
978
1030
  }
979
1031
  if (!upstreamResponse.ok) {
980
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,