@bitkyc08/opencodex 2.13.0 → 2.14.0

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 (55) hide show
  1. package/gui/dist/assets/index-Co12XTT-.js +76 -0
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/cursor/discovery.ts +4 -1
  5. package/src/adapters/cursor/effort-map.ts +5 -1
  6. package/src/adapters/cursor/request-builder.ts +3 -3
  7. package/src/adapters/google.ts +25 -5
  8. package/src/adapters/openai-chat.ts +182 -6
  9. package/src/adapters/openai-responses.ts +17 -7
  10. package/src/codex/catalog/bundled.ts +16 -0
  11. package/src/codex/catalog/metadata.ts +180 -5
  12. package/src/codex/catalog/parsing.ts +7 -6
  13. package/src/codex/catalog/sync.ts +73 -6
  14. package/src/codex/catalog.ts +1 -1
  15. package/src/codex/convergence.ts +20 -0
  16. package/src/codex/prompt-journal.ts +50 -13
  17. package/src/codex/prompt-layers.ts +1 -1
  18. package/src/config.ts +57 -0
  19. package/src/generated/compatibility-version.json +64 -48
  20. package/src/generated/model-metadata.ts +3 -3
  21. package/src/lib/local-provider-reload-contract.ts +100 -0
  22. package/src/oauth/login-cli.ts +52 -26
  23. package/src/providers/derive.ts +2 -0
  24. package/src/providers/openai-sidecar.ts +9 -2
  25. package/src/providers/quota.ts +57 -0
  26. package/src/providers/registry.ts +53 -25
  27. package/src/responses/state.ts +22 -0
  28. package/src/router.ts +1 -0
  29. package/src/server/claude-messages.ts +57 -11
  30. package/src/server/direct-local-http.ts +7 -3
  31. package/src/server/images.ts +6 -0
  32. package/src/server/index.ts +51 -11
  33. package/src/server/live.ts +117 -13
  34. package/src/server/local-provider-reload-client.ts +137 -0
  35. package/src/server/management/config-routes.ts +20 -3
  36. package/src/server/management/logs-usage-routes.ts +28 -0
  37. package/src/server/management/model-routes.ts +11 -3
  38. package/src/server/management/model-rows.ts +18 -3
  39. package/src/server/management/provider-routes.ts +107 -3
  40. package/src/server/management-auth.ts +65 -1
  41. package/src/server/proxy-liveness.ts +1 -0
  42. package/src/server/responses/agent-task-recovery-cache.ts +143 -0
  43. package/src/server/responses/agent-task-recovery.ts +460 -0
  44. package/src/server/responses/compact.ts +4 -2
  45. package/src/server/responses/core.ts +142 -6
  46. package/src/server/responses/encrypted-payload.ts +4 -1
  47. package/src/server/search.ts +4 -0
  48. package/src/types.ts +27 -0
  49. package/src/usage/expected-prices.ts +11 -0
  50. package/src/vision/describe.ts +4 -0
  51. package/src/web-search/anthropic-executor.ts +5 -1
  52. package/src/web-search/executor.ts +9 -1
  53. package/src/web-search/index.ts +5 -0
  54. package/src/web-search/loop.ts +42 -5
  55. package/gui/dist/assets/index-BHldBl6_.js +0 -76
@@ -237,6 +237,12 @@ export interface ProviderRegistryEntry {
237
237
  parallelToolCalls?: boolean;
238
238
  /** Opt this provider into forwarding prompt_cache_key (OpenAI-specific; strict backends reject it). */
239
239
  promptCacheKey?: boolean;
240
+ /**
241
+ * Opt-in: forward `service_tier` on the `/chat/completions` wire. Same hazard as
242
+ * `promptCacheKey` — an OpenAI-specific extension that strict gateways reject. Distinct from
243
+ * `supportsServiceTier`, which governs the Responses wire.
244
+ */
245
+ chatServiceTier?: boolean;
240
246
  autoToolChoiceOnlyModels?: string[];
241
247
  preserveReasoningContentModels?: string[];
242
248
  requiresReasoningPlaceholderModels?: string[];
@@ -427,38 +433,35 @@ const OPENCODE_ZEN_TEXT_ONLY_MODELS = [
427
433
  "deepseek-v4-flash-free",
428
434
  ];
429
435
  /*
430
- * DeepSeek's Codex ladder is low/high/max, and the two V4 models resolve it
431
- * DIFFERENTLY. From the official thinking-mode table (api-docs.deepseek.com,
432
- * EN and zh-cn agree, re-verified 2026-08-06):
436
+ * DeepSeek's Codex ladder is low/high/max. With the V4 Pro GA release
437
+ * (DeepSeek-V4-Pro-0813) the official thinking-mode table is IDENTICAL for both
438
+ * V4 models (api-docs.deepseek.com/guides/thinking_mode, verified 2026-08-13):
433
439
  *
434
440
  * requested | v4-flash | v4-pro
435
- * low | low | high
441
+ * low | low | low
442
+ * medium | high | high
436
443
  * high | high | high
437
- * xhigh | high | max
444
+ * xhigh | high | high
438
445
  * max | max | max
439
446
  *
440
- * Two consequences (#1057):
447
+ * Before GA, Pro silently upgraded low->high and mapped xhigh->max (#1057-era
448
+ * table); the page's footnote about an early-August Pro mapping update landed
449
+ * with this GA, so Pro now advertises the same three real tiers as Flash.
450
+ *
451
+ * Two standing notes (#1057):
441
452
  *
442
453
  * - `xhigh` is a COMPATIBILITY ALIAS, not a native tier. It stays in the wire maps
443
454
  * so existing requests and saved configs keep working, but it is not advertised.
444
- * - Pro does NOT honor `low` the vendor silently upgrades it to `high`. So Pro
445
- * advertises only the two levels it actually distinguishes. Advertising `low`
446
- * there would put a tier in the picker that costs `high`, which is the same
447
- * defect this fixes wearing a different value.
448
- *
449
- * The vendor page footnotes that Pro's mapping updates in early August 2026; as of
450
- * the re-verification above it had not changed. When it does, Pro gains `low` here.
451
- *
452
- * `medium` has no row in the vendor table — mapping it to `high` is OUR
453
- * compatibility choice for clients that only speak the OpenAI ladder.
455
+ * - `medium` has no row in the vendor table mapping it to `high` is OUR
456
+ * compatibility choice for clients that only speak the OpenAI ladder.
454
457
  */
455
458
  const DEEPSEEK_FLASH_THINKING_EFFORTS = ["low", "high", "max"];
456
- const DEEPSEEK_PRO_THINKING_EFFORTS = ["high", "max"];
459
+ const DEEPSEEK_PRO_THINKING_EFFORTS = ["low", "high", "max"];
457
460
  const DEEPSEEK_PRO_REASONING_MAP: Record<string, string> = {
458
- low: "high",
461
+ low: "low",
459
462
  medium: "high",
460
463
  high: "high",
461
- xhigh: "max",
464
+ xhigh: "high",
462
465
  max: "max",
463
466
  };
464
467
  const DEEPSEEK_FLASH_REASONING_MAP: Record<string, string> = {
@@ -947,7 +950,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
947
950
  // devlog/model_update/260709_model_refresh/001_xai_lineup.md.
948
951
  // grok-4.20-multi-agent-0309 is intentionally absent: the OAuth chat-completions
949
952
  // transport returns 400 ("Multi Agent requests are not allowed on chat completions").
950
- models: ["grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
953
+ // 260813: grok-4.6 added per the new docs.x.ai/developers/grok-4-6 page; specs mirrored
954
+ // from grok-4.5 until the official capability/pricing tables settle.
955
+ models: ["grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
951
956
  defaultModel: "grok-4.5",
952
957
  // Vision lineup per docs.x.ai model-capabilities/images/understanding: the grok-4.x chat
953
958
  // models accept image input (JPEG/PNG, URL or base64). Without this the catalog leaves
@@ -956,6 +961,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
956
961
  // the app blocks attachments client-side. grok-build-0.1 / grok-composer-2.5-fast stay out
957
962
  // (they are already listed in noVisionModels below).
958
963
  modelInputModalities: {
964
+ "grok-4.6": ["text", "image"],
959
965
  "grok-4.5": ["text", "image"],
960
966
  "grok-4.3": ["text", "image"],
961
967
  "grok-4.20-0309-reasoning": ["text", "image"],
@@ -966,10 +972,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
966
972
  // reasoning_content as the top cause of prompt-cache misses on multi-turn conversations
967
973
  // (docs.x.ai prompt-caching/multi-turn, verified 2026-07-13 — devlog/_plan/260713_grok_caching).
968
974
  // Models that never emit reasoning simply have no thinking parts to replay (no-op).
969
- preserveReasoningContentModels: ["grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning"],
975
+ preserveReasoningContentModels: ["grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning"],
970
976
  // grok-4.5 reasoning is always-on with low/medium/high control (no off tier upstream).
971
- modelReasoningEfforts: { "grok-4.5": ["low", "medium", "high"] },
977
+ modelReasoningEfforts: { "grok-4.6": ["low", "medium", "high"], "grok-4.5": ["low", "medium", "high"] },
972
978
  modelContextWindows: {
979
+ "grok-4.6": 500_000,
973
980
  "grok-4.5": 500_000,
974
981
  "grok-4.3": 1_000_000,
975
982
  "grok-4.20-0309-reasoning": 1_000_000,
@@ -1414,8 +1421,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1414
1421
  // Official DeepSeek Codex setup (codex-deepseek-setup.sh) advertises 1,048,576
1415
1422
  // for both V4 models; the older 1,000,000 figure was a rounded approximation.
1416
1423
  modelContextWindows: { "deepseek-v4-flash": 1_048_576, "deepseek-v4-pro": 1_048_576 },
1417
- // DeepSeek documents V4-Flash as a native Responses API model adapted for Codex. The
1418
- // API id is `deepseek-v4-flash`; `DeepSeek-V4-Flash-0731` is a release/version label.
1424
+ // DeepSeek documents both V4 models as native Responses API models adapted for Codex
1425
+ // (model table marks Responses API for flash and pro; the /responses reference lists
1426
+ // both ids as accepted `model` values — verified 2026-08-13 with the V4 Pro GA,
1427
+ // version label DeepSeek-V4-Pro-0813).
1419
1428
  modelWireDefaults: {
1420
1429
  // Codex speaks Responses natively and DeepSeek ships a Codex-compatible
1421
1430
  // apply_patch tool on that wire, so a Responses inbound goes straight out with
@@ -1424,6 +1433,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1424
1433
  // translating them into Responses would add a hop onto our newest upstream path
1425
1434
  // for no gain.
1426
1435
  "deepseek-v4-flash": { wire: "openai-responses", inbound: ["responses"] },
1436
+ "deepseek-v4-pro": { wire: "openai-responses", inbound: ["responses"] },
1427
1437
  },
1428
1438
  // The #875-era bounded-JSON force (`modelResponsesUpstreamStreaming`) is retired
1429
1439
  // for this entry: the official guide documents a `response.completed` /
@@ -1438,7 +1448,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1438
1448
  // devlog/_plan/260807_deepseek_responses_streaming/000_plan.md.
1439
1449
  // Current official streams normally carry a real terminal; retain a narrow grace
1440
1450
  // repair for the historical shape that closes after a complete graph without one.
1441
- modelResponsesTerminalRepair: { "deepseek-v4-flash": { graceMs: 5_000 } },
1451
+ modelResponsesTerminalRepair: { "deepseek-v4-flash": { graceMs: 5_000 }, "deepseek-v4-pro": { graceMs: 5_000 } },
1442
1452
  // DeepSeek's Responses route emits bare UUID item ids, which leave Codex
1443
1453
  // clients stuck on an uncommitted turn (#938). Client-facing only — raw
1444
1454
  // continuation snapshots keep the upstream ids.
@@ -2313,6 +2323,24 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2313
2323
  noVisionModels: OPENCODE_ZEN_TEXT_ONLY_MODELS,
2314
2324
  },
2315
2325
  { id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.5-pro" },
2326
+ // Xiaomi's public OpenAI-compatible endpoint is a distinct transport from both the Anthropic
2327
+ // preset above and the paid token-plan host below. Keep a separate fixed-destination contract
2328
+ // so existing custom providers are never retargeted while the official route receives the
2329
+ // strict reasoning ladder its validator enforces (#1483).
2330
+ {
2331
+ id: "xiaomi-mimo",
2332
+ label: "Xiaomi MiMo (OpenAI Chat)",
2333
+ baseUrl: "https://api.xiaomimimo.com/v1",
2334
+ adapter: "openai-chat",
2335
+ authKind: "key",
2336
+ dashboardUrl: "https://platform.xiaomimimo.com/console/balance",
2337
+ defaultModel: "mimo-v2.5",
2338
+ models: ["mimo-v2.5"],
2339
+ reasoningEfforts: ["low", "medium", "high"],
2340
+ reasoningEffortMap: { xhigh: "high", max: "high", ultra: "high" },
2341
+ preserveCustomDestination: true,
2342
+ note: "Official Xiaomi MiMo OpenAI-compatible Chat endpoint. The upstream validator accepts reasoning_effort none/low/medium/high; higher Codex tiers are clamped to high.",
2343
+ },
2316
2344
  { id: "kilo", label: "Kilo", baseUrl: "https://api.kilo.ai/api/gateway", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://kilo.ai" },
2317
2345
  {
2318
2346
  id: "mimo-free",
@@ -947,6 +947,27 @@ export function responseStateMetrics(): ResponseStateMetrics {
947
947
  * Cache completed output and max_output_tokens partial output for previous_response_id replay.
948
948
  * Content-filtered incomplete and failed output are not authoritative replay history.
949
949
  */
950
+ /**
951
+ * Request bodies that must never enter the continuation cache.
952
+ *
953
+ * The cache is persisted to `responses-state.json`, so anything recorded here reaches disk.
954
+ * Encrypted-agent-task recovery decrypts task text into the request body and promises
955
+ * in-memory, TTL-bounded retention; recording that body would put the plaintext on disk with
956
+ * no TTL and break the promise.
957
+ *
958
+ * A WeakSet rather than a body field on purpose: `_rawBody` is serialized verbatim by the
959
+ * native passthrough, so any marker written into the body itself would be sent upstream.
960
+ * Marking is enforced once here rather than at each call site, because every recording path
961
+ * (streaming, non-streaming, passthrough, forced) funnels through `rememberResponseState` —
962
+ * a new call site cannot reintroduce the leak by forgetting a guard.
963
+ */
964
+ const nonPersistableBodies = new WeakSet<object>();
965
+
966
+ /** Bar this exact request body from the continuation cache, and therefore from disk. */
967
+ export function markBodyNonPersistable(body: unknown): void {
968
+ if (body && typeof body === "object") nonPersistableBodies.add(body as object);
969
+ }
970
+
950
971
  export function rememberResponseState(
951
972
  requestBody: unknown,
952
973
  response: { id?: unknown; output?: unknown; status?: unknown; incomplete_details?: unknown },
@@ -955,6 +976,7 @@ export function rememberResponseState(
955
976
  ): void {
956
977
  if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) return;
957
978
  const request = requestBody as Record<string, unknown>;
979
+ if (nonPersistableBodies.has(request)) return;
958
980
  // `force` bypasses only the store:false skip: Codex sends `store:false` on every non-Azure
959
981
  // HTTP request (and WS inherits it), yet its WS turns still chain with previous_response_id.
960
982
  // The passthrough branch records with force so those chains can be expanded locally; the
package/src/router.ts CHANGED
@@ -350,6 +350,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
350
350
  // opt-in, while an explicit user `false` keeps overriding registry `true`.
351
351
  ...(provider.parallelToolCalls === undefined && registryEntry.parallelToolCalls !== undefined ? { parallelToolCalls: registryEntry.parallelToolCalls } : {}),
352
352
  ...(provider.promptCacheKey === undefined && registryEntry.promptCacheKey !== undefined ? { promptCacheKey: registryEntry.promptCacheKey } : {}),
353
+ ...(provider.chatServiceTier === undefined && registryEntry.chatServiceTier !== undefined ? { chatServiceTier: registryEntry.chatServiceTier } : {}),
353
354
  ...(provider.reasoningWireFormat === undefined && registryEntry.reasoningWireFormat !== undefined
354
355
  ? { reasoningWireFormat: registryEntry.reasoningWireFormat }
355
356
  : {}),
@@ -35,6 +35,12 @@ import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput,
35
35
  import { conversationIdFromClaudeMetadata } from "./request-log-conversation";
36
36
  import { responseWithDeferredRequestLog } from "./relay";
37
37
  import { handleResponses } from "./responses";
38
+ import {
39
+ isApiAuthRequired,
40
+ isDataPlaneAdmissionSecret,
41
+ isProxyAdmissionSecret,
42
+ type RequestPolicyView,
43
+ } from "./auth-cors";
38
44
  import type { AdmissionLease } from "../lib/admission";
39
45
  import { tryClaimNativeMainProfileForTurn } from "../codex/native-main-admission";
40
46
  import { CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE } from "../codex/auth-context";
@@ -96,20 +102,54 @@ const PASSTHROUGH_STRIP_HEADERS = new Set([
96
102
  "accept-encoding", "x-opencodex-api-key", "origin",
97
103
  ]);
98
104
 
99
- function hasAnthropicNativeCredential(req: Request): boolean {
100
- const bearer = req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim() ?? "";
101
- const apiKey = req.headers.get("x-api-key")?.trim() ?? "";
102
- return bearer.startsWith("sk-ant-") || apiKey.startsWith("sk-ant-");
105
+ function singleCredentialToken(name: "authorization" | "x-api-key", value: string | null): string | null {
106
+ const raw = value?.trim() ?? "";
107
+ // Fetch Headers comma-joins duplicate fields. Neither Anthropic credential format permits a
108
+ // comma, so treating a joined value as one token could hide an admission secret behind a real
109
+ // provider credential. Ambiguous credential headers fail closed.
110
+ if (!raw || raw.includes(",")) return null;
111
+ if (name === "authorization") {
112
+ const match = /^Bearer\s+(.+)$/i.exec(raw);
113
+ return match?.[1]?.trim() || null;
114
+ }
115
+ return raw;
116
+ }
117
+
118
+ function hasAnthropicNativeCredential(req: Request, config: OcxConfig): boolean {
119
+ const bearer = singleCredentialToken("authorization", req.headers.get("authorization"));
120
+ const apiKey = singleCredentialToken("x-api-key", req.headers.get("x-api-key"));
121
+ return (!!bearer && bearer.startsWith("sk-ant-") && !isProxyAdmissionSecret(bearer, config))
122
+ || (!!apiKey && apiKey.startsWith("sk-ant-") && !isProxyAdmissionSecret(apiKey, config));
103
123
  }
104
124
 
105
- function wantsNativePassthrough(req: Request, config: OcxConfig, model: unknown): model is string {
125
+ function wantsNativePassthrough(
126
+ req: Request,
127
+ config: OcxConfig,
128
+ requestPolicy: RequestPolicyView,
129
+ model: unknown,
130
+ ): model is string {
106
131
  if (config.claudeCode?.nativePassthrough === false) return false;
107
132
  if (typeof model !== "string" || !/^(claude|anthropic)/i.test(model)) return false;
108
- if (!hasAnthropicNativeCredential(req)) return false;
133
+ // Authorization and x-api-key both belong to the upstream on this branch. An exposed listener
134
+ // therefore requires the dedicated admission header even though the routed Messages surface
135
+ // keeps accepting all three legacy admission forms.
136
+ if (isApiAuthRequired(requestPolicy)) {
137
+ const dedicated = req.headers.get("x-opencodex-api-key")?.trim() ?? "";
138
+ if (!isDataPlaneAdmissionSecret(dedicated, config)) return false;
139
+ }
140
+ if (!hasAnthropicNativeCredential(req, config)) return false;
109
141
  // An alias or modelMap hit means the user asked for a ROUTED model: translate instead.
110
142
  return resolveInboundModel(model, config.claudeCode) === model;
111
143
  }
112
144
 
145
+ function shouldForwardNativeHeader(name: string, value: string, config: OcxConfig): boolean {
146
+ const lowerName = name.toLowerCase();
147
+ if (PASSTHROUGH_STRIP_HEADERS.has(lowerName)) return false;
148
+ if (lowerName !== "authorization" && lowerName !== "x-api-key") return true;
149
+ const token = singleCredentialToken(lowerName, value);
150
+ return !!token && !isProxyAdmissionSecret(token, config);
151
+ }
152
+
113
153
  /** Format a 32-hex cache key as a uuid-shaped session id (version/variant nibbles forced). */
114
154
  function uuidFromHex(hex32: string): string {
115
155
  const h = (hex32 + "0".repeat(32)).slice(0, 32);
@@ -330,7 +370,7 @@ async function anthropicNativePassthrough(
330
370
  }
331
371
  const headers = new Headers();
332
372
  req.headers.forEach((value, name) => {
333
- if (!PASSTHROUGH_STRIP_HEADERS.has(name.toLowerCase())) headers.set(name, value);
373
+ if (shouldForwardNativeHeader(name, value, config)) headers.set(name, value);
334
374
  });
335
375
  headers.set("content-type", "application/json");
336
376
 
@@ -528,11 +568,12 @@ export async function handleClaudeMessages(
528
568
  config: OcxConfig,
529
569
  logCtx: RequestLogContext,
530
570
  logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease },
571
+ requestPolicy: RequestPolicyView = config,
531
572
  ): Promise<Response> {
532
573
  const translatorBudget = createTranslatorBudget();
533
574
  try {
534
575
  return finalizeTranslatorBudgetResponse(
535
- await handleClaudeMessagesWithBudget(req, config, logCtx, translatorBudget, logIds),
576
+ await handleClaudeMessagesWithBudget(req, config, logCtx, translatorBudget, logIds, requestPolicy),
536
577
  translatorBudget,
537
578
  );
538
579
  } catch (error) {
@@ -547,6 +588,7 @@ async function handleClaudeMessagesWithBudget(
547
588
  logCtx: RequestLogContext,
548
589
  translatorBudget: TranslatorBudget,
549
590
  logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease },
591
+ requestPolicy: RequestPolicyView = config,
550
592
  ): Promise<Response> {
551
593
  logCtx.surface = "claude";
552
594
  const disabled = claudeInboundDisabled(config);
@@ -601,7 +643,7 @@ async function handleClaudeMessagesWithBudget(
601
643
  );
602
644
  if (claudeConversationId) logCtx.conversationId = claudeConversationId;
603
645
  }
604
- if (isRec(anthropicBody) && wantsNativePassthrough(req, config, anthropicBody.model)) {
646
+ if (isRec(anthropicBody) && wantsNativePassthrough(req, config, requestPolicy, anthropicBody.model)) {
605
647
  return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages");
606
648
  }
607
649
  if (isRec(anthropicBody) && effortOverride) {
@@ -940,7 +982,11 @@ export function estimateClaudeRequestTokens(
940
982
  return Math.max(1, estimateTokens(parts.join("\n"), modelId) + attachmentTokens);
941
983
  }
942
984
 
943
- export async function handleClaudeCountTokens(req: Request, config: OcxConfig): Promise<Response> {
985
+ export async function handleClaudeCountTokens(
986
+ req: Request,
987
+ config: OcxConfig,
988
+ requestPolicy: RequestPolicyView = config,
989
+ ): Promise<Response> {
944
990
  const disabled = claudeInboundDisabled(config);
945
991
  if (disabled) return disabled;
946
992
 
@@ -973,7 +1019,7 @@ export async function handleClaudeCountTokens(req: Request, config: OcxConfig):
973
1019
  raw.model = model;
974
1020
  }
975
1021
  captureClaudeInbound("count_tokens", raw, resolveInboundModel(model, config.claudeCode), req.headers.get("anthropic-beta") ?? undefined);
976
- if (wantsNativePassthrough(req, config, model)) {
1022
+ if (wantsNativePassthrough(req, config, requestPolicy, model)) {
977
1023
  return await anthropicNativePassthrough(req, config, { model, provider: "anthropic-native", surface: "claude" }, undefined, raw, "/v1/messages/count_tokens");
978
1024
  }
979
1025
  const inputTokens = estimateClaudeRequestTokens(raw, model);
@@ -219,7 +219,7 @@ function parseResponse(bytes: Buffer): Response {
219
219
  }
220
220
 
221
221
  /**
222
- * Fetch one local HTTP GET over a direct TCP connection.
222
+ * Fetch one bodyless local HTTP GET or POST over a direct TCP connection.
223
223
  *
224
224
  * Bun's global fetch and Bun 1.3's node:http compatibility layer can honor
225
225
  * HTTP(S)_PROXY. Local identity and capability probes must not expose headers
@@ -239,18 +239,22 @@ export async function directLocalHttpFetch(
239
239
 
240
240
  if (url.protocol !== "http:") throw new Error("direct local request must use HTTP");
241
241
  if (url.username || url.password) throw new Error("direct local request URL must not contain credentials");
242
- if (method !== "GET" || body !== null) throw new Error("direct local request must be a bodyless GET");
242
+ if ((method !== "GET" && method !== "POST") || body !== null) {
243
+ throw new Error("direct local request must be a bodyless GET or POST");
244
+ }
243
245
  if (signal?.aborted) throw abortReason(signal);
244
246
 
245
247
  const headers = new Headers(init.headers ?? (input instanceof Request ? input.headers : undefined));
246
248
  headers.delete("proxy-authorization");
247
249
  headers.delete("proxy-connection");
250
+ if (method === "POST") headers.set("content-length", "0");
251
+ else headers.delete("content-length");
248
252
  headers.set("host", url.host);
249
253
  headers.set("connection", "close");
250
254
  const headerLines: string[] = [];
251
255
  headers.forEach((value, key) => { headerLines.push(`${key}: ${value}`); });
252
256
  const requestBytes = Buffer.from(
253
- `GET ${url.pathname}${url.search} HTTP/1.1\r\n${headerLines.join("\r\n")}\r\n\r\n`,
257
+ `${method} ${url.pathname}${url.search} HTTP/1.1\r\n${headerLines.join("\r\n")}\r\n\r\n`,
254
258
  "latin1",
255
259
  );
256
260
  const parsedHostname = url.hostname.startsWith("[") && url.hostname.endsWith("]")
@@ -499,6 +499,12 @@ export async function handleImages(
499
499
  headers,
500
500
  body: JSON.stringify(body),
501
501
  signal: linkedSignal.signal,
502
+ // Do not follow a cross-origin 3xx while carrying Codex credentials. Bun strips
503
+ // `Authorization` across origins but forwards nonstandard headers, so
504
+ // `chatgpt-account-id`, `session_id`, and `x-codex-turn-metadata` would reach the
505
+ // redirect target. Verified with a two-server probe. The Responses path and native
506
+ // compact already set this; the credential-bearing sidecars did not.
507
+ redirect: "manual",
502
508
  });
503
509
  const observed = await readImageResponseBytes(upstreamResponse, {
504
510
  maxBytes: IMAGES_RESPONSE_MAX_BYTES,
@@ -195,6 +195,7 @@ import {
195
195
  createLocalAttestationSecret,
196
196
  } from "../lib/local-management-attestation";
197
197
  import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../lib/system-restart-contract";
198
+ import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../lib/local-provider-reload-contract";
198
199
  import { createReadinessGate, type ReadinessGate } from "./readiness";
199
200
 
200
201
  export const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
@@ -478,9 +479,19 @@ export function consumeStartupCacheInvalidationWrite(): boolean {
478
479
  return wrote;
479
480
  }
480
481
 
482
+ export function warnAgentTaskRecoveryStartup(config: {
483
+ agentTaskRecovery?: { enabled?: boolean };
484
+ }): void {
485
+ if (config.agentTaskRecovery?.enabled !== true) return;
486
+ console.warn("⚠️ Experimental encrypted V2 task recovery is enabled.");
487
+ console.warn(" A scoped cache miss may send an additional authenticated request to ChatGPT and may consume quota or add latency; concurrent misses can share one request.");
488
+ console.warn(" Recovered model output is retained only in a bounded in-memory cache; exact fidelity is not guaranteed and the path depends on undocumented backend behavior.");
489
+ }
490
+
481
491
  export function startServer(port?: number, deps: StartServerDeps = {}): Server<WsData> {
482
492
  const localAttestationSecret = deps.localAttestationSecret ?? createLocalAttestationSecret();
483
493
  const config = runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig()));
494
+ warnAgentTaskRecoveryStartup(config);
484
495
  setLiveStateStoreConfig(config);
485
496
  applyProxyEnv(config);
486
497
  assertServerAuthConfig(config);
@@ -617,6 +628,13 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
617
628
  }
618
629
  if (path === "/v1/responses/compact") return req.method === "POST";
619
630
  if (path === "/v1/models") return req.method === "GET";
631
+ // Standalone realtime voice sessions (codex-rs thread/realtime/start, WebSocket
632
+ // transport) — a directly-spawned `codex app-server` needs these for desktop
633
+ // voice the same way it needs /v1/responses. WebSocket upgrades only; plain
634
+ // HTTP on these paths stays rejected.
635
+ if (path === "/v1/realtime" || path === "/v1/live") {
636
+ return req.headers.get("upgrade")?.toLowerCase() === "websocket";
637
+ }
620
638
  return false;
621
639
  }
622
640
 
@@ -804,6 +822,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
804
822
  pid: process.pid,
805
823
  port: healthPort,
806
824
  restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION,
825
+ providerReloadCapability: LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION,
807
826
  }, 200, req, policy);
808
827
  const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER);
809
828
  if (challenge) {
@@ -887,10 +906,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
887
906
  }
888
907
  throw error;
889
908
  }
890
- const { applyNativeVisibility, buildCatalogEntries, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, NATIVE_OPENAI_MODELS, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog");
909
+ const { accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, buildCatalogEntries, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, NATIVE_OPENAI_MODELS, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog");
891
910
  const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
892
911
  const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config);
893
- const nativeSlugs = includeNativeOpenAi ? nativeOpenAiSlugs() : [];
912
+ const nativeSlugs = includeNativeOpenAi
913
+ ? nativeOpenAiSlugs()
914
+ : [];
894
915
  const disabledNatives = disabledNativeSlugs(config);
895
916
  const disabledModels = new Set(config.disabledModels ?? []);
896
917
  const shadowedNativeSlugs = configuredNativeAliasSlugs(config);
@@ -898,6 +919,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
898
919
  const accountSelectors = includeAccountBoundNativeOpenAi
899
920
  ? visibleCodexAccountSelectors(config)
900
921
  : [];
922
+ const accountNativeSlugsBySelector = includeAccountBoundNativeOpenAi
923
+ ? accountBoundNativeOpenAiSlugsBySelector(config)
924
+ : new Map<string, readonly string[]>();
925
+ const accountNativeSlugs = [...new Set(
926
+ [...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]),
927
+ )];
901
928
  const goEnabled = filterCatalogVisibleModels(goModels, config);
902
929
  const goOrdered = orderForSubagents(goEnabled, config.subagentModels);
903
930
  // Claude Code / Claude Desktop gateway model discovery (GET /v1/models with
@@ -945,7 +972,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
945
972
  // newly re-enabled native reappear under each selector before the next sync, while the
946
973
  // no-selector path keeps nativeOpenAiSlugs()'s existing visibility-sensitive behavior.
947
974
  const catalogNativeSlugs = accountSelectors.length > 0
948
- ? NATIVE_OPENAI_MODELS
975
+ ? [...new Set([...NATIVE_OPENAI_MODELS, ...accountNativeSlugs])]
949
976
  : nativeSlugs;
950
977
  const entries = buildCatalogEntries(
951
978
  loadCatalogTemplate(),
@@ -959,12 +986,15 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
959
986
  suppressedBareNativeSlugs,
960
987
  new Set(),
961
988
  providerContextCap(config, OPENAI_CODEX_PROVIDER_ID),
989
+ accountNativeSlugs,
990
+ accountNativeSlugsBySelector,
962
991
  );
963
992
  return jsonResponse({
964
993
  models: applyNativeVisibility(
965
994
  entries,
966
995
  disabledModels,
967
996
  accountSelectors.length > 0,
997
+ new Set(accountNativeSlugs),
968
998
  ),
969
999
  }, 200, req, policy);
970
1000
  }
@@ -1010,13 +1040,16 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1010
1040
  const selectorNativeSlugs = accountSelectors.length > 0
1011
1041
  ? NATIVE_OPENAI_MODELS.filter(slug => !disabledNatives.has(slug))
1012
1042
  : [];
1043
+ const bareSelectorNativeSlugs = accountSelectors.length > 0
1044
+ ? selectorNativeSlugs
1045
+ : [];
1013
1046
  const visibleNatives = includeNativeOpenAi
1014
1047
  ? accountSelectors.length > 0
1015
- ? selectorNativeSlugs.filter(slug => !shadowedNativeSlugs.has(slug))
1048
+ ? bareSelectorNativeSlugs.filter(slug => !shadowedNativeSlugs.has(slug))
1016
1049
  : visibleNativeSlugs(config)
1017
1050
  : [];
1018
1051
  const visibleAccountNatives = accountSelectors.flatMap(selector =>
1019
- selectorNativeSlugs.flatMap(metadataId => {
1052
+ (accountNativeSlugsBySelector.get(selector) ?? []).filter(metadataId => !disabledNatives.has(metadataId)).flatMap(metadataId => {
1020
1053
  const id = `${selector}/${metadataId}`;
1021
1054
  return disabledModels.has(id) ? [] : [{ id, metadataId }];
1022
1055
  })
@@ -1210,7 +1243,11 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1210
1243
  if (!isAllowedRequestOrigin(req, policy)) {
1211
1244
  return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, policy);
1212
1245
  }
1213
- return runAdmittedHttpTurn(req, policy, async () => withCors(await handleClaudeCountTokens(req, config), req, policy));
1246
+ return runAdmittedHttpTurn(req, policy, async () => withCors(
1247
+ await handleClaudeCountTokens(req, config, policy),
1248
+ req,
1249
+ policy,
1250
+ ));
1214
1251
  }
1215
1252
 
1216
1253
  if (url.pathname === "/v1/messages" && req.method === "POST") {
@@ -1237,9 +1274,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1237
1274
  // pre-translation stream + native passthrough callbacks) — do not re-wrap the
1238
1275
  // translated Anthropic stream here.
1239
1276
  return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors(
1240
- await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease }),
1277
+ await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease }, policy),
1241
1278
  req,
1242
- config,
1279
+ policy,
1243
1280
  ));
1244
1281
  }
1245
1282
 
@@ -1306,10 +1343,13 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1306
1343
  });
1307
1344
  }
1308
1345
 
1309
- // Voice / Realtime sideband WebSocket: Frameless joins /v1/live/{callId}; Realtime v1 joins
1310
- // /v1/realtime?call_id= (or /v1/realtime/calls/{callId}). Transparent bidirectional relay.
1346
+ // Voice / Realtime WebSocket relay. Sideband joins: Frameless /v1/live/{callId};
1347
+ // Realtime v1 /v1/realtime?call_id= (or /v1/realtime/calls/{callId}). Standalone
1348
+ // sessions (codex-rs thread/realtime/start, WebSocket transport — the desktop voice
1349
+ // path): /v1/realtime?intent=quicksilver&model= and /v1/live?model=.
1350
+ // Transparent bidirectional relay.
1311
1351
  const liveSidebandTarget = req.headers.get("upgrade")?.toLowerCase() === "websocket"
1312
- ? parseLiveSidebandTarget(url.pathname, url.searchParams)
1352
+ ? parseLiveSidebandTarget(url.pathname, url.searchParams, url.search.replace(/^\?/, ""))
1313
1353
  : null;
1314
1354
  if (liveSidebandTarget) {
1315
1355
  if (isDraining()) {