@bitkyc08/opencodex 2.22.0 → 2.23.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 (79) hide show
  1. package/gui/dist/assets/{index-ClEcVlFO.js → index-rFWrIE11.js} +19 -19
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/adapters/anthropic.ts +39 -7
  5. package/src/adapters/cursor/tool-definitions.ts +48 -0
  6. package/src/adapters/google.ts +18 -12
  7. package/src/adapters/openai-chat.ts +106 -13
  8. package/src/adapters/tool-call-id.ts +119 -0
  9. package/src/adapters/tool-catalog-nudge.ts +3 -0
  10. package/src/bridge.ts +16 -5
  11. package/src/chat/inbound.ts +5 -11
  12. package/src/claude/context-windows.ts +5 -1
  13. package/src/claude/desktop-3p.ts +11 -6
  14. package/src/claude/inbound.ts +39 -1
  15. package/src/claude/model-info.ts +28 -8
  16. package/src/cli/account-api.ts +5 -1
  17. package/src/cli/claude-desktop.ts +3 -0
  18. package/src/cli/config-command.ts +37 -14
  19. package/src/codex/app-server-restart-service.ts +1 -1
  20. package/src/codex/auth-api.ts +5 -0
  21. package/src/codex/auth-context.ts +43 -2
  22. package/src/codex/catalog/metadata.ts +55 -8
  23. package/src/codex/catalog/native-models.ts +32 -2
  24. package/src/codex/catalog/parsing.ts +21 -7
  25. package/src/codex/catalog/provider-fetch.ts +35 -7
  26. package/src/codex/catalog/sync.ts +40 -18
  27. package/src/codex/catalog-refresh-status.ts +21 -3
  28. package/src/codex/catalog.ts +1 -1
  29. package/src/codex/convergence-types.ts +23 -2
  30. package/src/codex/desired-state.ts +1 -1
  31. package/src/codex/inject.ts +38 -7
  32. package/src/codex/injected-marker.ts +28 -0
  33. package/src/codex/journal.ts +40 -1
  34. package/src/codex/management-convergence.ts +55 -2
  35. package/src/codex/quota-rejection.ts +61 -1
  36. package/src/codex/quota.ts +60 -6
  37. package/src/codex/routing.ts +30 -3
  38. package/src/combos/failover.ts +20 -0
  39. package/src/config.ts +271 -4
  40. package/src/generated/compatibility-version.json +86 -74
  41. package/src/grok/sync.ts +3 -1
  42. package/src/lab/artifacts/sanitize.ts +1 -1
  43. package/src/lab/live/manifest.ts +1 -1
  44. package/src/lib/codex-restart-contract.ts +1 -1
  45. package/src/lib/config-ownership.ts +1 -0
  46. package/src/lib/errors.ts +9 -0
  47. package/src/lib/lab-activation.ts +1 -1
  48. package/src/lib/optional-shutdown-hooks.ts +1 -1
  49. package/src/providers/quota.ts +10 -4
  50. package/src/providers/registry.ts +2 -2
  51. package/src/responses/parser.ts +42 -7
  52. package/src/responses/provider-opaque-metadata.ts +1 -1
  53. package/src/responses/thought-signature-replay.ts +261 -0
  54. package/src/router.ts +6 -1
  55. package/src/routing/compatibility/provider-slot.ts +1 -1
  56. package/src/routing/evaluator.ts +12 -2
  57. package/src/routing/health.ts +16 -5
  58. package/src/routing/history/schema.ts +1 -1
  59. package/src/routing/trace.ts +1 -1
  60. package/src/server/auth-cors.ts +56 -21
  61. package/src/server/chat-completions.ts +6 -2
  62. package/src/server/index.ts +5 -3
  63. package/src/server/management/agent-settings-routes.ts +26 -4
  64. package/src/server/management/context.ts +1 -1
  65. package/src/server/management/model-rows.ts +5 -0
  66. package/src/server/management/native-integration-routes.ts +4 -1
  67. package/src/server/management/provider-routes.ts +19 -0
  68. package/src/server/management/shared.ts +3 -3
  69. package/src/server/management-api.ts +13 -6
  70. package/src/server/passive-route-linker.ts +1 -1
  71. package/src/server/relay.ts +16 -0
  72. package/src/server/responses/compact.ts +10 -3
  73. package/src/server/responses/core.ts +160 -33
  74. package/src/server/responses/fetch-helpers.ts +34 -2
  75. package/src/server/responses/input-admission.ts +17 -9
  76. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  77. package/src/server/system-env.ts +4 -2
  78. package/src/service.ts +14 -7
  79. package/src/types.ts +34 -0
@@ -3,6 +3,7 @@ import { captureCatalogAdmissionSnapshot } from "./catalog-admission";
3
3
  import { convergeCodexCatalog } from "./convergence";
4
4
  import type {
5
5
  CatalogDisposition,
6
+ CatalogFailureCause,
6
7
  CatalogOnlyOutcome,
7
8
  CodexHistoryState,
8
9
  CodexObservedState,
@@ -49,6 +50,58 @@ function notEvaluatedObserved(history: CodexHistoryState): CodexObservedState {
49
50
  };
50
51
  }
51
52
 
53
+ /** Recognized errno/code tokens. Anything else is dropped rather than echoed. */
54
+ const RECOGNIZED_FAILURE_CODES: ReadonlySet<string> = new Set([
55
+ "ENOSPC", "EACCES", "EPERM", "EROFS", "ENOENT", "SQLITE_BUSY",
56
+ ]);
57
+
58
+ /**
59
+ * Reduce a caught error to an allowlisted cause (#1784).
60
+ *
61
+ * Nothing from the error text reaches the caller: `kind` is chosen from a closed set and
62
+ * `code` is only emitted when it is a recognized token. An `Error.message` routinely carries
63
+ * paths, home directories and account ids, and `redactSecretString` masks token shapes but
64
+ * none of those, so the message is never a safe thing to forward from here.
65
+ */
66
+ function catalogFailureCause(error: unknown): CatalogFailureCause {
67
+ const raw = (error as { code?: unknown } | null)?.code;
68
+ const code = typeof raw === "string" && RECOGNIZED_FAILURE_CODES.has(raw)
69
+ ? raw as CatalogFailureCause["code"]
70
+ : undefined;
71
+ if (error instanceof TypeError || error instanceof RangeError || error instanceof SyntaxError) {
72
+ return { kind: "invalid-request", ...(code ? { code } : {}) };
73
+ }
74
+ if (code === "SQLITE_BUSY") return { kind: "lock-busy", code };
75
+ if (code !== undefined) return { kind: "io", code };
76
+ return { kind: "unknown" };
77
+ }
78
+
79
+ /**
80
+ * Classify a failure that is NOT a filesystem problem.
81
+ *
82
+ * `disk` used to be the catch-all, so an operator saw "non-retryable disk failure" for a
83
+ * malformed request. Reserve `disk` for real IO and route the rest to honest reasons.
84
+ */
85
+ function classifiedCatalogFailure(error: unknown, commitBegan: boolean): CatalogDisposition {
86
+ const cause = catalogFailureCause(error);
87
+ const reason = cause.kind === "invalid-request"
88
+ ? "request-invalid" as const
89
+ : cause.kind === "lock-busy"
90
+ ? "admission" as const
91
+ : cause.kind === "io"
92
+ ? "disk" as const
93
+ : "internal" as const;
94
+ return {
95
+ status: "failed",
96
+ reason,
97
+ phase: commitBegan ? "commit" : "gather",
98
+ // Contention is the one class worth retrying unchanged.
99
+ retryable: reason === "admission",
100
+ partialWrite: commitBegan,
101
+ cause,
102
+ };
103
+ }
104
+
52
105
  function unexpectedCatalogFailure(commitBegan: boolean): CatalogDisposition {
53
106
  return {
54
107
  status: "failed",
@@ -64,7 +117,7 @@ function admissionFailure(error: unknown): CatalogDisposition {
64
117
  if (message.includes("config generation is busy") || message.includes("config generation is database")) {
65
118
  return { status: "skipped", reason: "busy", retryable: true };
66
119
  }
67
- return unexpectedCatalogFailure(false);
120
+ return classifiedCatalogFailure(error, false);
68
121
  }
69
122
 
70
123
  /** Project catalog work into the shared no-change/not-evaluated outcome shape. */
@@ -107,7 +160,7 @@ export function createManagementConvergeCodex(
107
160
  } catch (error) {
108
161
  return projectCatalogOnlyOutcome({
109
162
  changed: false,
110
- catalogRefresh: commitBegan ? unexpectedCatalogFailure(true) : admissionFailure(error),
163
+ catalogRefresh: commitBegan ? classifiedCatalogFailure(error, true) : admissionFailure(error),
111
164
  });
112
165
  }
113
166
  };
@@ -23,6 +23,63 @@ export interface CodexPreStreamRejection {
23
23
  alternateRetryEligible: boolean;
24
24
  resetCreditEligible: boolean;
25
25
  semanticCode?: CodexResetEligibleExhaustionCode;
26
+ /**
27
+ * Structured denial evidence for a 403. Present only when the upstream body names a
28
+ * workspace/entitlement denial, which proves the CREDENTIAL is valid and the account
29
+ * simply lacks access here (#1789). Status alone can never set this.
30
+ */
31
+ denial?: "workspace" | "entitlement";
32
+ }
33
+
34
+ /**
35
+ * Upstream codes that identify a WORKSPACE denial rather than a bad credential.
36
+ *
37
+ * #1789: a K12 account whose credential validates and whose WHAM usage returns 200 still
38
+ * gets 403 `codex_workspace_access_denied` on a routed prompt. Treating that as a credential
39
+ * failure tells the user to re-authenticate a credential that is already valid, and the loop
40
+ * repeats forever.
41
+ */
42
+ const WORKSPACE_DENIAL_CODES: ReadonlySet<string> = new Set([
43
+ "codex_workspace_access_denied",
44
+ "workspace_access_denied",
45
+ ]);
46
+
47
+ const ENTITLEMENT_DENIAL_CODES: ReadonlySet<string> = new Set([
48
+ "codex_entitlement_missing",
49
+ "entitlement_missing",
50
+ ]);
51
+
52
+ /** Read a structured denial code out of a 403 body. Fails closed to undefined. */
53
+ async function denialFromResponse(
54
+ response: Response,
55
+ signal?: AbortSignal,
56
+ ): Promise<"workspace" | "entitlement" | undefined> {
57
+ try {
58
+ const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true });
59
+ if (!body.displaySafe || body.truncated || !body.text.trim()) return undefined;
60
+ if (isUnsafeJsonDocument(body.text)) return undefined;
61
+ const payload = JSON.parse(body.text) as unknown;
62
+ const code = structuredDenialCode(payload);
63
+ if (code === undefined) return undefined;
64
+ if (WORKSPACE_DENIAL_CODES.has(code)) return "workspace";
65
+ if (ENTITLEMENT_DENIAL_CODES.has(code)) return "entitlement";
66
+ return undefined;
67
+ } catch {
68
+ // Same fail-closed rule as the exhaustion classifier: an unreadable body must not
69
+ // downgrade a credential failure into a workspace one.
70
+ return undefined;
71
+ }
72
+ }
73
+
74
+ /** Own-property `code` lookup at the top level or under `error`. No coercion, no accessors. */
75
+ function structuredDenialCode(payload: unknown): string | undefined {
76
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) return undefined;
77
+ const direct = (payload as Record<string, unknown>).code;
78
+ if (typeof direct === "string") return direct;
79
+ const error = (payload as Record<string, unknown>).error;
80
+ if (error === null || typeof error !== "object" || Array.isArray(error)) return undefined;
81
+ const nested = (error as Record<string, unknown>).code;
82
+ return typeof nested === "string" ? nested : undefined;
26
83
  }
27
84
 
28
85
  const RESET_ELIGIBLE_CODES: ReadonlySet<string> = new Set(RESET_ELIGIBLE_CODE_VALUES);
@@ -205,7 +262,10 @@ export async function classifyCodexPreStreamRejection(
205
262
  ): Promise<CodexPreStreamRejection> {
206
263
  const status = response.status;
207
264
  if (status === 401) return rejection(status, "authentication-error");
208
- if (status === 403) return rejection(status, "permission-error");
265
+ if (status === 403) {
266
+ const denial = await denialFromResponse(response, options.signal);
267
+ return { ...rejection(status, "permission-error"), ...(denial ? { denial } : {}) };
268
+ }
209
269
  if (TRANSIENT_SERVER_STATUSES.has(status)) return rejection(status, "transient-server-error");
210
270
  if (status !== 429 && status !== 402) return rejection(status, "other");
211
271
 
@@ -9,6 +9,20 @@ export type StoredAccountQuota = {
9
9
  monthlyPercent?: number;
10
10
  weeklyResetAt?: number;
11
11
  monthlyResetAt?: number;
12
+ /**
13
+ * A sub-day burst window, when upstream declares one (#1791).
14
+ *
15
+ * K12 and similar plans enforce a rolling 5-hour limit ALONGSIDE the weekly one.
16
+ * Not folding it into `weeklyPercent` stopped the mislabeling, but dropping it
17
+ * entirely hides a limit that genuinely blocks the account: a 429 at 100% here is
18
+ * real even while the weekly quota is untouched.
19
+ *
20
+ * `shortWindowSeconds` is retained because the duration is the only thing that makes
21
+ * this window self-describing; the slot it arrived in is not stable across plans.
22
+ */
23
+ shortPercent?: number;
24
+ shortResetAt?: number;
25
+ shortWindowSeconds?: number;
12
26
  resetCredits?: number;
13
27
  /**
14
28
  * True when `monthlyPercent` came from an explicitly-monthly PRIMARY window —
@@ -56,6 +70,19 @@ type WhamUsageWindow = {
56
70
  };
57
71
 
58
72
  const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60;
73
+ /**
74
+ * Shortest window still plausibly the WEEKLY quota (#1791).
75
+ *
76
+ * K12 and similar plans send a 5-hour primary window plus a 7-day secondary. Folding the
77
+ * primary into `weeklyPercent` reported the 5-hour bar as the weekly one and discarded the
78
+ * real weekly reading entirely, so the dashboard showed a window that reset every few hours
79
+ * and routing never saw the limit that actually gates the account.
80
+ *
81
+ * 24h is the discriminator: anything shorter is a burst window, not a weekly one. A window
82
+ * with no declared duration is unchanged, because older payloads omit `limit_window_seconds`
83
+ * and guessing there would break every legacy account.
84
+ */
85
+ const WEEKLY_WINDOW_MIN_SECONDS = 24 * 60 * 60;
59
86
  const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60;
60
87
 
61
88
  const accountQuota = new Map<string, StoredAccountQuota>();
@@ -72,13 +99,16 @@ export const CODEX_UNKNOWN_USAGE_SCORE = 101;
72
99
  export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
73
100
 
74
101
  export function isCodexQuotaExhausted(
75
- quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent"> | null,
102
+ quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "shortPercent"> | null,
76
103
  plan?: unknown,
77
104
  ): boolean {
78
105
  if (!quota) return false;
106
+ // The burst window counts on EVERY plan. It is upstream-enforced independently, so an
107
+ // account at 100% there is blocked regardless of which longer window governs its plan;
108
+ // omitting it would route traffic straight into a 429 (#1791).
79
109
  const values = codexQuotaWindowForPlan(plan) === "monthly"
80
- ? [quota.monthlyPercent]
81
- : [quota.weeklyPercent, quota.monthlyPercent];
110
+ ? [quota.monthlyPercent, quota.shortPercent]
111
+ : [quota.weeklyPercent, quota.monthlyPercent, quota.shortPercent];
82
112
  return values.some(value => typeof value === "number"
83
113
  && Number.isFinite(value)
84
114
  && value >= CODEX_EXHAUSTED_USAGE_PERCENT);
@@ -104,7 +134,7 @@ export function codexQuotaWindowForPlan(plan?: unknown): "monthly" | "weekly" {
104
134
  }
105
135
 
106
136
  export function isCompleteCodexQuotaRecoverySnapshot(
107
- quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow"> | null,
137
+ quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow" | "shortPercent"> | null,
108
138
  plan?: unknown,
109
139
  ): boolean {
110
140
  if (!quota || isCodexQuotaExhausted(quota, plan)) return false;
@@ -160,6 +190,15 @@ function hasKnownQuotaValue(quota: Omit<StoredAccountQuota, "updatedAt">): boole
160
190
  .some(value => typeof value === "number" && Number.isFinite(value));
161
191
  }
162
192
 
193
+ /** True only for a window that DECLARES a duration shorter than a day. */
194
+ function isExplicitShortWindow(window: WhamUsageWindow | null | undefined): boolean {
195
+ const seconds = window?.limit_window_seconds;
196
+ return typeof seconds === "number"
197
+ && Number.isFinite(seconds)
198
+ && seconds > 0
199
+ && seconds < WEEKLY_WINDOW_MIN_SECONDS;
200
+ }
201
+
163
202
  function isExplicitMonthlyWindow(window: WhamUsageWindow | null | undefined): boolean {
164
203
  const seconds = window?.limit_window_seconds;
165
204
  return typeof seconds === "number"
@@ -465,10 +504,25 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
465
504
  // - 선택한 방식: only an explicit primary duration of at least 28 days changes it to monthly.
466
505
  // - 다른 대안 대신 이 방식을 선택한 이유: it accepts calendar-month variance and preserves legacy payloads.
467
506
  // - 장점, 단점 및 영향: Team monthly quotas classify correctly; unknown durations remain weekly by design.
468
- const weeklyPercent = primaryIsMonthly ? secondaryPercent : primaryPercent ?? secondaryPercent;
507
+ // #1791: a primary window that declares a sub-day duration is a burst window, not the
508
+ // weekly one. Skip it so the secondary (the real 7-day window) is what lands in
509
+ // `weeklyPercent`; without this the 5-hour bar was reported as weekly and the actual
510
+ // weekly reading was dropped on the floor.
511
+ const primaryIsShort = isExplicitShortWindow(primaryWindow);
512
+ const weeklyCandidatePercent = primaryIsShort ? undefined : primaryPercent;
513
+ const weeklyCandidateResetAt = primaryIsShort ? undefined : primaryResetAt;
514
+ // Keep the burst reading instead of dropping it on the floor: it is a real limit, and
515
+ // the account is blocked when it fills even though the weekly window is fine (#1791).
516
+ if (primaryIsShort && primaryPercent !== undefined) {
517
+ quota.shortPercent = primaryPercent;
518
+ if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt;
519
+ const seconds = primaryWindow?.limit_window_seconds;
520
+ if (typeof seconds === "number" && Number.isFinite(seconds)) quota.shortWindowSeconds = seconds;
521
+ }
522
+ const weeklyPercent = primaryIsMonthly ? secondaryPercent : weeklyCandidatePercent ?? secondaryPercent;
469
523
  const weeklyResetAt = primaryIsMonthly
470
524
  ? secondaryResetAt
471
- : primaryPercent !== undefined ? primaryResetAt : secondaryResetAt;
525
+ : weeklyCandidatePercent !== undefined ? weeklyCandidateResetAt : secondaryResetAt;
472
526
  const monthlyPercent = primaryIsMonthly ? primaryPercent ?? tertiaryPercent : tertiaryPercent;
473
527
  const monthlyResetAt = primaryIsMonthly && primaryPercent !== undefined ? primaryResetAt : tertiaryResetAt;
474
528
  if (thirtyDayOnly) {
@@ -127,7 +127,8 @@ let lastReconciledGeneration = 0;
127
127
  let liveHealthAccountIds = new Set<string>();
128
128
 
129
129
  export type CodexUpstreamOutcome = number | "connect_error" | "timeout" | "connect_neutral";
130
- export type CodexUpstreamOutcomeClass = "success" | "credential" | "quota" | "transient" | "caller" | "neutral" | "unknown";
130
+ export type CodexUpstreamOutcomeClass = "success" | "credential"
131
+ | "workspace" | "quota" | "transient" | "caller" | "neutral" | "unknown";
131
132
  export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
132
133
  /**
133
134
  * Native Codex quota groups known to be independent upstream. Keep the mapping
@@ -193,6 +194,12 @@ export type CodexUpstreamOutcomeMeta = {
193
194
  now?: number;
194
195
  /** (provider, host) ledger key for account-neutral reachability failures (#914). */
195
196
  hostKey?: string;
197
+ /**
198
+ * Upstream denial evidence for a 403. A workspace/entitlement denial means the CREDENTIAL
199
+ * is fine and the account simply cannot reach this workspace, so it must not be quarantined
200
+ * for reauthentication (#1789). Absent evidence keeps the historical credential handling.
201
+ */
202
+ denial?: "workspace" | "entitlement";
196
203
  /** Stable transport code recorded alongside a neutral host failure. */
197
204
  lastFailureCode?: string;
198
205
  /** Native model selected for this request; used only for confirmed scoped quotas. */
@@ -327,7 +334,10 @@ export function computeCodexUsageScore(quota: {
327
334
  return values.length > 0 ? Math.max(...values) : CODEX_UNKNOWN_USAGE_SCORE;
328
335
  }
329
336
 
330
- export function classifyCodexUpstreamOutcome(outcome: CodexUpstreamOutcome): CodexUpstreamOutcomeClass {
337
+ export function classifyCodexUpstreamOutcome(
338
+ outcome: CodexUpstreamOutcome,
339
+ denial?: "workspace" | "entitlement",
340
+ ): CodexUpstreamOutcomeClass {
331
341
  if (outcome === "connect_neutral") return "neutral";
332
342
  if (outcome === "connect_error" || outcome === "timeout") return "transient";
333
343
  if (!Number.isFinite(outcome)) return "unknown";
@@ -337,6 +347,11 @@ export function classifyCodexUpstreamOutcome(outcome: CodexUpstreamOutcome): Cod
337
347
  // and says nothing about the credential. Relayed as the neutral class so a
338
348
  // stray 3xx cannot increment an account's transient streak.
339
349
  if (outcome >= 300 && outcome < 400) return "neutral";
350
+ // 401 is always a credential problem. A 403 is only a credential problem when nothing
351
+ // tells us otherwise: a workspace/entitlement denial (#1789) means the credential is valid
352
+ // and the account simply lacks access here, so quarantining it for reauth is wrong advice.
353
+ // Absent denial evidence the historical mapping stands, so the change fails safe.
354
+ if (outcome === 403 && denial !== undefined) return "workspace";
340
355
  if (outcome === 401 || outcome === 403) return "credential";
341
356
  // 402 Payment Required is treated as quota exhaustion for pool cooldown/failover
342
357
  // (same-request alternate retry records this outcome for the depleted account).
@@ -1596,7 +1611,7 @@ export function recordCodexUpstreamOutcome(
1596
1611
  const writerGeneration = meta.writerGeneration ?? captureConfigGeneration();
1597
1612
  if (writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)) return;
1598
1613
  const now = meta.now ?? Date.now();
1599
- const outcomeClass = classifyCodexUpstreamOutcome(outcome);
1614
+ const outcomeClass = classifyCodexUpstreamOutcome(outcome, meta.denial);
1600
1615
  const quotaScope = codexQuotaScopeForModel(meta.modelId);
1601
1616
  if (outcomeClass === "success") {
1602
1617
  const scopedProbe = meta.probeQuotaScope
@@ -1676,6 +1691,18 @@ export function recordCodexUpstreamOutcome(
1676
1691
  }
1677
1692
 
1678
1693
  const lastFailureStatus = typeof outcome === "number" ? outcome : 0;
1694
+ if (outcomeClass === "workspace") {
1695
+ // The credential is valid; this account just cannot reach this workspace (#1789).
1696
+ // Record the failure so routing stops preferring it, but do not mark it for
1697
+ // reauthentication and do not sweep its thread affinities: telling the user to
1698
+ // re-login is wrong advice that cannot fix a workspace grant.
1699
+ upstreamHealth.set(accountId, {
1700
+ consecutiveFailures: (upstreamHealth.get(accountId)?.consecutiveFailures ?? 0) + 1,
1701
+ lastFailureStatus,
1702
+ lastFailureAt: now,
1703
+ });
1704
+ return;
1705
+ }
1679
1706
  if (outcomeClass === "credential") {
1680
1707
  // 401/403 quarantines the account for reauth. That supersedes quota state
1681
1708
  // entirely: a cooldown (and any probe lease) on an unusable account is moot.
@@ -121,6 +121,26 @@ export function comboFailureDecision(
121
121
  if (isCyberPolicyCode(options?.code)) return "stop";
122
122
  const error = classifyError(status, "upstream_error", message);
123
123
  if (isCyberPolicyCode(error.code)) return "stop";
124
+ // A local input-admission refusal (#1524) says "this candidate cannot fit the request",
125
+ // not "the request is impossible": the next candidate may have a larger context window.
126
+ //
127
+ // This MUST be tested before the generic stop list below. Our own refusal message says
128
+ // "context window" -- that is what it refuses on -- and the classifier remaps that phrase,
129
+ // so checking the stop list first swallowed the signal and ended the chain. An UPSTREAM
130
+ // `context_length_exceeded` carries no admission code and still falls through to stop.
131
+ //
132
+ // Matched on the STRUCTURED code only, which classifyError now preserves for our own
133
+ // refusal. A raw substring test would additionally let any upstream override a terminal
134
+ // verdict by echoing the token in prose we do not control.
135
+ //
136
+ // Precise about what this is NOT: an upstream can still SET this code deliberately, since
137
+ // both extractors read the upstream error object. That is bounded rather than dangerous --
138
+ // an upstream already controls other hop signals (429, 5xx), and traversal is finite: policy
139
+ // tries each candidate once via `tried`, and combo excludes each attempted target. So this is
140
+ // structured-code-only, not provably local.
141
+ if (options?.code === "input_admission_refused" || error.code === "input_admission_refused") {
142
+ return "hop";
143
+ }
124
144
  if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
125
145
  return "stop";
126
146
  }