@bitkyc08/opencodex 2.74.0 → 2.75.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 (48) hide show
  1. package/gui/dist/assets/{App-KLz1TAXi.css → App-ia4U0ufY.css} +1 -1
  2. package/gui/dist/assets/{App-B00Yq11u.js → App-xm0zGNOe.js} +3 -3
  3. package/gui/dist/assets/{Tray-D3Ij1Cvf.js → Tray-CGAD2AGk.js} +1 -1
  4. package/gui/dist/assets/{index-BYQQfBGK.js → index-D_RGAT6Q.js} +2 -2
  5. package/gui/dist/assets/{quota-summary-yPtcEhGI.js → quota-summary-CUFq48t-.js} +1 -1
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +1 -1
  8. package/src/adapters/devin/cloud-direct/chat.ts +38 -6
  9. package/src/adapters/devin/live-models.ts +28 -8
  10. package/src/adapters/devin.ts +1 -1
  11. package/src/claude/devin-output-order.ts +98 -0
  12. package/src/codex/auth-api/pool-quota-probe.ts +2 -1
  13. package/src/codex/catalog/retained-sync.ts +9 -0
  14. package/src/codex/inject/config-toml.ts +179 -248
  15. package/src/codex/inject/provider-table.ts +5 -2
  16. package/src/codex/inject/remove.ts +23 -8
  17. package/src/codex/injected-marker.ts +28 -23
  18. package/src/codex/quota-auto-refresh.ts +1 -1
  19. package/src/codex/quota.ts +12 -4
  20. package/src/codex/toml-source-lines.ts +93 -1
  21. package/src/config/live-reconcile.ts +18 -0
  22. package/src/config/rebase-provenance.ts +12 -0
  23. package/src/config.ts +4 -3
  24. package/src/generated/compatibility-version.json +50 -38
  25. package/src/images/loop.ts +2 -0
  26. package/src/integrations/cursor-local-installer.ts +29 -2
  27. package/src/lib/redact.ts +54 -8
  28. package/src/oauth/anthropic-account-refusal.ts +76 -0
  29. package/src/oauth/anthropic-routing.ts +38 -10
  30. package/src/providers/new-model-policy-runtime.ts +83 -0
  31. package/src/providers/new-model-policy.ts +25 -0
  32. package/src/providers/registry/entries-core.ts +3 -0
  33. package/src/providers/registry/model-seeds.ts +31 -2
  34. package/src/providers/xai-transport.ts +2 -2
  35. package/src/server/claude-messages.ts +33 -10
  36. package/src/server/management/agent-settings-routes.ts +3 -1
  37. package/src/server/management/model-rows.ts +34 -4
  38. package/src/server/management/shared.ts +18 -2
  39. package/src/server/responses/adapter-continuation.ts +13 -21
  40. package/src/server/responses/adapter-delivery.ts +2 -2
  41. package/src/server/responses/adapter-dispatch.ts +10 -21
  42. package/src/server/responses/compact.ts +3 -2
  43. package/src/server/responses/core-codex-account.ts +4 -2
  44. package/src/server/responses/passthrough-delivery.ts +2 -1
  45. package/src/server/responses/request-transport.ts +2 -0
  46. package/src/server/responses/run-turn-execution.ts +25 -5
  47. package/src/server/responses/sidecar-execution.ts +9 -14
  48. package/src/web-search/loop.ts +7 -2
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Default OFF. When enabled:
5
5
  * - Sticky session affinity across requests that share a session key
6
- * - 429 cools the failed account and fails over to another eligible account
6
+ * - 429 and classified pre-output account 403s cool the failed account and fail over
7
7
  * - New sessions use `strategy` (default quota): lowest known fiveHour usage (#493),
8
8
  * round-robin, or fill-first — affinity still wins for bound sessions
9
9
  *
@@ -12,8 +12,8 @@
12
12
  *
13
13
  * Affinity is process-local (lost on restart). Cooldown uses Retry-After when present, else
14
14
  * the reset time of whichever rate-limit window upstream reports as rejected, else a default
15
- * backoff. 401/403 credential failures should set needsReauth on the store (existing OAuth
16
- * path) so the account is excluded from eligibility.
15
+ * backoff. Classified entitlement/billing 403s use a ten-minute default cooldown.
16
+ * Token refresh failures retain the existing store needsReauth policy.
17
17
  */
18
18
  import { createHash } from "node:crypto";
19
19
  import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store";
@@ -53,6 +53,7 @@ export type AnthropicRateLimitHeaders = Pick<Headers, "get">;
53
53
  const PROVIDER = "anthropic";
54
54
  /** Backoff only when upstream supplies no usable deadline. */
55
55
  const DEFAULT_COOLDOWN_MS = 60_000;
56
+ const ACCOUNT_REFUSAL_COOLDOWN_MS = 10 * 60_000;
56
57
  const AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
57
58
  const MAX_AFFINITY_ENTRIES = 2_000;
58
59
  const MAX_AFFINITY_COMPONENT_BYTES = 512;
@@ -879,7 +880,21 @@ export function rotateAnthropicAccountOn429(
879
880
  rateLimitHeaders?: AnthropicRateLimitHeaders | null,
880
881
  decision: AnthropicRouteDecision | null = null,
881
882
  ): string | null {
882
- if (!recordAnthropicAccount429(config, failedAccountId, retryAfterHeader, now, rateLimitHeaders)) return null;
883
+ return rotateAnthropicAccountOnRefusal(config, failedAccountId, 429, retryAfterHeader, sessionKey, now, rateLimitHeaders, decision);
884
+ }
885
+
886
+ /** The caller proves that a 403 is an account refusal before entering this pool policy. */
887
+ export function rotateAnthropicAccountOnRefusal(
888
+ config: OcxConfig,
889
+ failedAccountId: string,
890
+ status: 429 | 403,
891
+ retryAfterHeader: string | null | undefined,
892
+ sessionKey?: string | null,
893
+ now = Date.now(),
894
+ rateLimitHeaders?: AnthropicRateLimitHeaders | null,
895
+ decision: AnthropicRouteDecision | null = null,
896
+ ): string | null {
897
+ if (!recordAnthropicAccountRefusal(config, failedAccountId, status, retryAfterHeader, now, rateLimitHeaders)) return null;
883
898
 
884
899
  // The pool's strategy is a PROACTIVE policy. When the pool is disabled, reactive
885
900
  // presence-only recovery must not silently reactivate round-robin/fill-first merely
@@ -889,13 +904,14 @@ export function rotateAnthropicAccountOn429(
889
904
  ? pickAlternateAnthropicAccount(config, failedAccountId, now, decision)
890
905
  : pickLowestUsage(config, failedAccountId, now);
891
906
  if (!next) {
892
- console.warn(`[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}no eligible replacement; returning 429`);
907
+ console.warn(`[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}no eligible replacement; returning ${status}`);
893
908
  return null;
894
909
  }
895
910
 
896
- console.warn(
897
- `[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}429 on ${formatAnthropicAccountOrdinal(failedAccountId)}; failing over to ${formatAnthropicAccountOrdinal(next)}`,
898
- );
911
+ console.warn(status === 403
912
+ ? `[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}account unavailable (403); failing over`
913
+ : `[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}429 on ${formatAnthropicAccountOrdinal(failedAccountId)}; failing over to ${formatAnthropicAccountOrdinal(next)}`);
914
+
899
915
  return next;
900
916
  }
901
917
 
@@ -906,6 +922,18 @@ export function recordAnthropicAccount429(
906
922
  retryAfterHeader: string | null | undefined,
907
923
  now = Date.now(),
908
924
  rateLimitHeaders?: AnthropicRateLimitHeaders | null,
925
+ ): boolean {
926
+ return recordAnthropicAccountRefusal(config, failedAccountId, 429, retryAfterHeader, now, rateLimitHeaders);
927
+ }
928
+
929
+ /** Account entitlement refusals use a finite backoff; renewing a plan does not require login. */
930
+ export function recordAnthropicAccountRefusal(
931
+ config: OcxConfig,
932
+ failedAccountId: string,
933
+ status: 429 | 403,
934
+ retryAfterHeader: string | null | undefined,
935
+ now = Date.now(),
936
+ rateLimitHeaders?: AnthropicRateLimitHeaders | null,
909
937
  ): boolean {
910
938
  // Reactive 429 failover is NOT gated on the pool flag. That flag buys PROACTIVE routing --
911
939
  // session affinity, quota-ranked new-session selection, autoSwitchThreshold, strategy -- all
@@ -923,8 +951,8 @@ export function recordAnthropicAccount429(
923
951
  // without that fallback such a refusal cools for the 60s default and the exhausted
924
952
  // account is back in the rotation a minute later.
925
953
  const parsedRetry = parseRetryAfterMs(retryAfterHeader, now);
926
- const resetDerived = parsedRetry === undefined ? parseRateLimitReset(rateLimitHeaders, now) : undefined;
927
- const cooldownMs = parsedRetry ?? resetDerived?.delayMs ?? DEFAULT_COOLDOWN_MS;
954
+ const resetDerived = status === 429 && parsedRetry === undefined ? parseRateLimitReset(rateLimitHeaders, now) : undefined;
955
+ const cooldownMs = parsedRetry ?? resetDerived?.delayMs ?? (status === 403 ? ACCOUNT_REFUSAL_COOLDOWN_MS : DEFAULT_COOLDOWN_MS);
928
956
  const cooldownGeneration = noteAnthropicCooldownMutation(failedAccountId);
929
957
  upstreamHealth.set(failedAccountId, {
930
958
  cooldownUntil: now + cooldownMs,
@@ -0,0 +1,83 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { mutatePersistedConfig, readConfigDiagnostics, validateConfigCandidate } from "../config";
3
+ import { fileBackedConfigPath } from "../config/rebase-provenance";
4
+ import { getConfigPath } from "../config/paths";
5
+ import { observeModelCacheRevision } from "../codex/model-cache";
6
+ import { adoptPersistedModelDiscovery } from "../config/live-reconcile";
7
+ import type { OcxConfig } from "../types";
8
+ import { reconcileSuccessfulModelDiscoveries } from "./new-model-policy";
9
+
10
+ function inventory(config: OcxConfig): unknown {
11
+ const validated = validateConfigCandidate(config);
12
+ if (!validated.ok) return null;
13
+ const providers = Object.fromEntries(Object.entries(validated.config.providers).map(([name, provider]) => [name, {
14
+ ...provider,
15
+ // Initial selection may finish during this discovery, but a re-registration is distinct.
16
+ initialModelSelection: provider.initialModelSelection?.registrationId,
17
+ }]));
18
+ // Compare normalized persisted data, not injected fetch functions. Never log this identity.
19
+ return JSON.parse(JSON.stringify({ providers, combos: validated.config.combos,
20
+ policy: validated.config.modelDiscovery?.newModelPolicy }));
21
+ }
22
+
23
+ export function captureModelDiscoveryBaseline(config: OcxConfig) {
24
+ const identity = inventory(config);
25
+ const disk = readConfigDiagnostics();
26
+ return { identity, persisted: identity !== null && disk.source === "file"
27
+ && isDeepStrictEqual(identity, inventory(disk.config)) };
28
+ }
29
+
30
+ /** Publish new-arrival choices before returning discovery rows to any visibility consumer. */
31
+ export function finalizeModelDiscovery(
32
+ config: OcxConfig,
33
+ baseline: ReturnType<typeof captureModelDiscoveryBaseline>,
34
+ models: Iterable<{ provider: string; id: string; custom?: boolean }>,
35
+ authoritativeProviders: string[],
36
+ revisions: Map<string, string>,
37
+ /** Read consumers may publish detached policy state; writers require persisted decisions. */
38
+ publishProjection?: (projection: OcxConfig) => void,
39
+ ): boolean {
40
+ const filePath = fileBackedConfigPath(config);
41
+ const rows = [...models];
42
+ const now = new Date().toISOString();
43
+ const current = () => (filePath === undefined || filePath === getConfigPath())
44
+ && isDeepStrictEqual(baseline.identity, inventory(config))
45
+ && [...revisions].every(([provider, revision]) => observeModelCacheRevision(provider) === revision);
46
+ if (!current()) return false;
47
+ const reconcile = (target: OcxConfig) => reconcileSuccessfulModelDiscoveries({
48
+ config: target, models: rows, authoritativeProviders, now, mode: "discovery",
49
+ });
50
+ if (!baseline.persisted && filePath === undefined) {
51
+ // Standalone/synthetic callers may project their own config, never overwrite another home.
52
+ reconcile(config);
53
+ return true;
54
+ }
55
+ if (!baseline.persisted && publishProjection) {
56
+ // Drift already existed at admission. Do not add unsaved disables or advance the live
57
+ // merge baseline: a later rebase-save must preserve the operator's on-disk choices.
58
+ const projection = { ...config, modelDiscovery: structuredClone(config.modelDiscovery),
59
+ disabledModels: config.disabledModels === undefined ? undefined : [...config.disabledModels] };
60
+ reconcile(projection);
61
+ publishProjection(projection);
62
+ return true;
63
+ }
64
+ // A file-loaded instance remains file-backed after inventory drift. The locked
65
+ // freshness gate below refuses stale rows without creating unsaved live disables.
66
+ try {
67
+ const result = mutatePersistedConfig(fresh => {
68
+ if (!current() || !isDeepStrictEqual(baseline.identity, inventory(fresh))) {
69
+ return { changed: false, value: null };
70
+ }
71
+ // Re-evaluate against the latest baseline and manual choices under the mutation lock.
72
+ // A second caller then adopts a first caller's result rather than disabling an ID twice.
73
+ const changed = reconcile(fresh);
74
+ return { changed, value: { modelDiscovery: fresh.modelDiscovery, disabledModels: fresh.disabledModels } };
75
+ });
76
+ if (result.status === "unavailable" || !result.value) return false;
77
+ adoptPersistedModelDiscovery(config, result.value);
78
+ return true;
79
+ } catch {
80
+ // Do not publish an unrecorded arrival when persistence failed. The caller retries discovery.
81
+ return false;
82
+ }
83
+ }
@@ -8,6 +8,17 @@ export const MAX_RECENT_ARRIVALS_PER_PROVIDER = 50;
8
8
  export type KnownModelBaseline = NonNullable<NonNullable<OcxConfig["modelDiscovery"]>["knownModels"]>[string];
9
9
  export type NewModelPolicy = "on" | "off";
10
10
 
11
+ /**
12
+ * Which side of the reconciliation is running.
13
+ *
14
+ * `converge` (the default) is the catalog convergence cycle: it advances the removal grace and
15
+ * retires ids missing for {@link MODEL_REMOVAL_GRACE_FETCHES} cycles. `discovery` is the HTTP read
16
+ * side (GET /v1/models), which is polled far more often than convergence runs; it absorbs genuine
17
+ * arrivals and reappearances but never advances removal accounting, so a transiently absent id
18
+ * cannot be retired by repeated polls.
19
+ */
20
+ export type NewModelPolicyMode = "converge" | "discovery";
21
+
11
22
  export interface NewModelPolicyResult {
12
23
  newIds: string[];
13
24
  nextBaseline: KnownModelBaseline;
@@ -44,6 +55,8 @@ export function applyNewModelPolicy(options: {
44
55
  policy: NewModelPolicy;
45
56
  hasSelectedModels?: boolean;
46
57
  now: string;
58
+ /** See {@link NewModelPolicyMode}; defaults to `converge`. */
59
+ mode?: NewModelPolicyMode;
47
60
  }): NewModelPolicyResult {
48
61
  const discovered = [...new Set(options.discoveredIds)].sort();
49
62
  const prior = options.baseline;
@@ -60,8 +73,17 @@ export function applyNewModelPolicy(options: {
60
73
  const seen = new Set(discovered);
61
74
  const newIds = discovered.filter(id => !active.has(id) && !removed.has(id));
62
75
  const missing: Record<string, number> = {};
76
+ // Read-side discovery must not advance the removal grace: a per-poll increment would retire a
77
+ // transiently absent id after three polls. Absence keeps its recorded count (and a reappearance
78
+ // clears it) instead of moving toward `removed`.
79
+ const discovery = options.mode === "discovery";
63
80
  for (const id of active) {
64
81
  if (seen.has(id)) continue;
82
+ if (discovery) {
83
+ const priorCount = prior.missing?.[id];
84
+ if (priorCount !== undefined) missing[id] = priorCount;
85
+ continue;
86
+ }
65
87
  const count = (prior.missing?.[id] ?? 0) + 1;
66
88
  if (count >= MODEL_REMOVAL_GRACE_FETCHES) {
67
89
  active.delete(id);
@@ -99,6 +121,8 @@ export function reconcileSuccessfulModelDiscoveries(options: {
99
121
  models: Iterable<{ provider: string; id: string; custom?: boolean }>;
100
122
  authoritativeProviders: Iterable<string>;
101
123
  now: string;
124
+ /** See {@link NewModelPolicyMode}; defaults to `converge`. */
125
+ mode?: NewModelPolicyMode;
102
126
  }): boolean {
103
127
  const byProvider = new Map<string, string[]>();
104
128
  for (const model of options.models) {
@@ -118,6 +142,7 @@ export function reconcileSuccessfulModelDiscoveries(options: {
118
142
  policy: effectiveNewModelPolicy(options.config, provider),
119
143
  hasSelectedModels: (configured.selectedModels?.length ?? 0) > 0,
120
144
  now: options.now,
145
+ mode: options.mode,
121
146
  });
122
147
  if (result.overflow) continue;
123
148
  const priorBaseline = known[provider];
@@ -20,6 +20,7 @@ import {
20
20
  ANTHROPIC_MODEL_CONTEXT_WINDOWS,
21
21
  ANTHROPIC_MODEL_INPUT_MODALITIES,
22
22
  ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
23
+ ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS,
23
24
  ANTHROPIC_MODEL_REASONING_EFFORTS,
24
25
  ZAI_GLM_52_REASONING_EFFORTS,
25
26
  ZAI_GLM_53_REASONING_EFFORTS,
@@ -489,6 +490,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [
489
490
  // Codex omits max_output_tokens; without a provider budget the Anthropic adapter
490
491
  // falls back to 8192, which truncates long answers with stop_reason=max_tokens.
491
492
  defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
493
+ modelMaxOutputTokens: { ...ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS },
492
494
  defaultModel: "claude-sonnet-5",
493
495
  // Claude fast mode on the subscription lane (Claude Code `/fast`): the OAuth route accepts
494
496
  // `speed` and gates it on account entitlement (usage credits / org enablement), probed live
@@ -516,6 +518,7 @@ export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [
516
518
  modelInputModalities: { ...ANTHROPIC_MODEL_INPUT_MODALITIES },
517
519
  modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS },
518
520
  defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
521
+ modelMaxOutputTokens: { ...ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS },
519
522
  defaultModel: "claude-sonnet-5",
520
523
  fastWire: ANTHROPIC_FAST_WIRE,
521
524
  modelSupportsServiceTier: { ...ANTHROPIC_FAST_MODELS },
@@ -19,9 +19,38 @@ export const ANTHROPIC_MODEL_INPUT_MODALITIES: Record<string, string[]> = Object
19
19
  ANTHROPIC_MODELS.map(id => [id, ["text", "image"]]),
20
20
  );
21
21
  // Every current Claude family accepts at least 64k output tokens (Haiku 4.5 / Sonnet 4.x
22
- // through Opus 5 and Fable 5). Anthropic caps max_tokens per model server-side, so a
23
- // larger request never over-allocates; it only stops the 8192 truncation.
22
+ // through Opus 5 and Fable 5), so this stays the floor for a model id not listed in
23
+ // ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS below.
24
24
  export const ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 64_000;
25
+ /**
26
+ * Per-model synchronous Messages API output maxima, each read from that model's own overview
27
+ * page on platform.claude.com (`/docs/en/models/<slug>/overview`), which states the number in
28
+ * its header line and again in the Capabilities table. Deliberately NOT the 300K Message
29
+ * Batches extended-output beta, which needs `output-300k-2026-03-24` and does not apply to the
30
+ * synchronous route these providers use.
31
+ *
32
+ * Every seeded Claude model except Haiku 4.5 is 128K. Haiku 4.5 is genuinely 64K and is left
33
+ * out so it keeps inheriting ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS.
34
+ *
35
+ * Without this the 64000 default stood in for all of them, which understates the real ceiling
36
+ * by half on every Opus, Sonnet and Fable model. `resolveOutputCeiling` is the value the
37
+ * adapter budgets against when a caller declares no `max_tokens`, so a 128K-capable model
38
+ * stopped at 64000 with `stop_reason: max_tokens`; the same figure feeds the combo admission
39
+ * reserve in `checkComboTargetInputAdmission`, where understating a target's output makes its
40
+ * headroom arithmetic wrong.
41
+ */
42
+ export const ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS: Record<string, number> = {
43
+ "claude-fable-5-1": 128_000,
44
+ "claude-fable-5": 128_000,
45
+ "claude-sonnet-5-5": 128_000,
46
+ "claude-sonnet-5": 128_000,
47
+ "claude-opus-5-5": 128_000,
48
+ "claude-opus-5": 128_000,
49
+ "claude-opus-4-8": 128_000,
50
+ "claude-opus-4-7": 128_000,
51
+ "claude-opus-4-6": 128_000,
52
+ "claude-sonnet-4-6": 128_000,
53
+ };
25
54
  /**
26
55
  * The effort rungs opencodex exposes for native Anthropic models. Without this the
27
56
  * providers advertised no ladder at all, so every client that keys its effort control off
@@ -28,8 +28,8 @@ export function isXaiResponsesDestination(provider: Pick<OcxProviderConfig, "bas
28
28
  }
29
29
 
30
30
  export const XAI_GROK_COMPATIBILITY = {
31
- version: "0.2.93",
32
- userAgent: "opencodex-grok/0.2.93",
31
+ version: "1.0.46",
32
+ userAgent: "opencodex-grok/1.0.46",
33
33
  headers: {
34
34
  clientIdentifier: "x-grok-client-identifier",
35
35
  clientVersion: "x-grok-client-version",
@@ -555,6 +555,14 @@ async function anthropicNativePassthrough(
555
555
  logCtx.provider = "anthropic-native";
556
556
  logCtx.requestedModel = model;
557
557
  const finalize = createFinalRequestLog(logIds, logCtx).finish;
558
+ // Locally generated diagnostics are fixed text plus validated guard limits. Runtime
559
+ // fetch errors retain their existing client response but use a fixed stored reason.
560
+ const fail = (status: number, closeReason: PassthroughCloseReason | "non_stream", message: string, type: string, storedReason = message) => {
561
+ const safeMessage = redactSecretString(message);
562
+ logCtx.upstreamError = storedReason.slice(0, 500);
563
+ finalize(status, { closeReason });
564
+ return anthropicErrorResponse(status, safeMessage, type);
565
+ };
558
566
 
559
567
  const base = (config.claudeCode?.anthropicBaseUrl ?? "https://api.anthropic.com").replace(/\/$/, "");
560
568
  const search = new URL(req.url).search;
@@ -581,13 +589,11 @@ async function anthropicNativePassthrough(
581
589
  req.signal,
582
590
  );
583
591
  if (result.kind === "timeout") {
584
- finalize(504, { closeReason: "non_stream" });
585
- return anthropicErrorResponse(504, "anthropic passthrough timed out waiting for response headers", "timeout_error");
592
+ return fail(504, "non_stream", "anthropic passthrough timed out waiting for response headers", "timeout_error");
586
593
  }
587
594
  if (result.kind === "error") {
588
595
  const err = result.error;
589
- finalize(502, { closeReason: "non_stream" });
590
- return anthropicErrorResponse(502, `anthropic passthrough failed: ${err instanceof Error ? err.message : String(err)}`, "api_error");
596
+ return fail(502, "non_stream", `anthropic passthrough failed: ${err instanceof Error ? err.message : String(err)}`, "api_error", "anthropic passthrough failed: upstream connection error");
591
597
  }
592
598
  const upstream = result.upstream;
593
599
 
@@ -607,16 +613,13 @@ async function anthropicNativePassthrough(
607
613
  // idle/size bounds — headers are NOT yet sent here, so real statuses are available.
608
614
  const bodyResult = await readBoundedPassthroughBody(upstream, bodyGuard);
609
615
  if (bodyResult.kind === "client_cancel") {
610
- finalize(499, { closeReason: "client_cancel" });
611
- return anthropicErrorResponse(499, "client closed request during anthropic passthrough", "api_error");
616
+ return fail(499, "client_cancel", "client closed request during anthropic passthrough", "api_error");
612
617
  }
613
618
  if (bodyResult.kind === "stall") {
614
- finalize(504, { closeReason: "body_stall" });
615
- return anthropicErrorResponse(504, `anthropic passthrough body stalled: no upstream bytes for ${Math.round(bodyGuard.stallMs / 1000)}s`, "timeout_error");
619
+ return fail(504, "body_stall", `anthropic passthrough body stalled: no upstream bytes for ${Math.round(bodyGuard.stallMs / 1000)}s`, "timeout_error");
616
620
  }
617
621
  if (bodyResult.kind === "overflow") {
618
- finalize(502, { closeReason: "body_overflow" });
619
- return anthropicErrorResponse(502, `anthropic passthrough body exceeded ${bodyGuard.maxBytes} bytes`, "api_error");
622
+ return fail(502, "body_overflow", `anthropic passthrough body exceeded ${bodyGuard.maxBytes} bytes`, "api_error");
620
623
  }
621
624
  const text = bodyResult.text;
622
625
  if (upstream.ok) {
@@ -624,6 +627,21 @@ async function anthropicNativePassthrough(
624
627
  const parsed = JSON.parse(text) as { usage?: Rec };
625
628
  if (isRec(parsed?.usage)) logCtx.usage = anthropicUsageToOcx(parsed.usage);
626
629
  } catch { /* count_tokens etc. */ }
630
+ } else if (upstream.status >= 400) {
631
+ // Upstream text is untrusted: retain only a closed type vocabulary in history.
632
+ // Bound parsing work independently of the larger response-relay body limit.
633
+ const statusReason = `Provider error ${upstream.status}`;
634
+ logCtx.upstreamError = statusReason;
635
+ if (text.length <= PASSTHROUGH_ERROR_DETAIL_MAX_CHARS) {
636
+ try {
637
+ const parsed: unknown = JSON.parse(text);
638
+ if (isRec(parsed) && parsed.type === "error" && isRec(parsed.error)) {
639
+ const upstreamType = parsed.error.type;
640
+ const knownType = PASSTHROUGH_ERROR_TYPES.find(type => type === upstreamType);
641
+ if (knownType) logCtx.upstreamError = `${statusReason}: ${knownType}`;
642
+ }
643
+ } catch { /* Malformed errors keep the fixed status-only diagnostic. */ }
644
+ }
627
645
  }
628
646
  finalize(upstream.status, { closeReason: "non_stream" });
629
647
  const retryAfter = upstream.headers.get("retry-after");
@@ -633,6 +651,11 @@ async function anthropicNativePassthrough(
633
651
  });
634
652
  }
635
653
 
654
+ const PASSTHROUGH_ERROR_TYPES = [
655
+ "invalid_request_error", "authentication_error", "permission_error", "not_found_error",
656
+ "rate_limit_error", "api_error", "overloaded_error", "request_too_large",
657
+ ] as const;
658
+ const PASSTHROUGH_ERROR_DETAIL_MAX_CHARS = 64 * 1024;
636
659
  const DEFAULT_BODY_STALL_SEC = 90;
637
660
  const DEFAULT_BODY_MAX_BYTES = 64 * 1024 * 1024;
638
661
 
@@ -806,8 +806,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
806
806
  const models = await (deps.fetchAllModels ?? fetchAllModels)(config);
807
807
  // Evaluate visibility AFTER discovery: a concurrent visibility write may have completed.
808
808
  const visible = new Set(filterCatalogVisibleModels(models, config).map(catalogModelSlug).filter(slug => slug.includes("/")));
809
+ // A bare native id orders the complete picker (docs: guides/model-ordering.md).
810
+ for (const row of nativeModelRows(config)) if (!row.disabled) visible.add(row.slug);
809
811
  if (pickerOrder.some(model => !visible.has(model))) {
810
- return jsonResponse({ error: "pickerOrder must contain each visible routed model at most once" }, 400);
812
+ return jsonResponse({ error: "pickerOrder must contain visible routed or native model ids, each at most once" }, 400);
811
813
  }
812
814
  }
813
815
  }
@@ -12,6 +12,7 @@ import type { CatalogModel } from "../../codex/catalog";
12
12
  import { observeModelCacheRevision } from "../../codex/model-cache";
13
13
  import {
14
14
  captureExportConfigAdmission,
15
+ detachedConfigSnapshot,
15
16
  isExportConfigAdmissionCurrent,
16
17
  type ExportConfigAdmission,
17
18
  } from "../../config/admitted-identity";
@@ -37,6 +38,9 @@ import type { OcxConfig } from "../../types";
37
38
  import { enrichProviderFromRegistry } from "../../providers/derive";
38
39
  import { ensureCodexEntitlementFreshness } from "../../codex/model-entitlements";
39
40
  import { fetchAllModels } from "./shared";
41
+ import { CatalogGatherBusyError } from "../../codex/catalog/routed-gather";
42
+ import { captureModelDiscoveryBaseline, finalizeModelDiscovery } from "../../providers/new-model-policy-runtime";
43
+ import { reconcileSuccessfulModelDiscoveries } from "../../providers/new-model-policy";
40
44
  import { initialModelSelectionPending, pendingModelSelectionProviders } from "../../providers/initial-model-selection";
41
45
  import { catalogFastRowEligible, fastRowId } from "../fast-row";
42
46
  import { knownEffortRowIds } from "../effort-row";
@@ -275,7 +279,7 @@ export async function listManagementModelRows(
275
279
  */
276
280
  const models = options.models === undefined
277
281
  ? (await Promise.all([
278
- fetchAllModels(config, options.providerContentRevisions),
282
+ fetchAllModels(config, options.providerContentRevisions, projection => { config = projection; }),
279
283
  ensureCodexEntitlementFreshness(config, {
280
284
  waitMs: options.entitlementWaitMs ?? 3_000,
281
285
  }),
@@ -471,8 +475,10 @@ export async function loadExportModels(
471
475
  // The configuration this pass will use from beginning to end, proved to be the one on disk.
472
476
  // Without it there is nothing that may be retained, and the caller still gets its rows: only the
473
477
  // preview authority is withheld.
474
- const admission = captureExportConfigAdmission(config);
475
- const admitted = admission?.config ?? config;
478
+ let admission = captureExportConfigAdmission(config);
479
+ let admitted = admission?.config ?? config;
480
+ const discoveryBaseline = models === undefined ? captureModelDiscoveryBaseline(admitted) : null;
481
+ const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = [];
476
482
  // The gather stamps each provider as it chooses its rows, so the roster and the revisions that
477
483
  // vouch for it come from the same moment. Sampling afterwards would let a concurrent flight's
478
484
  // publication be recorded against rows it never produced.
@@ -483,10 +489,34 @@ export async function loadExportModels(
483
489
  // had, and runs alongside as it did inside the projection.
484
490
  const roster = models === undefined
485
491
  ? (await Promise.all([
486
- (await import("../../codex/catalog")).gatherRoutedModels(admitted, { providerContentRevisions: gathered }),
492
+ (await import("../../codex/catalog")).gatherRoutedModels(admitted, {
493
+ providerContentRevisions: gathered, providerModelOutcomes: outcomes,
494
+ }),
487
495
  ensureCodexEntitlementFreshness(admitted, { waitMs: 3_000 }),
488
496
  ]))[0]
489
497
  : models;
498
+ if (discoveryBaseline !== null) {
499
+ const authoritative = outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider);
500
+ if (admission !== null && isExportConfigAdmissionCurrent(admission, config)
501
+ && [...gathered].every(([provider, revision]) => observeModelCacheRevision(provider) === revision)) {
502
+ if (!finalizeModelDiscovery(config, discoveryBaseline, roster, authoritative, gathered)) {
503
+ lastExportSnapshot = null;
504
+ throw new CatalogGatherBusyError();
505
+ }
506
+ // Only our scoped discovery decision moved: bind the projection to the committed state.
507
+ admission = captureExportConfigAdmission(config);
508
+ admitted = admission?.config ?? config;
509
+ } else {
510
+ // Preserve the old-response contract after a race, without persisting stale evidence or
511
+ // retaining it as a current preview. Apply visibility policy only to a detached projection.
512
+ const projection = detachedConfigSnapshot(admitted);
513
+ if (projection === null) throw new CatalogGatherBusyError();
514
+ reconcileSuccessfulModelDiscoveries({ config: projection, models: roster,
515
+ authoritativeProviders: authoritative, now: new Date().toISOString(), mode: "discovery" });
516
+ admitted = projection;
517
+ admission = null;
518
+ }
519
+ }
490
520
  const rows = await listManagementModelRows(admitted, { models: roster });
491
521
  // Management deliberately lists the full roster so hidden models can be enabled.
492
522
  // A client picker must also honor the provider selection, not just its blocklist.
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { captureInitialSelectionBaseline, finalizeInitialModelSelection } from "../../providers/initial-model-selection-runtime";
3
+ import { captureModelDiscoveryBaseline, finalizeModelDiscovery } from "../../providers/new-model-policy-runtime";
4
+ import { CatalogGatherBusyError } from "../../codex/catalog/routed-gather";
3
5
  import { initialModelSelectionPending } from "../../providers/initial-model-selection";
4
6
  import { readFileSync } from "node:fs";
5
7
  import type { CatalogModel } from "../../codex/catalog";
@@ -266,17 +268,31 @@ export async function fetchAllModels(
266
268
  config: OcxConfig,
267
269
  /** Filled with each provider's content revision as of the moment its rows were chosen. */
268
270
  providerContentRevisions?: Map<string, string>,
271
+ /** Management renders disabled rows using this detached state after inventory drift. */
272
+ publishProjection?: (projection: OcxConfig) => void,
269
273
  ): Promise<CatalogModel[]> {
270
274
  const { gatherRoutedModels } = await import("../../codex/catalog");
271
275
  const baseline = captureInitialSelectionBaseline(config);
272
- if (!baseline) return gatherRoutedModels(config, providerContentRevisions ? { providerContentRevisions } : undefined);
276
+ const discoveryBaseline = captureModelDiscoveryBaseline(config);
277
+ const revisions = providerContentRevisions ?? new Map<string, string>();
273
278
  const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = [];
274
279
  const models = await gatherRoutedModels(config, {
275
280
  providerModelOutcomes: outcomes,
276
- ...(providerContentRevisions ? { providerContentRevisions } : {}),
281
+ providerContentRevisions: revisions,
277
282
  });
278
283
  finalizeInitialModelSelection(config, baseline, uniqueCatalogModelsForPublicList(models),
279
284
  outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider));
285
+ let publicationConfig = config;
286
+ if (!finalizeModelDiscovery(config, discoveryBaseline, models,
287
+ outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider), revisions,
288
+ projection => { publicationConfig = projection; publishProjection?.(projection); })) {
289
+ throw new CatalogGatherBusyError();
290
+ }
291
+ if (publicationConfig !== config && !publishProjection) {
292
+ // Other consumers keep their live config, so only return rows visible under the projection.
293
+ const { filterCatalogVisibleModels } = await import("../../codex/catalog");
294
+ return filterCatalogVisibleModels(models, publicationConfig);
295
+ }
280
296
  return models;
281
297
  }
282
298
 
@@ -1,3 +1,4 @@
1
+ import { rotateAnthropicAccountOnResponse } from "../../oauth/anthropic-account-refusal";
1
2
  import type { ResponsesRequestContext } from "./core-options";
2
3
  import type { PreparedResponsesRequest } from "./request-prepare";
3
4
  import type { ResponsesTransport } from "./request-transport";
@@ -34,8 +35,6 @@ import { resolveWireProtocolOverride } from "../adapter-resolve";
34
35
  import { bindRouteReasoningReplayScope } from "./core-replay";
35
36
  import {
36
37
  ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST,
37
- rotateAnthropicAccountOn429,
38
- recordAnthropicAccount429,
39
38
  getAnthropicPoolAccessSnapshot,
40
39
  formatAnthropicProviderForLog,
41
40
  } from "../../oauth/anthropic-routing";
@@ -158,6 +157,8 @@ export function createAdapterContinuations(
158
157
  const fetchTerminalGuardContinuation = async function* (
159
158
  nextParsed: OcxParsedRequest,
160
159
  initialRecoveryKind?: AttemptRecoveryKind,
160
+ // Ordinary terminal continuations have already delivered output; empty retries opt in.
161
+ allowAccountRefusal = false,
161
162
  ): AsyncGenerator<AdapterEvent> {
162
163
  let response: Response | undefined;
163
164
  let kiroRefusalPendingReplay: Response | undefined;
@@ -389,25 +390,21 @@ export function createAdapterContinuations(
389
390
  }
390
391
  }
391
392
  if (
392
- response.status === 429
393
+ (response.status === 429 || response.status === 403)
393
394
  && transportState.anthropicPoolAccountId
394
395
  && !isNonReplayableResponse(response)
395
- && transportState.anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
396
396
  ) {
397
- const nextAccountId = rotateAnthropicAccountOn429(
398
- config,
399
- transportState.anthropicPoolAccountId,
400
- response.headers.get("retry-after"),
401
- anthropicSessionKey,
402
- Date.now(),
403
- response.headers,
404
- transportState.anthropicRouteDecision,
405
- );
397
+ const nextAccountId = await rotateAnthropicAccountOnResponse(response, {
398
+ config, accountId: transportState.anthropicPoolAccountId, sessionKey: anthropicSessionKey,
399
+ decision: transportState.anthropicRouteDecision, signal: upstream.signal,
400
+ canRetry: transportState.anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST,
401
+ allowAccountRefusal,
402
+ });
406
403
  if (nextAccountId) {
407
- try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
408
404
  try {
409
405
  const admitted = await commitResolvedOAuthSelection(await getAnthropicPoolAccessSnapshot(nextAccountId));
410
406
  if (!admitted) throw new Error("OAuth selection changed during recovery");
407
+ try { void response.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
411
408
  transportState.anthropicPoolAccountId = admitted.accountId;
412
409
  transportState.anthropicPoolFailovers += 1;
413
410
  route.provider = { ...route.provider, apiKey: admitted.accessToken };
@@ -426,11 +423,6 @@ export function createAdapterContinuations(
426
423
  }
427
424
  }
428
425
  }
429
- if (response.status === 429 && transportState.anthropicPoolAccountId
430
- && transportState.anthropicPoolFailovers >= ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST) {
431
- recordAnthropicAccount429(config, transportState.anthropicPoolAccountId,
432
- response.headers.get("retry-after"), Date.now(), response.headers);
433
- }
434
426
  // Generic OAuth rotation for the continuation loop. The streaming loop grew this arm with
435
427
  // #2568 and this one did not, so an xAI/Cursor/Kimi/Copilot/Antigravity/Nous continuation
436
428
  // 429 stayed terminal even with failover fully active -- the same class of divergence the
@@ -704,14 +696,14 @@ export function createAdapterContinuations(
704
696
  };
705
697
 
706
698
  const fetchGuardedEmptyCompletionRetry = (): AsyncIterable<AdapterEvent> => {
707
- const retryEvents = fetchTerminalGuardContinuation(parsed, "empty-completion");
699
+ const retryEvents = fetchTerminalGuardContinuation(parsed, "empty-completion", true);
708
700
  return terminalGuardEnabled
709
701
  ? guardTerminalEventStream({
710
702
  parsed,
711
703
  firstEvents: retryEvents,
712
704
  adapterName: transportState.activeAdapter.name,
713
705
  maxAutoContinuations: 1,
714
- continuation: fetchTerminalGuardContinuation,
706
+ continuation: next => fetchTerminalGuardContinuation(next, undefined, !parsed.stream),
715
707
  })
716
708
  : retryEvents;
717
709
  };
@@ -110,7 +110,7 @@ export async function deliverAdapterResponse(
110
110
  firstEvents: initialEventStream,
111
111
  adapterName: transportState.activeAdapter.name,
112
112
  maxAutoContinuations: 1,
113
- continuation: fetchTerminalGuardContinuation,
113
+ continuation: next => fetchTerminalGuardContinuation(next, undefined, !parsed.stream),
114
114
  })
115
115
  : initialEventStream;
116
116
  // The empty-completion guard sits OUTSIDE the terminal guard: a completed
@@ -235,7 +235,7 @@ export async function deliverAdapterResponse(
235
235
  firstEvents: (async function* () { yield* initialEvents; })(),
236
236
  adapterName: transportState.activeAdapter.name,
237
237
  maxAutoContinuations: 1,
238
- continuation: fetchTerminalGuardContinuation,
238
+ continuation: next => fetchTerminalGuardContinuation(next, undefined, !parsed.stream),
239
239
  })) guardedEvents.push(event);
240
240
  } else {
241
241
  guardedEvents = initialEvents;