@bitkyc08/opencodex 2.69.0 → 2.70.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 (147) hide show
  1. package/gui/dist/assets/App-_MrbTrBe.js +51 -0
  2. package/gui/dist/assets/{Tray-uqKRqfvw.js → Tray-5PuUalET.js} +1 -1
  3. package/gui/dist/assets/index-DKrO2eF3.js +86 -0
  4. package/gui/dist/assets/quota-summary-BCLivc0Q.js +1 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-model-contract.ts +119 -0
  8. package/src/adapters/anthropic.ts +14 -83
  9. package/src/adapters/base.ts +2 -0
  10. package/src/adapters/command-code-project-context.ts +542 -0
  11. package/src/adapters/command-code-tool-text.ts +99 -67
  12. package/src/adapters/command-code.ts +5 -1
  13. package/src/adapters/cursor/catalog.ts +11 -0
  14. package/src/adapters/cursor/effort-map.ts +3 -0
  15. package/src/adapters/devin/cloud-direct/catalog.ts +91 -0
  16. package/src/adapters/devin/cloud-direct/chat.ts +78 -48
  17. package/src/adapters/devin/cloud-direct/tool-schema.ts +121 -0
  18. package/src/adapters/devin/context-overflow.ts +75 -0
  19. package/src/adapters/devin/live-models.ts +174 -13
  20. package/src/adapters/devin/reasoning-signature.ts +100 -0
  21. package/src/adapters/devin.ts +310 -117
  22. package/src/adapters/kiro/payload.ts +10 -10
  23. package/src/adapters/kiro-images.ts +58 -7
  24. package/src/adapters/openai-chat/passthrough.ts +3 -1
  25. package/src/adapters/openai-chat.ts +2 -1
  26. package/src/adapters/openai-responses/passthrough.ts +2 -1
  27. package/src/bridge/response-json.ts +15 -1
  28. package/src/bridge/sse.ts +3 -1
  29. package/src/claude/intercept/model-bindings.ts +1 -0
  30. package/src/cli/account-extended.ts +31 -1
  31. package/src/cli/account.ts +3 -0
  32. package/src/cli/capabilities.ts +13 -0
  33. package/src/cli/client-start-fence.ts +36 -0
  34. package/src/cli/dispatch.ts +3 -2
  35. package/src/cli/index.ts +98 -117
  36. package/src/cli/provider-runtime.ts +23 -2
  37. package/src/cli/provider.ts +3 -0
  38. package/src/cli/resolve.ts +18 -0
  39. package/src/cli/start-owner-fence.ts +30 -0
  40. package/src/cli/stop-approval.ts +13 -1
  41. package/src/cli/stop-restore.ts +73 -0
  42. package/src/cli/tray-proxy.ts +221 -18
  43. package/src/cli/version-skew.ts +25 -23
  44. package/src/client/runtime.ts +8 -4
  45. package/src/codex/auth-api/account-list.ts +11 -5
  46. package/src/codex/auth-api/login-flow.ts +5 -3
  47. package/src/codex/auth-api/main-account-probe.ts +186 -110
  48. package/src/codex/auth-api/pool-mode-gate.ts +61 -7
  49. package/src/codex/auth-api/pool-quota-probe.ts +142 -90
  50. package/src/codex/auth-api/reset-credit-service.ts +1 -1
  51. package/src/codex/catalog/gather-capture.ts +1 -0
  52. package/src/codex/catalog/model-hints.ts +3 -3
  53. package/src/codex/catalog/provider-models.ts +2 -0
  54. package/src/codex/low-quota-events.ts +28 -0
  55. package/src/codex/low-quota-observer.ts +23 -0
  56. package/src/codex/low-quota-protection.ts +272 -0
  57. package/src/codex/quota-query-backoff.ts +148 -0
  58. package/src/codex/quota.ts +7 -2
  59. package/src/config/diagnostics.ts +10 -1
  60. package/src/config/load-degrade.ts +17 -0
  61. package/src/config/mutation-lock.ts +3 -4
  62. package/src/config/process-state.ts +1 -1
  63. package/src/config/provider-validation.ts +22 -0
  64. package/src/config/schema/blocked-model-redirects.ts +17 -0
  65. package/src/config/schema/config-schema.ts +4 -1
  66. package/src/config/schema/leaf-validators.ts +33 -2
  67. package/src/config/serving-runtimes.ts +412 -0
  68. package/src/generated/compatibility-version.json +211 -115
  69. package/src/generated/model-metadata.ts +3 -3
  70. package/src/images/loop.ts +3 -0
  71. package/src/lib/errors.ts +2 -1
  72. package/src/lib/handled-signal-exit.ts +23 -0
  73. package/src/lib/request-failure-model.ts +1 -0
  74. package/src/lib/self-launch-argv.ts +9 -0
  75. package/src/lib/shadow-call.ts +5 -2
  76. package/src/lib/strict-semver.ts +26 -0
  77. package/src/lib/transient-service-path.ts +16 -0
  78. package/src/lib/windows-service-wrappers.ts +10 -6
  79. package/src/lib/winsw.ts +3 -1
  80. package/src/oauth/anthropic-model-routes.ts +105 -0
  81. package/src/oauth/anthropic-routing.ts +85 -48
  82. package/src/oauth/devin.ts +135 -5
  83. package/src/oauth/index.ts +20 -3
  84. package/src/oauth/kiro-terminal-failover.ts +23 -9
  85. package/src/oauth/pool-settings-capability.ts +10 -2
  86. package/src/oauth/store.ts +1 -1
  87. package/src/protocols/encoders/adapter-events.ts +3 -1
  88. package/src/providers/github-copilot-context.ts +27 -0
  89. package/src/providers/kiro-models.ts +4 -0
  90. package/src/providers/model-rename-fields.ts +3 -0
  91. package/src/providers/quota/account-cache.ts +8 -3
  92. package/src/providers/quota/devin.ts +286 -0
  93. package/src/providers/quota/vendor-probes-oauth.ts +6 -2
  94. package/src/providers/quota.ts +1 -1
  95. package/src/providers/registry/entries-core.ts +1 -1
  96. package/src/providers/registry/model-seeds.ts +5 -2
  97. package/src/responses/compaction-images.ts +27 -0
  98. package/src/router.ts +104 -16
  99. package/src/routing/analytics.ts +1 -0
  100. package/src/server/auth-cors.ts +9 -1
  101. package/src/server/background-lifecycle.ts +21 -10
  102. package/src/server/chat-completions.ts +2 -1
  103. package/src/server/chat-native.ts +1 -0
  104. package/src/server/index.ts +4 -8
  105. package/src/server/inference/client-encoder-delivery.ts +3 -0
  106. package/src/server/management/context.ts +3 -0
  107. package/src/server/management/low-quota-routes.ts +14 -0
  108. package/src/server/management/oauth-account-routes.ts +21 -1
  109. package/src/server/management/provider-overwrite-carry.ts +79 -6
  110. package/src/server/management/provider-routes.ts +41 -28
  111. package/src/server/management/route-registry.ts +1 -0
  112. package/src/server/management-api.ts +7 -0
  113. package/src/server/responses/adapter-continuation.ts +3 -0
  114. package/src/server/responses/adapter-delivery.ts +3 -0
  115. package/src/server/responses/adapter-dispatch.ts +20 -4
  116. package/src/server/responses/antigravity-validation-refusal.ts +20 -0
  117. package/src/server/responses/core-auth.ts +1 -0
  118. package/src/server/responses/core-codex-account.ts +1 -0
  119. package/src/server/responses/core-normalize.ts +4 -0
  120. package/src/server/responses/passthrough-dispatch.ts +8 -1
  121. package/src/server/responses/request-sidecar-auth.ts +2 -0
  122. package/src/server/responses/request-transport.ts +27 -10
  123. package/src/server/responses/response-effects.ts +4 -1
  124. package/src/server/responses/run-turn-execution.ts +126 -42
  125. package/src/server/responses/sidecar-execution.ts +4 -1
  126. package/src/service/diagnostics.ts +3 -1
  127. package/src/service/guarded-manager-target.ts +39 -12
  128. package/src/service/launchd.ts +14 -6
  129. package/src/service/repair.ts +6 -1
  130. package/src/service/service-child-ownership.ts +168 -0
  131. package/src/service/state.ts +22 -4
  132. package/src/service/systemd.ts +2 -2
  133. package/src/service/windows-guarded-manager.ts +530 -0
  134. package/src/service/windows-taskxml.ts +2 -2
  135. package/src/service.ts +2 -0
  136. package/src/types/config.ts +26 -0
  137. package/src/types/provider.ts +12 -0
  138. package/src/types/request.ts +2 -0
  139. package/src/types.ts +1 -0
  140. package/src/usage/expected-prices.ts +13 -0
  141. package/src/usage/telemetry-contract.ts +1 -0
  142. package/src/vision/anthropic-describe.ts +2 -1
  143. package/src/web-search/anthropic-executor.ts +2 -1
  144. package/src/web-search/loop.ts +1 -0
  145. package/gui/dist/assets/App-DDbgOTh6.js +0 -51
  146. package/gui/dist/assets/index-CuaBJkG4.js +0 -86
  147. package/gui/dist/assets/quota-summary-COppQSVq.js +0 -1
@@ -9,8 +9,11 @@ import type { ProviderModelDiscoverySpec } from "./types";
9
9
  // 260923 Claude Opus 5.5 (`claude-opus-5-5`, released 2026-09-22): 1M context / 128K output /
10
10
  // adaptive thinking always on / effort low..max with a medium default, per the Opus 5.5
11
11
  // overview, effort and pricing pages (platform.claude.com).
12
- export const ANTHROPIC_MODELS = ["claude-fable-5-1", "claude-fable-5", "claude-sonnet-5", "claude-opus-5-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
13
- export const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record<string, number> = { "claude-fable-5-1": 1_000_000, "claude-sonnet-5": 1_000_000, "claude-fable-5": 1_000_000, "claude-opus-5-5": 1_000_000, "claude-opus-5": 1_000_000, "claude-opus-4-8": 1_000_000, "claude-opus-4-7": 1_000_000, "claude-opus-4-6": 1_000_000, "claude-sonnet-4-6": 1_000_000, "claude-haiku-4-5": 200_000 };
12
+ // 260929 Claude Sonnet 5.5 (`claude-sonnet-5-5`, released 2026-09-28): 1M context / 128K output /
13
+ // adaptive thinking / effort low..max with a high default, same price as Sonnet 5, per the Sonnet 5.5
14
+ // overview, migration guide, effort and pricing pages (platform.claude.com).
15
+ export const ANTHROPIC_MODELS = ["claude-fable-5-1", "claude-fable-5", "claude-sonnet-5-5", "claude-sonnet-5", "claude-opus-5-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
16
+ export const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record<string, number> = { "claude-fable-5-1": 1_000_000, "claude-sonnet-5-5": 1_000_000, "claude-sonnet-5": 1_000_000, "claude-fable-5": 1_000_000, "claude-opus-5-5": 1_000_000, "claude-opus-5": 1_000_000, "claude-opus-4-8": 1_000_000, "claude-opus-4-7": 1_000_000, "claude-opus-4-6": 1_000_000, "claude-sonnet-4-6": 1_000_000, "claude-haiku-4-5": 200_000 };
14
17
  // All seeded Claude models support vision: https://platform.claude.com/docs/en/models/overview
15
18
  export const ANTHROPIC_MODEL_INPUT_MODALITIES: Record<string, string[]> = Object.fromEntries(
16
19
  ANTHROPIC_MODELS.map(id => [id, ["text", "image"]]),
@@ -0,0 +1,27 @@
1
+ import type { OcxMessage } from "../types";
2
+
3
+ export const COMPACTION_IMAGE_NOTE = "[Earlier image omitted from this compaction request. "
4
+ + "Retain existing analysis and source references; reopen the original attachment if visual details are still needed.]";
5
+
6
+ /**
7
+ * Summarize earlier image-bearing turns from their text, without paying their vision cost again.
8
+ * A later explicit final answer is a structural boundary, not proof that every image was read.
9
+ * Keep unphased/commentary-only turns and pending images. Never rewrite stored/raw history.
10
+ */
11
+ export function omitEarlierCompactionImages(messages: readonly OcxMessage[]): OcxMessage[] {
12
+ let finalAnswerSeen = false;
13
+ const result = [...messages];
14
+ for (let index = messages.length - 1; index >= 0; index--) {
15
+ const message = messages[index]!;
16
+ if (message.role === "assistant") {
17
+ if (message.phase === "final_answer"
18
+ && message.content.some(part => part.type === "text" && part.text.trim())) finalAnswerSeen = true;
19
+ continue;
20
+ }
21
+ if (!finalAnswerSeen || (message.role !== "user" && message.role !== "toolResult")
22
+ || !Array.isArray(message.content) || !message.content.some(part => part.type === "image")) continue;
23
+ result[index] = { ...message, content: message.content.map(part => part.type === "image"
24
+ ? { type: "text" as const, text: COMPACTION_IMAGE_NOTE } : part) };
25
+ }
26
+ return result;
27
+ }
package/src/router.ts CHANGED
@@ -89,6 +89,12 @@ export interface RouteResult {
89
89
  combo?: ComboPick;
90
90
  /** Bounded route-decision trace (RI-01); never contains secrets. */
91
91
  routeDecision?: RouteDecisionTraceV1;
92
+ /**
93
+ * Set when a blocked-model redirect moved the request to a different
94
+ * provider. Caller credentials addressed to the source route must not follow
95
+ * it, exactly as for combo and policy routes.
96
+ */
97
+ credentialDomainRewrite?: true;
92
98
  }
93
99
 
94
100
  export function captureRouteStaticPolicy(
@@ -549,6 +555,44 @@ export function comboRouteDecisionTrace(
549
555
  // Codex uses a small number of control-plane model ids that are not part of the public GPT/o
550
556
  // naming families. Keep this exact: a broad `codex-*` rule could capture a third-party model.
551
557
  const CODEX_INTERNAL_OPENAI_MODELS = new Set(["codex-auto-review"]);
558
+ const MAX_BLOCKED_MODEL_REDIRECT_EDGES = 5;
559
+
560
+ interface BlockedModelRedirectState {
561
+ visited: Set<string>;
562
+ edges: number;
563
+ }
564
+
565
+ function crossProviderRedirectTarget(config: OcxConfig, providerName: string, value: string | undefined): string | undefined {
566
+ if (!value) return undefined;
567
+ const slash = value.indexOf("/");
568
+ if (slash <= 0) return undefined;
569
+ const targetProvider = value.slice(0, slash);
570
+ if (targetProvider === providerName || !hasOwnProvider(config.providers, targetProvider)) return undefined;
571
+ const configured = config.providers[targetProvider];
572
+ if (configured.disabled === true) return undefined;
573
+ try {
574
+ const effective = routedProviderConfig(targetProvider, configured);
575
+ const registry = providerMatchesRegistryTransportWithStaticGuards(targetProvider, configured)
576
+ ? PROVIDER_REGISTRY.find(entry => entry.id === targetProvider)
577
+ : undefined;
578
+ const authMode = effective.authMode ?? registry?.authKind ?? "key";
579
+ if (authMode === "forward") return undefined; // The source caller bearer is stripped on a redirect.
580
+ if (authMode === "oauth") {
581
+ if (registry?.authKind !== "oauth") return undefined;
582
+ const usable = peekAuthStore()[targetProvider]?.accounts.some(account =>
583
+ account.paused !== true && account.needsReauth !== true
584
+ && (account.credential.expires > Date.now()
585
+ ? Boolean(account.credential.access.trim())
586
+ : Boolean(account.credential.refresh.trim()))
587
+ );
588
+ return usable ? value : undefined;
589
+ }
590
+ return authMode === "local" || effective.keyOptional === true || Boolean(effective.apiKey?.trim())
591
+ ? value : undefined;
592
+ } catch {
593
+ return undefined;
594
+ }
595
+ }
552
596
 
553
597
  function isBareOpenAiFamilyModel(modelId: string): boolean {
554
598
  return !modelId.includes("/")
@@ -562,8 +606,32 @@ function routeResult(
562
606
  modelId: string,
563
607
  routeKind: RouteDecisionKind,
564
608
  routeReason: string,
609
+ redirectState: BlockedModelRedirectState,
565
610
  ): RouteResult {
566
- const redirected = resolveBlockedModelRedirect(config, modelId);
611
+ const qualified = resolveBlockedModelRedirect(config, `${providerName}/${modelId}`);
612
+ const bare = resolveBlockedModelRedirect(config, modelId);
613
+ const crossTarget = config && (
614
+ crossProviderRedirectTarget(config, providerName, qualified)
615
+ ?? crossProviderRedirectTarget(config, providerName, bare)
616
+ );
617
+ if (crossTarget && config) {
618
+ if (routeKind === "explicit-account") {
619
+ throw new Error("Blocked model redirect cannot leave a pinned account route");
620
+ }
621
+ const source = `${providerName}/${modelId}`;
622
+ if (redirectState.visited.has(source)) {
623
+ throw new Error(`Blocked model redirect cycle detected: ${source}`);
624
+ }
625
+ if (redirectState.edges >= MAX_BLOCKED_MODEL_REDIRECT_EDGES) {
626
+ throw new Error(`Blocked model redirect exceeded maximum redirect depth (${MAX_BLOCKED_MODEL_REDIRECT_EDGES})`);
627
+ }
628
+ redirectState.visited.add(source);
629
+ redirectState.edges += 1;
630
+ const targetRoute = routeModelInternal(config, crossTarget, true, undefined, false, false, redirectState);
631
+ return { ...targetRoute, routeReason: "blocked-model-redirect", credentialDomainRewrite: true };
632
+ }
633
+ // Existing bare mappings remain one post-resolution, same-provider substitution.
634
+ const redirected = bare;
567
635
  const effectiveModelId = redirected ?? modelId;
568
636
  const effectiveRouteReason = redirected ? "blocked-model-redirect" : routeReason;
569
637
  const codexAccountMode = providerCodexAccountMode(providerName, provider);
@@ -628,6 +696,7 @@ function routeModelInternal(
628
696
  policyEvidence?: PolicyRequestEvidence,
629
697
  allowCompactionNativeFallback = false,
630
698
  preview = false,
699
+ redirectState: BlockedModelRedirectState = { visited: new Set<string>(), edges: 0 },
631
700
  ): RouteResult {
632
701
  const slash = modelId.indexOf("/");
633
702
  // Policy namespace is system-reserved: an explicit `policy/<id>` or a
@@ -654,12 +723,25 @@ function routeModelInternal(
654
723
  }
655
724
  const selected = evaluation.candidates[evaluation.selectedIndex]!;
656
725
  const concrete = `${selected.provider}/${selected.model}`;
657
- const routed = routeModelInternal(config, concrete, true);
726
+ const routed = routeModelInternal(config, concrete, true, undefined, false, preview, redirectState);
727
+ if (routed.routeReason === "blocked-model-redirect" && !evaluation.candidates.some(candidate =>
728
+ candidate.provider === routed.providerName && candidate.model === routed.modelId && candidate.eligible
729
+ )) {
730
+ throw new NoEligiblePolicyCandidateError(policyId, evaluation.trace);
731
+ }
658
732
  return {
659
733
  ...routed,
660
734
  routeKind: "policy" as const,
661
- routeReason: "policy-selected",
662
- routeDecision: evaluation.trace,
735
+ routeReason: routed.routeReason === "blocked-model-redirect" ? "blocked-model-redirect" : "policy-selected",
736
+ routeDecision: {
737
+ ...evaluation.trace,
738
+ selected: {
739
+ ...evaluation.trace.selected,
740
+ provider: routed.providerName,
741
+ model: routed.modelId,
742
+ reason: routed.routeReason === "blocked-model-redirect" ? "blocked-model-redirect" : evaluation.trace.selected.reason,
743
+ },
744
+ },
663
745
  };
664
746
  }
665
747
  if (slash > 0) {
@@ -683,8 +765,12 @@ function routeModelInternal(
683
765
  if (!isCanonicalOpenAiForwardProvider(providerForCanonicalCheck)) {
684
766
  throw new NoEnabledOpenAiProviderError(nativeModelId);
685
767
  }
768
+ const accountQualified = resolveBlockedModelRedirect(config, modelId);
769
+ if (crossProviderRedirectTarget(config, OPENAI_CODEX_PROVIDER_ID, accountQualified)) {
770
+ throw new Error("Blocked model redirect cannot leave a pinned account route");
771
+ }
686
772
  return {
687
- ...routeResult(config, OPENAI_CODEX_PROVIDER_ID, provider, nativeModelId, "explicit-account", "account-namespace"),
773
+ ...routeResult(config, OPENAI_CODEX_PROVIDER_ID, provider, nativeModelId, "explicit-account", "account-namespace", redirectState),
688
774
  // Exact account injection uses the pool credential machinery even when the canonical
689
775
  // provider is globally Direct. The fixed id bypasses pool selection entirely.
690
776
  codexAccountMode: "pool",
@@ -700,8 +786,8 @@ function routeModelInternal(
700
786
  const concrete = `${combo.target.provider}/${combo.target.model}`;
701
787
  // The selected target is already a concrete provider/model reference. Resolve it without
702
788
  // consulting combo aliases again, otherwise an alias that shadows the target can recurse.
703
- const routed = routeModelInternal(config, concrete, true, undefined);
704
- return { ...routed, combo, routeKind: "combo" as const, routeReason: "combo-pick" };
789
+ const routed = routeModelInternal(config, concrete, true, undefined, false, preview, redirectState);
790
+ return { ...routed, combo, routeKind: "combo" as const, routeReason: routed.routeReason === "blocked-model-redirect" ? "blocked-model-redirect" : "combo-pick" };
705
791
  }
706
792
  }
707
793
 
@@ -761,7 +847,7 @@ function routeModelInternal(
761
847
  // itself a known model (e.g. orcarouter/auto). Route it whole instead of stripping to the
762
848
  // remainder, which would send a bare `auto` the upstream cannot resolve.
763
849
  if (known.includes(modelId)) {
764
- return routeResult(config, provName, prov, modelId, "explicit-provider", "explicit-provider-namespace");
850
+ return routeResult(config, provName, prov, modelId, "explicit-provider", "explicit-provider-namespace", redirectState);
765
851
  }
766
852
  // Codex-facing alias ids (`provider/vendor-model`) decode back to the native
767
853
  // slash id via an exact known-id lookup; raw full-slash selectors keep working.
@@ -777,6 +863,7 @@ function routeModelInternal(
777
863
  nativeModel,
778
864
  "explicit-provider",
779
865
  "explicit-provider-namespace",
866
+ redirectState,
780
867
  );
781
868
  }
782
869
  }
@@ -785,7 +872,7 @@ function routeModelInternal(
785
872
  if (isBareOpenAiFamilyModel(modelId)) {
786
873
  const provider = config.providers[OPENAI_CODEX_PROVIDER_ID];
787
874
  if (provider && provider.disabled !== true) {
788
- return routeResult(config, OPENAI_CODEX_PROVIDER_ID, provider, modelId, "native", "native-family");
875
+ return routeResult(config, OPENAI_CODEX_PROVIDER_ID, provider, modelId, "native", "native-family", redirectState);
789
876
  }
790
877
  // Codex chooses a bare native model for compaction even when the operator's
791
878
  // ordinary route is a third-party provider. Keep the native reservation
@@ -806,6 +893,7 @@ function routeModelInternal(
806
893
  modelId,
807
894
  "default-provider",
808
895
  "compaction-default-provider",
896
+ redirectState,
809
897
  );
810
898
  }
811
899
  }
@@ -815,18 +903,18 @@ function routeModelInternal(
815
903
  for (const [provName, prov] of activeProviderEntries(config)) {
816
904
  if (prov.defaultModel === modelId
817
905
  || (typeof prov.defaultModel === "string" && encodeRoutedModelId(prov.defaultModel) === modelId)) {
818
- return routeResult(config, provName, prov, prov.defaultModel as string, "explicit-provider", "configured-default-model");
906
+ return routeResult(config, provName, prov, prov.defaultModel as string, "explicit-provider", "configured-default-model", redirectState);
819
907
  }
820
908
  }
821
909
 
822
- const patternRoute = routeByKnownModelPattern(config, modelId);
910
+ const patternRoute = routeByKnownModelPattern(config, modelId, redirectState);
823
911
  if (patternRoute) return patternRoute;
824
912
 
825
913
  for (const [provName, prov] of activeProviderEntries(config)) {
826
914
  if (prov.models && Array.isArray(prov.models)) {
827
915
  const hit = (prov.models as string[]).find(id => id === modelId || encodeRoutedModelId(id) === modelId);
828
916
  if (hit !== undefined) {
829
- return routeResult(config, provName, prov, hit, "explicit-provider", "configured-model-list");
917
+ return routeResult(config, provName, prov, hit, "explicit-provider", "configured-model-list", redirectState);
830
918
  }
831
919
  }
832
920
  }
@@ -846,7 +934,7 @@ function routeModelInternal(
846
934
  }
847
935
  if (aliasMatches[0]) {
848
936
  const match = aliasMatches[0];
849
- return routeResult(config, match.provider, config.providers[match.provider], match.model, "explicit-provider", "model-alias");
937
+ return routeResult(config, match.provider, config.providers[match.provider], match.model, "explicit-provider", "model-alias", redirectState);
850
938
  }
851
939
 
852
940
  if (config.defaultProvider === LEGACY_CHATGPT_PROVIDER_ID) {
@@ -855,7 +943,7 @@ function routeModelInternal(
855
943
  if (hasOwnProvider(config.providers, config.defaultProvider)) {
856
944
  const defaultProv = config.providers[config.defaultProvider];
857
945
  if (defaultProv.disabled === true) throw new Error(`Default provider is disabled: ${config.defaultProvider}`);
858
- return routeResult(config, config.defaultProvider, defaultProv, modelId, "default-provider", "default-provider");
946
+ return routeResult(config, config.defaultProvider, defaultProv, modelId, "default-provider", "default-provider", redirectState);
859
947
  }
860
948
 
861
949
  throw new Error(`No provider configured for model: ${modelId}`);
@@ -921,7 +1009,7 @@ export function routeConcreteModel(config: OcxConfig, modelId: string): RouteRes
921
1009
  return routeModelInternal(config, modelId, true, undefined);
922
1010
  }
923
1011
 
924
- function routeByKnownModelPattern(config: OcxConfig, modelId: string): RouteResult | undefined {
1012
+ function routeByKnownModelPattern(config: OcxConfig, modelId: string, redirectState: BlockedModelRedirectState): RouteResult | undefined {
925
1013
  for (const { providerNames, prefixes } of MODEL_PROVIDER_PATTERNS) {
926
1014
  if (prefixes.some(prefix => modelId.startsWith(prefix))) {
927
1015
  const matchingProvider = Object.entries(config.providers).find(
@@ -929,7 +1017,7 @@ function routeByKnownModelPattern(config: OcxConfig, modelId: string): RouteResu
929
1017
  );
930
1018
  if (matchingProvider) {
931
1019
  const [provName, prov] = matchingProvider;
932
- return routeResult(config, provName, prov, modelId, "explicit-provider", "model-pattern");
1020
+ return routeResult(config, provName, prov, modelId, "explicit-provider", "model-pattern", redirectState);
933
1021
  }
934
1022
  // Deliberately no "first provider with an Anthropic adapter" fallback here. Picking by
935
1023
  // object insertion order, without checking `models`, `selectedModels`, `disabledModels` or
@@ -119,6 +119,7 @@ const COOLDOWN_RECOVERY_KINDS = new Set([
119
119
  "key-401",
120
120
  "key-429",
121
121
  "oauth-401",
122
+ "oauth-account-403",
122
123
  "anthropic-oauth-429",
123
124
  "oauth-account-429",
124
125
  ]);
@@ -1,5 +1,5 @@
1
1
  import { providerRelativeSendPathConfigError } from "../config/provider-relative-send-path";
2
- import { modelCapabilitiesConfigError } from "../config/provider-validation";
2
+ import { contextTierRecordConfigError, modelCapabilitiesConfigError } from "../config/provider-validation";
3
3
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
4
4
  import { initialModelSelection } from "../providers/initial-model-selection";
5
5
  import { extractAccountId } from "../oauth/chatgpt";
@@ -16,6 +16,7 @@ import {
16
16
  } from "../config";
17
17
  import {
18
18
  apiKeyTransportConfigError,
19
+ projectContextConfigError,
19
20
  autoReviewModelOverridesConfigError,
20
21
  autoReviewModelTargetConfigError,
21
22
  booleanRecordConfigError,
@@ -693,6 +694,8 @@ export function providerManagementConfigError(
693
694
  return "provider must be a plain object";
694
695
  }
695
696
  const raw = provider as Record<string, unknown>;
697
+ const contextTiersError = contextTierRecordConfigError(raw.modelContextTiers);
698
+ if (contextTiersError) return contextTiersError;
696
699
  const capabilitiesError = modelCapabilitiesConfigError(raw.modelCapabilities);
697
700
  if (capabilitiesError) return capabilitiesError;
698
701
  const pinsError = providerReasoningPinsConfigError(raw);
@@ -828,6 +831,8 @@ export function providerManagementConfigError(
828
831
  }
829
832
  const apiKeyTransportError = apiKeyTransportConfigError(typed);
830
833
  if (apiKeyTransportError) return `provider ${name} ${apiKeyTransportError}`;
834
+ const projectContextError = projectContextConfigError(typed);
835
+ if (projectContextError) return `provider ${JSON.stringify(redactSecretString(name))} ${projectContextError}`;
831
836
  const maxInputError = positiveIntegerRecordConfigError(raw.modelMaxInputTokens, "modelMaxInputTokens");
832
837
  if (maxInputError) return `provider ${name} ${maxInputError}`;
833
838
  const autoCompactError = modelAutoCompactTokenLimitsConfigError(
@@ -967,6 +972,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
967
972
  defaultAliases: "editor",
968
973
  adapter: "editor",
969
974
  codexToolMode: "editor",
975
+ projectContext: "editor",
970
976
  requestPacing: "editor",
971
977
  mcpMaxTools: "editor",
972
978
  mcpMaxSchemaBytes: "editor",
@@ -1021,6 +1027,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
1021
1027
  modelPreset: "editor",
1022
1028
  contextWindow: "editor",
1023
1029
  modelContextWindows: "editor",
1030
+ modelContextTiers: "editor",
1024
1031
  modelInputModalities: "editor",
1025
1032
  modelCapabilities: "editor",
1026
1033
  modelMaxInputTokens: "runtime",
@@ -1084,6 +1091,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
1084
1091
  preserveReasoningContentModels: "editor",
1085
1092
  requiresReasoningPlaceholderModels: "editor",
1086
1093
  showThinkingSummary: "editor",
1094
+ hideRawReasoning: "editor",
1087
1095
  retryOn429: "editor",
1088
1096
  transientRetryOn5xx: "editor",
1089
1097
  retryOnReset: "editor",
@@ -1,4 +1,6 @@
1
- import type { StorageCleanupPolicy } from "../types";
1
+ import type { OcxConfig, StorageCleanupPolicy } from "../types";
2
+ import { registerCodexLowQuotaProtection, type LowQuotaRegistration } from "../codex/low-quota-protection";
3
+ import type { LowQuotaEvent } from "../codex/low-quota-events";
2
4
  import { startStateStoreSweeper } from "../lib/state-store-sweeper";
3
5
  import {
4
6
  abortStorageCleanupPolicyJobAsync,
@@ -40,10 +42,12 @@ type LeaseOwner = {
40
42
  token: symbol;
41
43
  applyPolicy: PolicyApply;
42
44
  resources: ServerResourceOwnerLease;
45
+ lowQuota: LowQuotaRegistration;
43
46
  };
44
47
 
45
48
  export type ServerBackgroundLifecycleLease = {
46
49
  scheduleStartupRun(): void;
50
+ listLowQuotaEvents(limit?: number): LowQuotaEvent[];
47
51
  release(): Promise<void>;
48
52
  releaseAfterFailedStart(): void;
49
53
  };
@@ -148,6 +152,7 @@ function removeOwner(owner: LeaseOwner): boolean {
148
152
 
149
153
  function releaseOwnerSynchronously(owner: LeaseOwner): "inactive" | "shared" | "last" {
150
154
  if (!removeOwner(owner)) return "inactive";
155
+ owner.lowQuota();
151
156
  owner.resources.release();
152
157
  const nextOwner = owners.at(-1);
153
158
  if (nextOwner) {
@@ -168,6 +173,7 @@ function releaseOwnerSynchronously(owner: LeaseOwner): "inactive" | "shared" | "
168
173
  */
169
174
  export function acquireServerBackgroundLifecycle(
170
175
  applyPolicy: PolicyApply,
176
+ config: OcxConfig,
171
177
  ): ServerBackgroundLifecycleLease {
172
178
  if (cleanupInProgress) {
173
179
  throw new Error("server background lifecycle cleanup is still in progress");
@@ -177,6 +183,7 @@ export function acquireServerBackgroundLifecycle(
177
183
  token: Symbol("server-background-lifecycle"),
178
184
  applyPolicy,
179
185
  resources: acquireServerResourceOwner(),
186
+ lowQuota: registerCodexLowQuotaProtection(config),
180
187
  };
181
188
  try {
182
189
  if (!processLoops) {
@@ -186,12 +193,20 @@ export function acquireServerBackgroundLifecycle(
186
193
  }
187
194
  owners.push(owner);
188
195
  } catch (error) {
196
+ owner.lowQuota();
189
197
  owner.resources.release();
190
198
  throw error;
191
199
  }
192
200
 
193
201
  let releaseFlight: Promise<void> | null = null;
202
+ function finishRelease(): Promise<void> {
203
+ const outcome = releaseOwnerSynchronously(owner);
204
+ if (outcome !== "last") return Promise.resolve();
205
+ cleanupInProgress = true;
206
+ return stopStoragePolicyWorker().finally(() => { cleanupInProgress = false; });
207
+ }
194
208
  return {
209
+ listLowQuotaEvents(limit) { return owner.lowQuota.listEvents(limit); },
195
210
  scheduleStartupRun() {
196
211
  if (owners.some(candidate => candidate.token === owner.token)) {
197
212
  scheduleStorageCleanupStartupRun();
@@ -199,19 +214,15 @@ export function acquireServerBackgroundLifecycle(
199
214
  },
200
215
  release() {
201
216
  if (releaseFlight) return releaseFlight;
202
- const outcome = releaseOwnerSynchronously(owner);
203
- if (outcome !== "last") {
204
- releaseFlight = Promise.resolve();
205
- return releaseFlight;
206
- }
207
- cleanupInProgress = true;
208
- releaseFlight = stopStoragePolicyWorker().finally(() => {
209
- cleanupInProgress = false;
210
- });
217
+ // Default installs have no low-quota write. Preserve synchronous owner removal;
218
+ // an unconditional await here extends process-global ownership into later starts.
219
+ if (!owner.lowQuota.hasPendingSave()) releaseFlight = finishRelease();
220
+ else releaseFlight = owner.lowQuota.flush().then(finishRelease, finishRelease);
211
221
  return releaseFlight;
212
222
  },
213
223
  releaseAfterFailedStart() {
214
224
  if (releaseFlight) return;
225
+ owner.lowQuota();
215
226
  // Startup evaluation is scheduled only after both listeners bind, so a
216
227
  // failed start cannot have spawned a Worker for this lease. Keep rollback
217
228
  // synchronous so a caller may immediately retry a different port.
@@ -200,7 +200,8 @@ async function handleChatCompletionsWithBudget(
200
200
  logCtx.provider = route.providerName;
201
201
  logCtx.routeDecision = route.routeDecision;
202
202
  settledRoute = route;
203
- routeMayChangeCredentialDomain = route.combo !== undefined || route.routeKind === "policy";
203
+ routeMayChangeCredentialDomain = route.combo !== undefined || route.routeKind === "policy"
204
+ || route.credentialDomainRewrite === true;
204
205
  callerAuthorizationRoute = !routeMayChangeCredentialDomain
205
206
  && providerConsumesCallerAuthorization(route.provider);
206
207
  if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") {
@@ -336,6 +336,7 @@ export async function runNativeChatAttempt(
336
336
  requestedStream,
337
337
  fastPolicyForModel(activeProvider, route.modelId, route.providerName, "chat"),
338
338
  config.fastMode,
339
+ route.providerName,
339
340
  );
340
341
  };
341
342
  try {
@@ -640,6 +640,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
640
640
  let remoteWorkspaceShutdown: (() => Promise<void>) | undefined;
641
641
  const managementApiDeps: ManagementApiDeps = {
642
642
  ...deps.managementApi,
643
+ listLowQuotaEvents: limit => backgroundLifecycle?.listLowQuotaEvents(limit) ?? [],
643
644
  remoteWorkspaceStopping: () => remoteWorkspaceStopping,
644
645
  onRemoteWorkspaceShutdown: shutdown => { remoteWorkspaceShutdown = shutdown; }, linkSupervisor: () => optionalListeners.linkSupervisor(), linkListener: () => optionalListeners,
645
646
  };
@@ -655,13 +656,10 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
655
656
  return workspaceRuntimeFlight;
656
657
  };
657
658
  try {
658
- backgroundLifecycle = acquireServerBackgroundLifecycle(applyPolicy);
659
+ backgroundLifecycle = acquireServerBackgroundLifecycle(applyPolicy, config);
659
660
  unregisterQuotaAutoRefresh = (deps.registerCodexQuotaAutoRefreshWorker
660
661
  ?? registerCodexQuotaAutoRefreshWorker)(config);
661
- // External `ocx config set` / direct config.json edits run in other
662
- // processes; poll the file so Logs/Usage display prices follow them live.
663
- // Started inside the guarded startup transaction so the catch below can
664
- // release the owner-scoped lease on any listener failure.
662
+ // Poll external pricing edits; the startup catch releases this owner-scoped lease.
665
663
  userCostOverlayReconciler = startUserCostOverlayReconciler({ liveConfig: config });
666
664
  const serveOptions = createServeOptions({
667
665
  drainingResponse,
@@ -754,9 +752,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
754
752
  value: async (closeActiveConnections?: boolean): Promise<void> => {
755
753
  remoteWorkspaceStopping = true;
756
754
  liveCallBindings.clear();
757
- // Disarm the package-tree restart timer before listener teardown: a queued
758
- // replacement callback must not call acceptSystemRestart() after stop() has
759
- // begun, or it would schedule a drain-and-restart on a stopped server.
755
+ // Disarm the package-tree restart timer before teardown can schedule another restart.
760
756
  if (!packageRefreshStopped) {
761
757
  packageRefreshStopped = true;
762
758
  stopPackageRefresh();
@@ -87,6 +87,7 @@ export interface ClientEncodedDelivery {
87
87
  fold: {
88
88
  replayCacheScope?: OcxReasoningReplayScopeRef;
89
89
  hideThinkingSummary?: boolean;
90
+ hideRawReasoning?: boolean;
90
91
  toolNsMap?: Map<string, { namespace: string; name: string; freeform?: true }>;
91
92
  declaredToolNames?: ReadonlySet<string>;
92
93
  toolParameterSchemas?: ReadonlyMap<string, Record<string, unknown>>;
@@ -164,6 +165,8 @@ export async function deliverClientEncodedResponse(input: ClientEncodedDelivery)
164
165
  enforceDeclaredToolNames: false,
165
166
  translatorBudget,
166
167
  recordBufferedDelivery: false,
168
+ // The client never receives this body, and its Chat/Messages wire has no envelope field.
169
+ omitHiddenReasoningEnvelope: true,
167
170
  });
168
171
  } catch {
169
172
  return undefined;
@@ -1,4 +1,5 @@
1
1
  import type { OcxConfig } from "../../types";
2
+ import type { LowQuotaEvent } from "../../codex/low-quota-events";
2
3
  import type { Channel } from "../../update/index";
3
4
  import type { UpdateCheckResult } from "../../update/job";
4
5
  import type { NativeProfileApiDeps } from "../../codex/native-profile-api";
@@ -44,6 +45,8 @@ export interface ManagementRequestIngress {
44
45
  }
45
46
 
46
47
  export interface ManagementApiDeps {
48
+ /** Bound to this server's lifecycle owner; absent in direct route tests. */
49
+ listLowQuotaEvents?: (limit?: number) => LowQuotaEvent[];
47
50
  /** Bound Claude intercept state, injectable for isolated management-route tests. */
48
51
  getClaudeInterceptState?: typeof import("../../claude/intercept/runtime").getClaudeInterceptState;
49
52
  /** Reconciliation seam for field-scoped rollback tests. */
@@ -0,0 +1,14 @@
1
+ /** Auth is enforced by the management dispatcher before this lazy route runs. */
2
+ import { jsonResponse } from "../auth-cors";
3
+ import type { ManagementContext } from "./context";
4
+
5
+ export async function handleLowQuotaRoutes(ctx: ManagementContext): Promise<Response | null> {
6
+ const { url, req, config } = ctx;
7
+ if (url.pathname !== "/api/codex-auth/low-quota-events" || req.method !== "GET") return null;
8
+ const raw = url.searchParams.get("limit");
9
+ if (raw !== null && (!/^[0-9]+$/.test(raw) || !Number.isSafeInteger(Number(raw)))) {
10
+ return jsonResponse({ error: { code: "invalid_limit", message: "limit must be a non-negative integer" } }, 400, req, config);
11
+ }
12
+ const limit = raw === null ? 20 : Math.min(100, Number(raw));
13
+ return jsonResponse({ events: ctx.deps.listLowQuotaEvents?.(limit) ?? [] }, 200, req, config);
14
+ }
@@ -1,3 +1,4 @@
1
+ import { parseAnthropicModelRoutes, readAnthropicModelRoutes } from "../../oauth/anthropic-model-routes";
1
2
  import { randomBytes, randomUUID } from "node:crypto";
2
3
  import { readFileSync } from "node:fs";
3
4
  import type { CatalogModel } from "../../codex/catalog";
@@ -545,7 +546,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
545
546
  if (req.method !== "GET" && !isPlainRecord(rawBody)) {
546
547
  return jsonResponse({ error: "body must be an object" }, 400);
547
548
  }
548
- const fields = rawBody as { provider?: unknown; enabled?: unknown; strategy?: unknown; stickyLimit?: unknown; autoSwitchThreshold?: unknown; quotaWindow?: unknown; maxConcurrentPerAccount?: unknown };
549
+ const fields = rawBody as { provider?: unknown; enabled?: unknown; strategy?: unknown; stickyLimit?: unknown; autoSwitchThreshold?: unknown; quotaWindow?: unknown; maxConcurrentPerAccount?: unknown; routes?: unknown };
549
550
  const provider = req.method === "GET"
550
551
  ? (url.searchParams.get("provider") ?? "").trim().toLowerCase()
551
552
  : (typeof fields.provider === "string" ? fields.provider.trim().toLowerCase() : "");
@@ -575,6 +576,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
575
576
  if (parsed === null) return jsonResponse({ error: "autoSwitchThreshold must be an integer 0-100" }, 400);
576
577
  autoSwitchThreshold = parsed;
577
578
  }
579
+ if (Object.hasOwn(fields, "routes") && kind !== "anthropic") return jsonResponse({ error: "routes are only part of the anthropic pool contract" }, 400);
580
+ const parsedRoutes = Object.hasOwn(fields, "routes") && fields.routes !== null
581
+ ? parseAnthropicModelRoutes(fields.routes) : null;
582
+ if (parsedRoutes && !parsedRoutes.ok) return jsonResponse({ error: parsedRoutes.error }, 400);
578
583
  if (fields.quotaWindow !== undefined && kind !== "anthropic") {
579
584
  return jsonResponse({ error: "quotaWindow is only part of the anthropic pool contract" }, 400);
580
585
  }
@@ -607,6 +612,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
607
612
  if (stickyLimit !== undefined) pool.stickyLimit = stickyLimit;
608
613
  if (autoSwitchThreshold !== undefined) pool.autoSwitchThreshold = autoSwitchThreshold;
609
614
  if (quotaWindow !== undefined) pool.quotaWindow = quotaWindow as never;
615
+ if (Object.hasOwn(fields, "routes")) {
616
+ if (fields.routes === null) delete pool.routes;
617
+ else if (parsedRoutes?.ok) pool.routes = parsedRoutes.routes;
618
+ }
610
619
  config.anthropicAccountPool = pool;
611
620
  } else {
612
621
  const prov = config.providers[provider]!;
@@ -650,6 +659,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
650
659
  strategy: normalizeAccountPoolStrategy(pool.strategy),
651
660
  stickyLimit: normalizeAccountPoolStickyLimit(pool.stickyLimit),
652
661
  quotaWindow: normalizeAccountPoolQuotaWindow(pool.quotaWindow),
662
+ ...readAnthropicModelRoutes(pool.routes),
653
663
  experimental: true,
654
664
  });
655
665
  }
@@ -666,6 +676,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
666
676
  stickyLimit?: unknown;
667
677
  quotaWindow?: unknown;
668
678
  maxConcurrentPerAccount?: unknown;
679
+ routes?: unknown;
669
680
  };
670
681
  const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
671
682
  if (provider !== "anthropic") {
@@ -677,6 +688,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
677
688
  if (!provider || !prov || poolSettingsCapability(provider, prov) !== "generic") {
678
689
  return jsonResponse({ error: "pool config is only supported for anthropic and generic OAuth providers" }, 400);
679
690
  }
691
+ if (Object.hasOwn(body, "routes")) return jsonResponse({ error: "routes are only part of the anthropic pool contract" }, 400);
680
692
  if (body.quotaWindow !== undefined) {
681
693
  return jsonResponse({ error: "quotaWindow is not part of the generic pool contract yet" }, 400);
682
694
  }
@@ -762,12 +774,19 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
762
774
  }
763
775
  quotaWindow = parsed;
764
776
  }
777
+ const parsedLegacyRoutes = Object.hasOwn(body, "routes") && body.routes !== null
778
+ ? parseAnthropicModelRoutes(body.routes) : null;
779
+ if (parsedLegacyRoutes && !parsedLegacyRoutes.ok) return jsonResponse({ error: parsedLegacyRoutes.error }, 400);
780
+ const routes = Object.hasOwn(body, "routes")
781
+ ? (parsedLegacyRoutes?.ok ? parsedLegacyRoutes.routes : undefined)
782
+ : config.anthropicAccountPool?.routes;
765
783
  config.anthropicAccountPool = {
766
784
  enabled,
767
785
  autoSwitchThreshold: threshold,
768
786
  ...(strategy !== undefined ? { strategy } : {}),
769
787
  ...(stickyLimit !== undefined ? { stickyLimit } : {}),
770
788
  ...(quotaWindow !== undefined ? { quotaWindow } : {}),
789
+ ...(routes !== undefined ? { routes } : {}),
771
790
  };
772
791
  saveConfigPreservingClaudeCode(config);
773
792
  reconcileLiveStateStores();
@@ -779,6 +798,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
779
798
  strategy: normalizeAccountPoolStrategy(strategy),
780
799
  stickyLimit: normalizeAccountPoolStickyLimit(stickyLimit),
781
800
  quotaWindow: normalizeAccountPoolQuotaWindow(quotaWindow),
801
+ routes: routes ?? null,
782
802
  experimental: true,
783
803
  });
784
804
  }