@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.
- package/gui/dist/assets/{index-J96sug5C.css → index-BFgUC17B.css} +1 -1
- package/gui/dist/assets/{index-CCfD72yq.js → index-NcAVXkST.js} +19 -19
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/raycast.svg +3 -0
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +8 -3
- package/src/adapters/openai-responses.ts +7 -0
- package/src/bridge.ts +27 -7
- package/src/claude/inbound.ts +23 -5
- package/src/claude/outbound.ts +87 -19
- package/src/cli/capabilities.ts +4 -1
- package/src/cli/dispatch.ts +1 -1
- package/src/cli/doctor.ts +2 -2
- package/src/cli/export-command.ts +22 -10
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +34 -7
- package/src/cli/integrations.ts +34 -1
- package/src/cli/provider.ts +27 -15
- package/src/cli/registry.ts +2 -2
- package/src/cli/version-skew.ts +36 -3
- package/src/clients/aside-profiles.ts +8 -7
- package/src/clients/config-export/contracts.ts +2 -1
- package/src/clients/config-export/raycast.ts +106 -0
- package/src/clients/config-export.ts +36 -0
- package/src/clients/model-presentation.ts +61 -0
- package/src/codex/catalog/sync.ts +44 -2
- package/src/codex/convergence.ts +7 -0
- package/src/generated/compatibility-version.json +59 -43
- package/src/generated/model-metadata.ts +1 -0
- package/src/images/loop.ts +10 -2
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/merge.ts +158 -25
- package/src/integrations/raycast-detect.ts +111 -0
- package/src/integrations/registry.ts +18 -0
- package/src/integrations/state.ts +82 -13
- package/src/integrations/writer.ts +46 -31
- package/src/lib/bounded-body.ts +22 -7
- package/src/oauth/anthropic-routing.ts +59 -14
- package/src/oauth/health.ts +3 -0
- package/src/providers/quota.ts +145 -9
- package/src/providers/registry.ts +31 -0
- package/src/responses/parser.ts +16 -3
- package/src/responses/reasoning-envelope.ts +3 -2
- package/src/server/grok-responses-control-frame.ts +43 -0
- package/src/server/management/config-routes.ts +6 -2
- package/src/server/management/integration-routes.ts +29 -2
- package/src/server/management/model-routes.ts +9 -1
- package/src/server/request-decompress.ts +34 -8
- package/src/server/responses/agent-task-recovery-cache.ts +43 -14
- package/src/server/responses/agent-task-recovery.ts +28 -16
- package/src/server/responses/core.ts +39 -6
- 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
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
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 (
|
|
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
|
-
|
|
243
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
}
|
package/src/lib/bounded-body.ts
CHANGED
|
@@ -212,13 +212,28 @@ export async function readBoundedResponseBytes(
|
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
*
|
|
15
|
-
*
|
|
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:
|
|
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)
|
|
121
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
|
728
|
+
cooldownSource: parsedRetry !== undefined
|
|
729
|
+
? "retry-after"
|
|
730
|
+
: resetDerived !== undefined ? "reset-derived" : "default",
|
|
686
731
|
});
|
|
687
732
|
sweepExpiredOnWrite(now);
|
|
688
733
|
clearAnthropicSessionAffinityForAccount(failedAccountId);
|
package/src/oauth/health.ts
CHANGED
|
@@ -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,
|