@bitkyc08/opencodex 2.45.0 → 2.46.0-preview.20260907

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 (52) hide show
  1. package/gui/dist/assets/{index-J96sug5C.css → index-BFgUC17B.css} +1 -1
  2. package/gui/dist/assets/{index-CCfD72yq.js → index-NcAVXkST.js} +19 -19
  3. package/gui/dist/index.html +2 -2
  4. package/gui/dist/provider-icons/raycast.svg +3 -0
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +8 -3
  7. package/src/adapters/openai-responses.ts +7 -0
  8. package/src/bridge.ts +27 -7
  9. package/src/claude/inbound.ts +23 -5
  10. package/src/claude/outbound.ts +87 -19
  11. package/src/cli/capabilities.ts +4 -1
  12. package/src/cli/dispatch.ts +1 -1
  13. package/src/cli/doctor.ts +2 -2
  14. package/src/cli/export-command.ts +22 -10
  15. package/src/cli/help.ts +1 -1
  16. package/src/cli/index.ts +34 -7
  17. package/src/cli/integrations.ts +34 -1
  18. package/src/cli/provider.ts +27 -15
  19. package/src/cli/registry.ts +2 -2
  20. package/src/cli/version-skew.ts +36 -3
  21. package/src/clients/aside-profiles.ts +8 -7
  22. package/src/clients/config-export/contracts.ts +2 -1
  23. package/src/clients/config-export/raycast.ts +106 -0
  24. package/src/clients/config-export.ts +36 -0
  25. package/src/clients/model-presentation.ts +61 -0
  26. package/src/codex/catalog/sync.ts +44 -2
  27. package/src/codex/convergence.ts +7 -0
  28. package/src/generated/compatibility-version.json +59 -43
  29. package/src/generated/model-metadata.ts +1 -0
  30. package/src/images/loop.ts +10 -2
  31. package/src/integrations/catalog-refresh.ts +1 -1
  32. package/src/integrations/merge.ts +158 -25
  33. package/src/integrations/raycast-detect.ts +111 -0
  34. package/src/integrations/registry.ts +18 -0
  35. package/src/integrations/state.ts +82 -13
  36. package/src/integrations/writer.ts +46 -31
  37. package/src/lib/bounded-body.ts +22 -7
  38. package/src/oauth/anthropic-routing.ts +59 -14
  39. package/src/oauth/health.ts +3 -0
  40. package/src/providers/quota.ts +145 -9
  41. package/src/providers/registry.ts +31 -0
  42. package/src/responses/parser.ts +16 -3
  43. package/src/responses/reasoning-envelope.ts +3 -2
  44. package/src/server/grok-responses-control-frame.ts +43 -0
  45. package/src/server/management/config-routes.ts +6 -2
  46. package/src/server/management/integration-routes.ts +29 -2
  47. package/src/server/management/model-routes.ts +9 -1
  48. package/src/server/request-decompress.ts +34 -8
  49. package/src/server/responses/agent-task-recovery-cache.ts +43 -14
  50. package/src/server/responses/agent-task-recovery.ts +28 -16
  51. package/src/server/responses/core.ts +39 -6
  52. package/src/web-search/loop.ts +10 -2
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Detect a Raycast install and whether Custom Providers can take effect.
3
+ *
4
+ * Custom Providers is a Raycast Pro feature: Raycast reads
5
+ * `~/.config/raycast/ai/providers.yaml` only while a subscription is active, and
6
+ * the `ai` directory itself only exists once the user has clicked "Reveal
7
+ * Providers Config" in Settings > AI. Neither fact stops the writer — the plan
8
+ * (devlog/_plan/260904_raycast_integration/000_plan.md) makes a free plan a
9
+ * WARNING, never a refusal — so this module only answers what status and the
10
+ * GUI need to explain a file that is written but ignored.
11
+ *
12
+ * Detection is read-only and injectable, like cursor-detect.ts: nothing here
13
+ * writes to the Raycast install or its preferences, and the tests run against
14
+ * stubbed deps rather than the machine they execute on.
15
+ */
16
+ import { existsSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { posix, win32 } from "node:path";
19
+
20
+ export type RaycastPlan = "pro" | "free" | "unknown";
21
+
22
+ export interface RaycastInstall {
23
+ /** The app bundle or install directory, or null when none of the well-known locations exist. */
24
+ appPath: string | null;
25
+ /** `~/.config/raycast/ai` exists — the install signal the registry uses. */
26
+ aiDirPresent: boolean;
27
+ plan: RaycastPlan;
28
+ }
29
+
30
+ export interface RaycastDetectDeps {
31
+ platform: string;
32
+ homedir: string;
33
+ env: Record<string, string | undefined>;
34
+ exists(path: string): boolean;
35
+ /** stdout of `defaults read <domain> <key>` trimmed, or null when the command fails / is unavailable. */
36
+ readDefault(domain: string, key: string): string | null;
37
+ }
38
+
39
+ /**
40
+ * A private preference used only as an advisory subscription hint, not an
41
+ * entitlement API or a condition for writes. Read through
42
+ * `defaults` rather than by parsing the plist: cfprefsd caches writes, so the
43
+ * file on disk can lag what the running app believes.
44
+ */
45
+ const RAYCAST_DEFAULTS_DOMAIN = "com.raycast.macos.v1";
46
+ const RAYCAST_SUBSCRIPTION_KEY = "subscriptions_active";
47
+
48
+ export function realRaycastDetectDeps(): RaycastDetectDeps {
49
+ return {
50
+ platform: process.platform,
51
+ homedir: homedir(),
52
+ env: process.env,
53
+ exists: path => {
54
+ try {
55
+ return existsSync(path);
56
+ } catch {
57
+ return false;
58
+ }
59
+ },
60
+ readDefault: (domain, key) => {
61
+ // `defaults` is macOS-only; elsewhere the plan is simply unknown.
62
+ if (process.platform !== "darwin") return null;
63
+ try {
64
+ const result = Bun.spawnSync(["defaults", "read", domain, key], { stdout: "pipe", stderr: "pipe" });
65
+ if (result.exitCode !== 0) return null;
66
+ return result.stdout.toString().trim();
67
+ } catch {
68
+ return null;
69
+ }
70
+ },
71
+ };
72
+ }
73
+
74
+ function appPathFor(deps: RaycastDetectDeps): string | null {
75
+ // Join with the target platform's separator so a test describing another OS
76
+ // gets that OS's paths, not the host's.
77
+ const { join } = deps.platform === "win32" ? win32 : posix;
78
+ if (deps.platform === "darwin") {
79
+ for (const candidate of ["/Applications/Raycast.app", join(deps.homedir, "Applications", "Raycast.app")]) {
80
+ if (deps.exists(candidate)) return candidate;
81
+ }
82
+ return null;
83
+ }
84
+ if (deps.platform === "win32") {
85
+ const local = deps.env.LOCALAPPDATA;
86
+ if (!local) return null;
87
+ const candidate = join(local, "Programs", "Raycast");
88
+ return deps.exists(candidate) ? candidate : null;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ function planFor(deps: RaycastDetectDeps): RaycastPlan {
94
+ if (deps.platform !== "darwin") return "unknown";
95
+ // Read once: `defaults` spawns a process, and the answer cannot change
96
+ // between two reads inside one detection.
97
+ const value = deps.readDefault(RAYCAST_DEFAULTS_DOMAIN, RAYCAST_SUBSCRIPTION_KEY);
98
+ if (value === "1") return "pro";
99
+ if (value === "0") return "free";
100
+ return "unknown";
101
+ }
102
+
103
+ export function detectRaycast(deps: RaycastDetectDeps = realRaycastDetectDeps()): RaycastInstall {
104
+ const { join } = deps.platform === "win32" ? win32 : posix;
105
+ return {
106
+ appPath: appPathFor(deps),
107
+ // Raycast ignores XDG and uses this path on every platform it ships on.
108
+ aiDirPresent: deps.exists(join(deps.homedir, ".config", "raycast", "ai")),
109
+ plan: planFor(deps),
110
+ };
111
+ }
@@ -35,6 +35,8 @@ import {
35
35
  piConfigPath,
36
36
  primeAgentDir,
37
37
  primeConfigPath,
38
+ raycastAiDir,
39
+ raycastConfigPath,
38
40
  zcodeConfigPath,
39
41
  zcodeHomeDir,
40
42
  type ExportClientId,
@@ -261,6 +263,22 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
261
263
  */
262
264
  unresolvedPathHint: (env = process.env, home = homedir()) => join(asideHomeDir(env, home), "u"),
263
265
  },
266
+ raycast: {
267
+ id: "raycast",
268
+ configPath: (env = process.env, home = homedir()) => raycastConfigPath(env, home),
269
+ /*
270
+ * The `ai` directory, not `Raycast.app`. Raycast creates it only when the
271
+ * user clicks "Reveal Providers Config" in Settings > AI, which is exactly
272
+ * the signal that Custom Providers is reachable on this install; an app
273
+ * bundle alone says nothing about the plan or the feature.
274
+ *
275
+ * No `sourcePreservingYaml`: that patcher handles block-map leaves only,
276
+ * and our entry is a SEQUENCE item, so the file is re-rendered through
277
+ * `renderYaml` (block style). The `[id=opencodex]` selector keeps the user's
278
+ * other providers in place across that re-render.
279
+ */
280
+ detectDir: (env = process.env, home = homedir()) => raycastAiDir(env, home),
281
+ },
264
282
  };
265
283
 
266
284
  export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] =
@@ -12,6 +12,7 @@ import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, type ExportModel
12
12
  import type { OcxConfig } from "../types";
13
13
  import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
14
14
  import { SNAPSHOT_RETENTION } from "./journal";
15
+ import { AmbiguousSelectorError, parseSegment, selectIndex, type PathSegment } from "./merge";
15
16
  import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership";
16
17
  import {
17
18
  protectedContributionFingerprint,
@@ -35,6 +36,7 @@ export type StateReason =
35
36
  | "unowned-key"
36
37
  /** A container we would have to write through holds a non-object value. */
37
38
  | "blocked-container"
39
+ | "ambiguous-selector"
38
40
  /** A path selector we cannot resolve, e.g. a relative OPENCLAW_CONFIG_PATH. */
39
41
  | "unresolvable-path";
40
42
 
@@ -52,11 +54,41 @@ export interface IntegrationStatus {
52
54
  retentionDegraded: boolean;
53
55
  }
54
56
 
57
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
58
+ return typeof value === "object" && value !== null && !Array.isArray(value);
59
+ }
60
+
61
+ function assertNever(segment: never): never {
62
+ throw new Error(`unknown path segment ${JSON.stringify(segment)}`);
63
+ }
64
+
65
+ /** The element a selector names, or `undefined` when none matches. */
66
+ function selectElement(items: readonly unknown[], segment: PathSegment & { kind: "select" }): unknown {
67
+ return items[selectIndex(items, segment.field, segment.value)];
68
+ }
69
+
70
+ /**
71
+ * Same segment grammar as `setPath`: a plain key reads through a record, a
72
+ * `[field=value]` selector reads through an array. Because the classifier and
73
+ * the writer share this one function, status and mutation cannot disagree
74
+ * about which element is ours.
75
+ */
55
76
  export function readPath(doc: unknown, path: readonly string[]): unknown {
56
77
  let cursor: unknown = doc;
57
- for (const key of path) {
58
- if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) return undefined;
59
- cursor = (cursor as Record<string, unknown>)[key];
78
+ for (const raw of path) {
79
+ const segment = parseSegment(raw);
80
+ switch (segment.kind) {
81
+ case "key":
82
+ if (!isPlainRecord(cursor)) return undefined;
83
+ cursor = cursor[segment.key];
84
+ break;
85
+ case "select":
86
+ if (!Array.isArray(cursor)) return undefined;
87
+ cursor = selectElement(cursor, segment);
88
+ break;
89
+ default:
90
+ return assertNever(segment);
91
+ }
60
92
  if (cursor === undefined) return undefined;
61
93
  }
62
94
  return cursor;
@@ -82,10 +114,35 @@ export function blockedContainerPath(
82
114
  doc: unknown,
83
115
  contribution: ManagedContribution,
84
116
  ): readonly string[] | null {
117
+ /*
118
+ * What a segment needs the value it walks through to BE: a record for a key,
119
+ * an array for a selector. `typeof null === "object"`, so null is excluded
120
+ * by both checks rather than walking straight into the dereference below.
121
+ */
122
+ const holds = (segment: PathSegment, value: unknown): boolean => {
123
+ switch (segment.kind) {
124
+ case "key":
125
+ return isPlainRecord(value);
126
+ case "select":
127
+ return Array.isArray(value);
128
+ default:
129
+ return assertNever(segment);
130
+ }
131
+ };
132
+ const step = (segment: PathSegment, value: unknown): unknown => {
133
+ switch (segment.kind) {
134
+ case "key":
135
+ return (value as Record<string, unknown>)[segment.key];
136
+ case "select":
137
+ return selectElement(value as readonly unknown[], segment);
138
+ default:
139
+ return assertNever(segment);
140
+ }
141
+ };
85
142
  for (const fragment of contribution.fragments) {
86
143
  let cursor: unknown = doc;
87
144
  for (let depth = 0; depth < fragment.path.length - 1; depth += 1) {
88
- const key = fragment.path[depth]!;
145
+ const segment = parseSegment(fragment.path[depth]!);
89
146
  /*
90
147
  * ONLY `undefined` means absent. A missing file parses as `{}`, so an
91
148
  * absent prefix reads `undefined` — but a parsed `null` is a value the
@@ -94,14 +151,10 @@ export function blockedContainerPath(
94
151
  * "successful" apply.
95
152
  */
96
153
  if (cursor === undefined) break;
97
- // `typeof null === "object"`, so null has to be named explicitly or it
98
- // walks straight into the dereference below.
99
- if (cursor === null || typeof cursor !== "object" || Array.isArray(cursor)) {
100
- return fragment.path.slice(0, depth);
101
- }
102
- const next = (cursor as Record<string, unknown>)[key];
154
+ if (!holds(segment, cursor)) return fragment.path.slice(0, depth);
155
+ const next = step(segment, cursor);
103
156
  if (next === undefined) break;
104
- if (typeof next !== "object" || next === null || Array.isArray(next)) {
157
+ if (!holds(parseSegment(fragment.path[depth + 1]!), next)) {
105
158
  return fragment.path.slice(0, depth + 1);
106
159
  }
107
160
  cursor = next;
@@ -239,8 +292,24 @@ export function classifyIntegration(input: {
239
292
  * Checked BEFORE `absent`: our leaf is missing in exactly this case, so the
240
293
  * absent branch would authorize an apply that replaces the user's value.
241
294
  */
242
- if (blockedContainerPath(input.parsed, input.contribution)) {
243
- return { state: "unsafe", reason: "blocked-container" };
295
+ try {
296
+ if (blockedContainerPath(input.parsed, input.contribution)) {
297
+ return { state: "unsafe", reason: "blocked-container" };
298
+ }
299
+ // Check every selector before presence/fingerprint short-circuits, including
300
+ // paths an older ownership record may remove during refresh or disable.
301
+ const paths = [
302
+ ...input.contribution.fragments.map(fragment => fragment.path),
303
+ ...(input.record?.fragmentPaths ?? []),
304
+ ];
305
+ for (const path of paths) {
306
+ if (Array.isArray(path) && path.every(key => typeof key === "string")) {
307
+ readPath(input.parsed, path);
308
+ }
309
+ }
310
+ } catch (error) {
311
+ if (!(error instanceof AmbiguousSelectorError)) throw error;
312
+ return { state: "unsafe", reason: "ambiguous-selector" };
244
313
  }
245
314
  if (!hasOurFragments(input.parsed, input.contribution)) return { state: "absent" };
246
315
 
@@ -27,7 +27,7 @@ import {
27
27
  refreshablePathsOf,
28
28
  semanticProtectedContributionFingerprint,
29
29
  } from "./ownership-policy";
30
- import { createdContainerPaths, mergeContribution, removeFragments } from "./merge";
30
+ import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, removeFragments } from "./merge";
31
31
  import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry";
32
32
  import { classifyIntegration, exportContextOf } from "./state";
33
33
  import type { IntegrationState } from "./state";
@@ -321,7 +321,9 @@ function applyOrRefreshIntegration(
321
321
  return refuse(clientId, "unsafe", "unsafe",
322
322
  classified.reason === "blocked-container"
323
323
  ? `${configPath} holds a value where opencodex would have to write a section, so applying would replace it`
324
- : `${configPath} cannot be changed safely`);
324
+ : classified.reason === "ambiguous-selector"
325
+ ? `${configPath} has more than one entry matching a managed selector`
326
+ : `${configPath} cannot be changed safely`);
325
327
  }
326
328
  /*
327
329
  * An implicit catalog sync is refresh-only. Keeping this decision inside the
@@ -352,36 +354,39 @@ function applyOrRefreshIntegration(
352
354
  * concludes the user owns it, and the replacement record forgets we made it
353
355
  * — so a later disable strands it forever.
354
356
  */
355
- const base = classified.state === "stale" && record
356
- ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc
357
- : classified.state === "conflict" && record
358
- /*
359
- * A forced overwrite of a `foreign-edit` conflict drops what the previous
360
- * record owned for the same reason a stale refresh does: the replacement
361
- * record covers the paths we are about to write, so a path the old record
362
- * owned and the new one does not would be stranded forever, unremovable by
363
- * any later disable.
364
- *
365
- * With NO record -- an `unowned-key` conflict -- there is nothing to drop and
366
- * the merge runs against the user's document directly. That is correct:
367
- * createdContainerPaths then attributes every container they already had to
368
- * them, so a later disable removes our leaves and leaves their structure
369
- * standing.
370
- */
371
- ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc
372
- : parsed;
373
- // Computed against the document as it stands BEFORE the merge: afterwards
374
- // every container exists and "did we create this?" is unanswerable.
375
- const created = createdContainerPaths(base, contribution);
376
357
  /*
377
358
  * A document can hold a value its own format cannot round-trip through our
378
359
  * renderers. That used to throw straight out of the writer and reach the
379
360
  * user as a 500 with no path and no advice; it is a refusal like any other,
380
- * and the file is untouched because this happens before any write.
361
+ * and the file is untouched because this happens before any write. The
362
+ * removal and merge sit inside the same guard: a sequence holding two
363
+ * entries our selector matches is equally unwritable, and equally untouched.
381
364
  */
382
- const nextDocument = mergeContribution(base, contribution);
365
+ let created: string[];
383
366
  let text: string;
384
367
  try {
368
+ const base = classified.state === "stale" && record
369
+ ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc
370
+ : classified.state === "conflict" && record
371
+ /*
372
+ * A forced overwrite of a `foreign-edit` conflict drops what the previous
373
+ * record owned for the same reason a stale refresh does: the replacement
374
+ * record covers the paths we are about to write, so a path the old record
375
+ * owned and the new one does not would be stranded forever, unremovable by
376
+ * any later disable.
377
+ *
378
+ * With NO record -- an `unowned-key` conflict -- there is nothing to drop and
379
+ * the merge runs against the user's document directly. That is correct:
380
+ * createdContainerPaths then attributes every container they already had to
381
+ * them, so a later disable removes our leaves and leaves their structure
382
+ * standing.
383
+ */
384
+ ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc
385
+ : parsed;
386
+ // Computed against the document as it stands BEFORE the merge: afterwards
387
+ // every container exists and "did we create this?" is unanswerable.
388
+ created = createdContainerPaths(base, contribution);
389
+ const nextDocument = mergeContribution(base, contribution);
385
390
  if (spec.sourcePreservingYaml && before !== null) {
386
391
  const value = sourcePreservingFragmentValue(contribution, spec.sourcePreservingYaml.path);
387
392
  const patched = value === undefined
@@ -401,6 +406,10 @@ function applyOrRefreshIntegration(
401
406
  text = serializeDocument(nextDocument, exportSpec.format);
402
407
  }
403
408
  } catch (error) {
409
+ if (error instanceof AmbiguousSelectorError) {
410
+ return refuse(clientId, "unsafe", "unsafe",
411
+ `${configPath} holds more than one entry matching ours, so it was left alone`);
412
+ }
404
413
  if (!(error instanceof UnserializableValueError)) throw error;
405
414
  return refuse(clientId, "unsafe", "unsafe",
406
415
  `${configPath} contains something opencodex cannot rewrite safely (${error.message}), so it was left alone`);
@@ -504,7 +513,9 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
504
513
  return refuse(clientId, "unsafe", "unsafe",
505
514
  classified.reason === "blocked-container"
506
515
  ? `${configPath} holds a value where opencodex would have to read a section, so nothing can be removed safely`
507
- : `${configPath} cannot be changed safely`);
516
+ : classified.reason === "ambiguous-selector"
517
+ ? `${configPath} has more than one entry matching a managed selector`
518
+ : `${configPath} cannot be changed safely`);
508
519
  }
509
520
 
510
521
  /*
@@ -527,11 +538,15 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
527
538
  return refuse(clientId, "unsafe", "unsafe",
528
539
  `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`);
529
540
  }
530
- const { doc, removed } = removeFragments(
531
- parsed,
532
- record!.fragmentPaths,
533
- new Set(prunableCreated),
534
- );
541
+ let doc: unknown;
542
+ let removed: boolean;
543
+ try {
544
+ ({ doc, removed } = removeFragments(parsed, record!.fragmentPaths, new Set(prunableCreated)));
545
+ } catch (error) {
546
+ if (!(error instanceof AmbiguousSelectorError)) throw error;
547
+ return refuse(clientId, "unsafe", "unsafe",
548
+ `${configPath} holds more than one entry matching ours, so nothing was removed`);
549
+ }
535
550
  if (!removed) {
536
551
  return { ok: true, changed: false, state: "absent", clientId, message: "nothing to remove" };
537
552
  }
@@ -212,13 +212,28 @@ export async function readBoundedResponseBytes(
212
212
  }
213
213
  }
214
214
 
215
- function decodeUtf8(chunks: readonly Uint8Array[], fatal: boolean): string {
215
+ // Mark only exceptions thrown by our decoder, preserving their identity and TypeError contract.
216
+ // Timeout-path flushing may fail too; retain that origin so callers do not lose the deadline.
217
+ const decodeFailures = new WeakMap<object, "invalid_utf8" | "timeout">();
218
+
219
+ export function boundedBodyDecodeFailure(error: unknown): "invalid_utf8" | "timeout" | undefined {
220
+ return error !== null && typeof error === "object" ? decodeFailures.get(error) : undefined;
221
+ }
222
+
223
+ function decodeUtf8(chunks: readonly Uint8Array[], fatal: boolean, timedOut = false): string {
216
224
  const decoder = new TextDecoder("utf-8", { fatal });
217
- let text = "";
218
- for (const chunk of chunks) text += decoder.decode(chunk, { stream: true });
219
- // Flush an incomplete trailing UTF-8 sequence deterministically.
220
- text += decoder.decode();
221
- return text;
225
+ try {
226
+ let text = "";
227
+ for (const chunk of chunks) text += decoder.decode(chunk, { stream: true });
228
+ // Flush an incomplete trailing UTF-8 sequence deterministically.
229
+ text += decoder.decode();
230
+ return text;
231
+ } catch (error) {
232
+ if (error !== null && typeof error === "object") {
233
+ decodeFailures.set(error, timedOut ? "timeout" : "invalid_utf8");
234
+ }
235
+ throw error;
236
+ }
222
237
  }
223
238
 
224
239
  /**
@@ -297,7 +312,7 @@ export async function readBoundedResponseBody(
297
312
  "TimeoutError",
298
313
  );
299
314
  return {
300
- text: decodeUtf8([retained.subarray(0, retainedBytes)], options.fatalUtf8 === true),
315
+ text: decodeUtf8([retained.subarray(0, retainedBytes)], options.fatalUtf8 === true, true),
301
316
  truncated: true,
302
317
  timedOut: true,
303
318
  totalTimedOut: outcome === TOTAL_TIMEOUT,
@@ -10,9 +10,10 @@
10
10
  * Intentionally narrower than the Codex pool: no mid-session quota rotation,
11
11
  * soft-avoid ladders, or probe leases. Anthropic OAuth is ToS-sensitive.
12
12
  *
13
- * Affinity is process-local (lost on restart). Cooldown uses Retry-After when present,
14
- * otherwise a default backoff. 401/403 credential failures should set needsReauth on the
15
- * store (existing OAuth path) so the account is excluded from eligibility.
13
+ * Affinity is process-local (lost on restart). Cooldown uses Retry-After when present, else
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.
16
17
  */
17
18
  import { createHash } from "node:crypto";
18
19
  import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store";
@@ -33,9 +34,16 @@ import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConf
33
34
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
34
35
  import { retainedUtf8Bytes } from "../lib/admission";
35
36
 
37
+ /**
38
+ * The read side of a `Headers` object, so a caller can pass the live upstream response's
39
+ * headers without this module importing anything from the server layer -- and so a test can
40
+ * hand it a plain `new Headers({...})`.
41
+ */
42
+ export type AnthropicRateLimitHeaders = Pick<Headers, "get">;
43
+
36
44
  const PROVIDER = "anthropic";
45
+ /** Backoff only when upstream supplies no usable deadline. */
37
46
  const DEFAULT_COOLDOWN_MS = 60_000;
38
- const MAX_COOLDOWN_MS = 15 * 60_000;
39
47
  const AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
40
48
  const MAX_AFFINITY_ENTRIES = 2_000;
41
49
  const MAX_AFFINITY_COMPONENT_BYTES = 512;
@@ -58,9 +66,19 @@ export interface AnthropicAccountPoolConfig {
58
66
  quotaWindow?: OcxAccountPoolQuotaWindow;
59
67
  }
60
68
 
69
+ /**
70
+ * Where a cooldown's length came from. Same vocabulary as `CodexCooldownSource`, because it
71
+ * answers the same question for the same reason: `retry-after` is upstream answering THIS
72
+ * refusal, `reset-derived` is upstream stating when the spent window reopens, and `default`
73
+ * is our own guess. The dashboard renders the first as a rate limit and the rest as quota,
74
+ * which is exactly the distinction a reset-derived cooldown carries -- collapsing it into
75
+ * `retry-after` would report a drained five-hour window as request-rate throttling.
76
+ */
77
+ type AnthropicCooldownSource = "retry-after" | "reset-derived" | "default";
78
+
61
79
  interface AccountHealth {
62
80
  cooldownUntil: number;
63
- cooldownSource: "retry-after" | "default";
81
+ cooldownSource: AnthropicCooldownSource;
64
82
  }
65
83
 
66
84
  interface AffinityEntry {
@@ -112,19 +130,38 @@ export function anthropicQuotaWindow(config: AnthropicAccountPoolConfig): OcxAcc
112
130
  return normalizeAccountPoolQuotaWindow(config.quotaWindow);
113
131
  }
114
132
 
133
+ /** Accept upstream deadlines within the runtime's date range, without a policy ceiling. */
134
+ function delayUntil(timestamp: number, now: number): number | undefined {
135
+ const delay = timestamp - now;
136
+ return Number.isFinite(new Date(timestamp).getTime()) && Number.isFinite(delay) && delay > 0
137
+ ? delay : undefined;
138
+ }
139
+
115
140
  function parseRetryAfterMs(value: string | null | undefined, now: number): number | undefined {
116
141
  const text = value?.trim();
117
142
  if (!text) return undefined;
118
143
  if (/^\d+(?:\.\d+)?$/.test(text)) {
119
144
  const seconds = Number(text);
120
- if (Number.isFinite(seconds) && seconds > 0) {
121
- return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_COOLDOWN_MS);
122
- }
145
+ if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
146
+ return delayUntil(now + Math.max(Math.ceil(seconds * 1000), 1), now);
123
147
  }
124
- const timestamp = Date.parse(text);
125
- if (!Number.isFinite(timestamp)) return undefined;
126
- const delay = timestamp - now;
127
- return delay > 0 ? Math.min(delay, MAX_COOLDOWN_MS) : undefined;
148
+ return delayUntil(Date.parse(text), now);
149
+ }
150
+
151
+ /** Only rejected windows constrain recovery; all must reopen, so take the latest reset. */
152
+ function parseRateLimitResetMs(headers: AnthropicRateLimitHeaders | null | undefined, now: number): number | undefined {
153
+ if (!headers) return undefined;
154
+ let latest: number | undefined;
155
+ for (const window of ["5h", "7d"] as const) {
156
+ if (headers.get(`anthropic-ratelimit-unified-${window}-status`)?.trim() !== "rejected") continue;
157
+ const resetSeconds = Number(headers.get(`anthropic-ratelimit-unified-${window}-reset`)?.trim());
158
+ if (!Number.isFinite(resetSeconds) || resetSeconds <= 0) continue;
159
+ const resetAt = resetSeconds * 1000;
160
+ if (delayUntil(resetAt, now) === undefined) continue;
161
+ if (latest === undefined || resetAt > latest) latest = resetAt;
162
+ }
163
+ if (latest === undefined) return undefined;
164
+ return latest - now;
128
165
  }
129
166
 
130
167
  export function getAnthropicAccountHealthSnapshot(
@@ -669,6 +706,7 @@ export function rotateAnthropicAccountOn429(
669
706
  retryAfterHeader: string | null | undefined,
670
707
  sessionKey?: string | null,
671
708
  now = Date.now(),
709
+ rateLimitHeaders?: AnthropicRateLimitHeaders | null,
672
710
  ): string | null {
673
711
  // Reactive 429 failover is NOT gated on the pool flag. That flag buys PROACTIVE routing --
674
712
  // session affinity, quota-ranked new-session selection, autoSwitchThreshold, strategy -- all
@@ -678,11 +716,18 @@ export function rotateAnthropicAccountOn429(
678
716
  // Presence is the activation rule, the same one an apiKeyPool of two keys already uses.
679
717
  if (!isAnthropicAccountPoolEnabled(config) && !hasAnthropicFailoverQuorum(now)) return null;
680
718
 
719
+ // Retry-After first: it is the header written FOR this decision. The rejected window's
720
+ // reset is the fallback, because a 429 that omits Retry-After still carries it -- and
721
+ // without that fallback such a refusal cools for the 60s default and the exhausted
722
+ // account is back in the rotation a minute later.
681
723
  const parsedRetry = parseRetryAfterMs(retryAfterHeader, now);
682
- const cooldownMs = parsedRetry ?? DEFAULT_COOLDOWN_MS;
724
+ const resetDerived = parsedRetry === undefined ? parseRateLimitResetMs(rateLimitHeaders, now) : undefined;
725
+ const cooldownMs = parsedRetry ?? resetDerived ?? DEFAULT_COOLDOWN_MS;
683
726
  upstreamHealth.set(failedAccountId, {
684
727
  cooldownUntil: now + cooldownMs,
685
- cooldownSource: parsedRetry ? "retry-after" : "default",
728
+ cooldownSource: parsedRetry !== undefined
729
+ ? "retry-after"
730
+ : resetDerived !== undefined ? "reset-derived" : "default",
686
731
  });
687
732
  sweepExpiredOnWrite(now);
688
733
  clearAnthropicSessionAffinityForAccount(failedAccountId);
@@ -184,6 +184,9 @@ export function projectStoredOAuthAccountHealth(
184
184
  needsReauth: account.needsReauth === true,
185
185
  reauthReason: account.needsReauth === true ? "refresh_failed" : undefined,
186
186
  cooldownUntilMs: anthropicSnap?.cooldownUntil,
187
+ // Same mapping as the Codex pool's `cooldownReasonFromSource`: only a Retry-After is
188
+ // request-rate throttling. A reset-derived cooldown means a usage window is spent, which
189
+ // is quota, and reporting it as a rate limit would tell the operator to retry shortly.
187
190
  cooldownReason: anthropicSnap?.cooldownSource === "retry-after" ? "rate_limit" : anthropicSnap ? "quota" : undefined,
188
191
  warningReason: detectOAuthWarning(provider, account, opts.observeOnly === true, now),
189
192
  now,