@crouton-kit/crouter 0.3.68 → 0.3.78
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/README.md +1 -1
- package/dist/builtin-memory/00-runtime-base.md +3 -10
- package/dist/builtin-memory/04-base-worker.md +18 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +639 -741
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +241 -58
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +365 -94
- package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
- package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
- package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
- package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
- package/dist/builtin-views/canvas/tui.mjs +8 -9
- package/dist/builtin-views/chat/tui.mjs +12 -12
- package/dist/builtin-views/git-pr/tui.mjs +7 -8
- package/dist/builtin-views/inbox/tui.mjs +27 -41
- package/dist/builtin-views/linkedin/tui.mjs +23 -37
- package/dist/builtin-views/prompt-review/tui.mjs +11 -11
- package/dist/builtin-views/settings/tui.mjs +11 -11
- package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +1 -0
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -7
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
- package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
- package/dist/clients/attach/attach-cmd.d.ts +1 -1
- package/dist/clients/attach/attach-cmd.js +733 -730
- package/dist/clients/attach/auth-pickers.d.ts +12 -0
- package/dist/clients/attach/auth-pickers.js +11 -0
- package/dist/clients/attach/canvas-panels.js +2 -3
- package/dist/clients/attach/chat-view.d.ts +7 -8
- package/dist/clients/attach/chat-view.js +119 -79
- package/dist/clients/attach/edit-diff-render.d.ts +6 -0
- package/dist/clients/attach/edit-diff-render.js +263 -0
- package/dist/clients/attach/input-controller.d.ts +28 -2
- package/dist/clients/attach/input-controller.js +38 -5
- package/dist/clients/attach/mermaid-render.js +5 -2
- package/dist/clients/attach/pickers.d.ts +8 -7
- package/dist/clients/attach/pickers.js +13 -17
- package/dist/clients/attach/slash-commands.d.ts +9 -0
- package/dist/clients/attach/slash-commands.js +128 -7
- package/dist/clients/attach/titled-editor.d.ts +12 -1
- package/dist/clients/attach/titled-editor.js +103 -8
- package/dist/commands/canvas-browse.js +2 -2
- package/dist/commands/memory/lint.js +39 -5
- package/dist/commands/memory/write.js +1 -0
- package/dist/commands/node.js +12 -5
- package/dist/commands/profile/new.js +30 -5
- package/dist/commands/surface-tmux-spread.js +1 -3
- package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
- package/dist/commands/sys/config.js +18 -21
- package/dist/commands/sys/setup-core.d.ts +12 -1
- package/dist/commands/sys/setup-core.js +25 -3
- package/dist/commands/sys/setup.js +166 -20
- package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
- package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
- package/dist/core/__tests__/fault-classifier.test.js +15 -0
- package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
- package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
- package/dist/core/__tests__/full/cascade-close.test.js +5 -2
- package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
- package/dist/core/__tests__/full/detach-focus.test.js +10 -5
- package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
- package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
- package/dist/core/__tests__/helpers/harness.d.ts +1 -0
- package/dist/core/__tests__/helpers/harness.js +28 -4
- package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
- package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
- package/dist/core/__tests__/review-model-floor.test.js +1 -1
- package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
- package/dist/core/__tests__/stream-watchdog.test.js +70 -0
- package/dist/core/__tests__/tmux-surface.test.js +72 -0
- package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
- package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
- package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
- package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
- package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
- package/dist/core/canvas/browse/app.js +164 -5
- package/dist/core/canvas/browse/model.d.ts +10 -6
- package/dist/core/canvas/browse/model.js +20 -12
- package/dist/core/canvas/browse/pins.d.ts +4 -0
- package/dist/core/canvas/browse/pins.js +29 -0
- package/dist/core/canvas/browse/render.d.ts +13 -1
- package/dist/core/canvas/browse/render.js +72 -15
- package/dist/core/canvas/nav-model.js +5 -11
- package/dist/core/canvas/paths.d.ts +5 -0
- package/dist/core/canvas/paths.js +7 -0
- package/dist/core/canvas/pid.d.ts +14 -10
- package/dist/core/canvas/pid.js +14 -10
- package/dist/core/config.d.ts +53 -2
- package/dist/core/config.js +228 -110
- package/dist/core/fault-classifier.js +1 -1
- package/dist/core/fs-utils.d.ts +1 -0
- package/dist/core/fs-utils.js +15 -1
- package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
- package/dist/core/keybindings/catalog.d.ts +14 -0
- package/dist/core/keybindings/catalog.js +256 -0
- package/dist/core/keybindings/index.d.ts +5 -0
- package/dist/core/keybindings/index.js +4 -0
- package/dist/core/keybindings/match.d.ts +29 -0
- package/dist/core/keybindings/match.js +99 -0
- package/dist/core/keybindings/persistence.d.ts +14 -0
- package/dist/core/keybindings/persistence.js +31 -0
- package/dist/core/keybindings/resolve.d.ts +14 -0
- package/dist/core/keybindings/resolve.js +305 -0
- package/dist/core/keybindings/types.d.ts +37 -0
- package/dist/core/keybindings/types.js +1 -0
- package/dist/core/memory-resolver.d.ts +1 -1
- package/dist/core/memory-resolver.js +10 -3
- package/dist/core/predicate.d.ts +5 -3
- package/dist/core/predicate.js +5 -3
- package/dist/core/runtime/broker-protocol.d.ts +33 -5
- package/dist/core/runtime/broker.js +165 -14
- package/dist/core/runtime/launch.d.ts +39 -6
- package/dist/core/runtime/launch.js +79 -21
- package/dist/core/runtime/naming.js +3 -3
- package/dist/core/runtime/pi-cli.d.ts +6 -0
- package/dist/core/runtime/pi-cli.js +16 -2
- package/dist/core/runtime/placement.d.ts +2 -2
- package/dist/core/runtime/placement.js +4 -1
- package/dist/core/runtime/promote.js +4 -0
- package/dist/core/runtime/recap.d.ts +3 -3
- package/dist/core/runtime/recap.js +47 -44
- package/dist/core/runtime/recycle.js +6 -1
- package/dist/core/runtime/reset.js +5 -0
- package/dist/core/runtime/revive.js +28 -18
- package/dist/core/runtime/session-list-cache.d.ts +23 -0
- package/dist/core/runtime/session-list-cache.js +270 -0
- package/dist/core/runtime/spawn.js +51 -32
- package/dist/core/runtime/stream-watchdog.d.ts +26 -0
- package/dist/core/runtime/stream-watchdog.js +75 -0
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +2 -2
- package/dist/core/runtime/tmux.d.ts +18 -15
- package/dist/core/runtime/tmux.js +216 -110
- package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
- package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
- package/dist/core/tui/host.d.ts +30 -1
- package/dist/core/tui/host.js +148 -34
- package/dist/core/view/__tests__/transport-remote.test.js +9 -6
- package/dist/core/view/contract.d.ts +17 -3
- package/dist/daemon/crtrd-cli.d.ts +1 -1
- package/dist/daemon/crtrd-cli.js +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
- package/dist/pi-extensions/canvas-recap.d.ts +4 -0
- package/dist/pi-extensions/canvas-recap.js +38 -37
- package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
- package/dist/pi-extensions/canvas-stophook.js +33 -4
- package/dist/suppress-experimental-warnings.d.ts +1 -0
- package/dist/suppress-experimental-warnings.js +15 -0
- package/dist/types.d.ts +3 -13
- package/dist/types.js +12 -36
- package/dist/web-client/assets/{index-CnF5r8ky.js → index--md2ylfi.js} +18 -17
- package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
- package/dist/web-client/index.html +3 -3
- package/dist/web-client/sw.js +1 -1
- package/package.json +11 -8
- package/scripts/postinstall.mjs +1 -1
- package/dist/pi-extensions/canvas-nav.d.ts +0 -43
- package/dist/pi-extensions/canvas-nav.js +0 -640
- package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
- package/dist/pi-extensions/widget-order-bus.js +0 -34
- package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { defaultModelLaddersConfig } from "../../../types.js";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
5
|
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
@@ -25,6 +26,11 @@ export type SubscriptionCredential = {
|
|
|
25
26
|
rateLimitedUntil: number;
|
|
26
27
|
lastAttemptAt: number;
|
|
27
28
|
lastRateLimitedAt: number;
|
|
29
|
+
// Provider account identity, captured at login for Codex (the JWT `chatgpt_account_id`).
|
|
30
|
+
// Absent on Anthropic entries (their OAuth token carries no account claim) and on legacy
|
|
31
|
+
// entries added before identity capture -- a missing `accountId` means "unknown identity"
|
|
32
|
+
// and makes no dedupe claim.
|
|
33
|
+
accountId?: string;
|
|
28
34
|
};
|
|
29
35
|
|
|
30
36
|
export type ModelRef = {
|
|
@@ -90,10 +96,11 @@ function readJsonFile<T>(path: string): T | undefined {
|
|
|
90
96
|
}
|
|
91
97
|
}
|
|
92
98
|
|
|
93
|
-
function writeJsonFile(path: string, value: unknown): void {
|
|
99
|
+
function writeJsonFile(path: string, value: unknown, mode?: number): void {
|
|
94
100
|
mkdirSync(dirname(path), { recursive: true });
|
|
95
101
|
const tmp = `${path}.${process.pid}.tmp`;
|
|
96
|
-
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
102
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, mode === undefined ? "utf8" : { encoding: "utf8", mode });
|
|
103
|
+
if (mode !== undefined) chmodSync(tmp, mode);
|
|
97
104
|
renameSync(tmp, path);
|
|
98
105
|
}
|
|
99
106
|
|
|
@@ -111,11 +118,13 @@ function normalizeCredential(value: Partial<SubscriptionCredential> & { label?:
|
|
|
111
118
|
if (!label) return undefined;
|
|
112
119
|
const refresh = typeof value.refresh === "string" ? value.refresh.trim() : "";
|
|
113
120
|
const access = typeof value.access === "string" ? value.access.trim() : "";
|
|
121
|
+
const accountId = typeof value.accountId === "string" ? value.accountId.trim() : "";
|
|
114
122
|
const expires = Number(value.expires);
|
|
115
123
|
const rateLimitedUntil = Number(value.rateLimitedUntil);
|
|
116
124
|
return {
|
|
117
125
|
label,
|
|
118
126
|
...(refresh && access ? { refresh, access, expires: Number.isFinite(expires) ? expires : 0 } : {}),
|
|
127
|
+
...(accountId ? { accountId } : {}),
|
|
119
128
|
rateLimitedUntil: Number.isFinite(rateLimitedUntil) ? rateLimitedUntil : 0,
|
|
120
129
|
lastAttemptAt: normalizeTiming(value.lastAttemptAt),
|
|
121
130
|
lastRateLimitedAt: normalizeTiming(value.lastRateLimitedAt),
|
|
@@ -131,32 +140,60 @@ function getCurrentModelRef(model?: { provider: string; id: string }): ModelRef
|
|
|
131
140
|
return isManagedProvider(model?.provider) ? { providerId: model.provider, modelId: model.id } : undefined;
|
|
132
141
|
}
|
|
133
142
|
|
|
134
|
-
type AuthFileData = Record<string, { type?: string }>;
|
|
143
|
+
type AuthFileData = Record<string, { type?: string; accountId?: unknown }>;
|
|
135
144
|
|
|
136
|
-
function readAuthCredential(providerId: ManagedProviderId): { type?: string } | undefined {
|
|
145
|
+
function readAuthCredential(providerId: ManagedProviderId): { type?: string; accountId?: string } | undefined {
|
|
137
146
|
return new FileAuthStorageBackend().withLock((current) => {
|
|
138
147
|
if (!current) return { result: undefined };
|
|
139
148
|
const data = JSON.parse(current) as AuthFileData;
|
|
140
|
-
|
|
149
|
+
const record = data[providerId];
|
|
150
|
+
if (!record) return { result: undefined };
|
|
151
|
+
const accountId = typeof record.accountId === "string" && record.accountId.trim() ? record.accountId.trim() : undefined;
|
|
152
|
+
return { result: { type: record.type, accountId } };
|
|
141
153
|
});
|
|
142
154
|
}
|
|
143
155
|
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
156
|
+
// Reconciles the METADATA-ONLY pool entry for the default label (`label === providerId`)
|
|
157
|
+
// against auth.json, which is the source of truth for the default slot's identity. auth.json
|
|
158
|
+
// is read through FileAuthStorageBackend's real lock so a held auth.json lock or parse failure
|
|
159
|
+
// is surfaced instead of silently pretending the default credential is absent. Gated
|
|
160
|
+
// specifically on an OAUTH auth.json record (`type === "oauth"`) rather than the presence of
|
|
161
|
+
// any credential, because an api_key credential has no refresh token -- a metadata-only
|
|
162
|
+
// "default" slot seeded for one would be a slot `refreshDefaultSlotCredential` can never
|
|
163
|
+
// actually refresh; with no oauth record the pool is left exactly as-is (never seeded, never
|
|
164
|
+
// stripped). When an oauth record exists this ALWAYS re-derives the default entry's identity:
|
|
165
|
+
// it seeds a missing entry and rewrites an existing entry's `accountId` to match auth.json's
|
|
166
|
+
// (dropping it when auth.json carries none), preserving that entry's cooldown/timing fields.
|
|
167
|
+
// A NEW array reference is returned only when something actually changed -- an in-sync pool
|
|
168
|
+
// returns the SAME reference, which is what lets `readSubscriptionPool`'s heal-trigger and the
|
|
169
|
+
// mutate primitive's change detection stay sound. No token is ever copied into the pool: the
|
|
170
|
+
// value lives exclusively in auth.json. `accountId` IS identity metadata, so the default slot
|
|
171
|
+
// participates in duplicate detection and `/provider-sub list` rendering like an explicit account.
|
|
154
172
|
function ensureDefaultSubscriptionMetadata(providerId: ManagedProviderId, pool: SubscriptionCredential[]): SubscriptionCredential[] {
|
|
155
|
-
if (pool.some((entry) => entry.label === providerId)) return pool;
|
|
156
173
|
const authCred = readAuthCredential(providerId);
|
|
157
174
|
if (authCred?.type !== "oauth") return pool;
|
|
158
|
-
const
|
|
159
|
-
|
|
175
|
+
const desired = authCred.accountId;
|
|
176
|
+
const idx = pool.findIndex((entry) => entry.label === providerId);
|
|
177
|
+
if (idx === -1) {
|
|
178
|
+
const seeded = normalizeCredential({
|
|
179
|
+
label: providerId,
|
|
180
|
+
...(desired ? { accountId: desired } : {}),
|
|
181
|
+
rateLimitedUntil: 0,
|
|
182
|
+
lastAttemptAt: 0,
|
|
183
|
+
lastRateLimitedAt: 0,
|
|
184
|
+
});
|
|
185
|
+
return seeded ? [...pool, seeded] : pool;
|
|
186
|
+
}
|
|
187
|
+
const existing = pool[idx];
|
|
188
|
+
if ((existing.accountId ?? undefined) === (desired ?? undefined)) return pool;
|
|
189
|
+
const next = [...pool];
|
|
190
|
+
if (desired) {
|
|
191
|
+
next[idx] = { ...existing, accountId: desired };
|
|
192
|
+
} else {
|
|
193
|
+
const { accountId: _drop, ...rest } = existing;
|
|
194
|
+
next[idx] = rest;
|
|
195
|
+
}
|
|
196
|
+
return next;
|
|
160
197
|
}
|
|
161
198
|
|
|
162
199
|
// The default label's value lives exclusively in auth.json, so its pool entry must never
|
|
@@ -180,23 +217,98 @@ export function isManagedProvider(providerId: string | undefined): providerId is
|
|
|
180
217
|
return providerId === ANTHROPIC_PROVIDER_ID || providerId === OPENAI_CODEX_PROVIDER_ID;
|
|
181
218
|
}
|
|
182
219
|
|
|
220
|
+
/** The `modelLadders` CONFIG key for a runtime provider id -- `openai-codex`
|
|
221
|
+
* (the runtime/auth id) maps to the `openai` ladder key used in
|
|
222
|
+
* ~/.crouter/config.json. Callers building a repair command that a user can
|
|
223
|
+
* paste into `crtr sys config set modelLadders.<key>...` must use THIS key,
|
|
224
|
+
* not the runtime provider id. */
|
|
225
|
+
export function getLadderProviderId(providerId: ManagedProviderId): LadderProviderId {
|
|
226
|
+
return PROVIDERS[providerId].ladderProviderId;
|
|
227
|
+
}
|
|
228
|
+
|
|
183
229
|
export function getProviderLabel(providerId: ManagedProviderId): string {
|
|
184
230
|
return PROVIDERS[providerId].label;
|
|
185
231
|
}
|
|
186
232
|
|
|
233
|
+
// The ONE locked read/modify/write primitive for a provider pool file (design §1). Acquires
|
|
234
|
+
// a cross-process lock ON THE POOL FILE via the same proper-lockfile-backed backend pi uses
|
|
235
|
+
// for auth.json, re-reads + normalizes the pool UNDER the lock, applies the caller's pure
|
|
236
|
+
// transform, and persists atomically (tmp+rename, mode 0600) before releasing. Every write to
|
|
237
|
+
// a pool file goes through here -- that is what finally makes the causal cooldown guards sound
|
|
238
|
+
// (they compare against fresh-under-lock state, not a stale snapshot).
|
|
239
|
+
//
|
|
240
|
+
// `fn` returning `undefined` means "no change, skip the write" (used by the timestamp guards).
|
|
241
|
+
// `withLock` seeds a missing file with `{}` (an OBJECT, not an array) -- normalizeCredentials
|
|
242
|
+
// coerces any non-array to [] so that first-lock seed is harmless; do not "fix" it into a parse
|
|
243
|
+
// error. We do our OWN atomic write inside the callback and return `next: undefined` so the
|
|
244
|
+
// backend's non-atomic writeFileSync path never runs and lock-free readers always observe a
|
|
245
|
+
// whole snapshot.
|
|
246
|
+
//
|
|
247
|
+
// Lock ordering (contract): pool lock -> auth lock is legal (seeding reads auth.json under its
|
|
248
|
+
// own FileAuthStorageBackend lock while holding the pool lock, via ensureDefaultSubscriptionMetadata
|
|
249
|
+
// -> readAuthCredential). NEVER acquire a pool lock inside an auth-lock callback. Throws (rather
|
|
250
|
+
// than proceeding unlocked) if lock acquisition is exhausted -- callers surface that as a visible
|
|
251
|
+
// error, never a silent lost write.
|
|
252
|
+
export function mutateSubscriptionPool(
|
|
253
|
+
providerId: ManagedProviderId,
|
|
254
|
+
fn: (pool: SubscriptionCredential[]) => SubscriptionCredential[] | undefined,
|
|
255
|
+
): SubscriptionCredential[] {
|
|
256
|
+
const poolFile = PROVIDERS[providerId].poolFile;
|
|
257
|
+
const backend = new FileAuthStorageBackend(poolFile);
|
|
258
|
+
return backend.withLock((current) => {
|
|
259
|
+
let parsed: unknown;
|
|
260
|
+
try {
|
|
261
|
+
parsed = current ? JSON.parse(current) : undefined;
|
|
262
|
+
} catch {
|
|
263
|
+
parsed = undefined;
|
|
264
|
+
}
|
|
265
|
+
const { pool: scrubbed, changed: wasScrubbed } = scrubDefaultValue(providerId, normalizeCredentials(parsed));
|
|
266
|
+
const base = ensureDefaultSubscriptionMetadata(providerId, scrubbed);
|
|
267
|
+
const transformed = fn(base);
|
|
268
|
+
if (transformed === undefined) {
|
|
269
|
+
assertDefaultAccountAvailable(base, providerId);
|
|
270
|
+
// A causal timestamp guard may reject the caller's update while auth-driven identity
|
|
271
|
+
// reconciliation or token scrubbing still needs to heal the stored metadata.
|
|
272
|
+
if (wasScrubbed || base !== scrubbed) {
|
|
273
|
+
const healed = scrubDefaultValue(providerId, normalizeCredentials(base)).pool;
|
|
274
|
+
writeJsonFile(poolFile, healed, 0o600);
|
|
275
|
+
return { result: healed };
|
|
276
|
+
}
|
|
277
|
+
return { result: base };
|
|
278
|
+
}
|
|
279
|
+
const normalized = scrubDefaultValue(providerId, normalizeCredentials(transformed)).pool;
|
|
280
|
+
// Reconciliation may have projected a new default identity from auth.json. Validate the
|
|
281
|
+
// final candidate while the pool lock is still held, after the transform so a removal or
|
|
282
|
+
// default-account switch can repair an externally-created collision instead of bricking it.
|
|
283
|
+
assertDefaultAccountAvailable(normalized, providerId);
|
|
284
|
+
writeJsonFile(poolFile, normalized, 0o600);
|
|
285
|
+
return { result: normalized };
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
187
289
|
export function readSubscriptionPool(providerId: ManagedProviderId): SubscriptionCredential[] {
|
|
188
290
|
const poolFile = PROVIDERS[providerId].poolFile;
|
|
189
291
|
const raw = normalizeCredentials(readJsonFile<unknown>(poolFile));
|
|
190
292
|
const { pool: scrubbed, changed: wasScrubbed } = scrubDefaultValue(providerId, raw);
|
|
191
293
|
const withDefaultSlot = ensureDefaultSubscriptionMetadata(providerId, scrubbed);
|
|
192
|
-
|
|
294
|
+
// Reads are lock-free (a whole-file tmp+rename write means a reader always sees a complete
|
|
295
|
+
// snapshot). Only when the on-disk pool actually needs self-healing -- a stray default-slot
|
|
296
|
+
// value to scrub, or default metadata to reconcile with auth.json -- do we take the lock and
|
|
297
|
+
// re-derive the healed pool from fresh state through the write primitive.
|
|
298
|
+
// A source-of-truth auth change can reconcile the default slot into an explicit account.
|
|
299
|
+
// Force that case through the locked mutation path too: it either validates a fresh snapshot
|
|
300
|
+
// and persists a unique identity, or throws without returning/writing a duplicate pool.
|
|
301
|
+
if (wasScrubbed || withDefaultSlot !== scrubbed || findDefaultAccountCollision(withDefaultSlot, providerId)) {
|
|
302
|
+
return mutateSubscriptionPool(providerId, (pool) => pool);
|
|
303
|
+
}
|
|
193
304
|
return withDefaultSlot;
|
|
194
305
|
}
|
|
195
306
|
|
|
307
|
+
// Seed/replace the entire pool under the lock. Production mutation goes through the specific
|
|
308
|
+
// mutators below (each a thin transform over `mutateSubscriptionPool`); this whole-array replace
|
|
309
|
+
// exists for test seeding and is still locked, never an unlocked write surface.
|
|
196
310
|
export function writeSubscriptionPool(providerId: ManagedProviderId, next: SubscriptionCredential[]): SubscriptionCredential[] {
|
|
197
|
-
|
|
198
|
-
writeJsonFile(PROVIDERS[providerId].poolFile, normalized);
|
|
199
|
-
return normalized;
|
|
311
|
+
return mutateSubscriptionPool(providerId, () => next);
|
|
200
312
|
}
|
|
201
313
|
|
|
202
314
|
function resolveCredentialIndex(pool: SubscriptionCredential[], ref: string): number {
|
|
@@ -208,39 +320,210 @@ function resolveCredentialIndex(pool: SubscriptionCredential[], ref: string): nu
|
|
|
208
320
|
return pool.findIndex((entry) => entry.label.toLowerCase() === needle);
|
|
209
321
|
}
|
|
210
322
|
|
|
323
|
+
// Merge an incoming upsert onto an existing same-label entry (design §1). Credential VALUE
|
|
324
|
+
// fields always come from the incoming entry (that is the point of the upsert); timing/cooldown
|
|
325
|
+
// state is reconciled causally so a refresh/reauth upsert built from a snapshot taken before a
|
|
326
|
+
// concurrent 429 can never erase that newer cooldown. The `>=` on `lastRateLimitedAt` is
|
|
327
|
+
// deliberate: reauth clears a cooldown after a successful re-login while preserving
|
|
328
|
+
// `lastRateLimitedAt`, so its equal-timestamp write wins and clears -- but a 429 that landed
|
|
329
|
+
// AFTER the reauth snapshot has a strictly greater `lastRateLimitedAt` and survives.
|
|
330
|
+
function mergeUpsert(existing: SubscriptionCredential, incoming: SubscriptionCredential): SubscriptionCredential {
|
|
331
|
+
const cooldownFromIncoming = incoming.lastRateLimitedAt >= existing.lastRateLimitedAt;
|
|
332
|
+
const accountId = incoming.accountId ?? existing.accountId;
|
|
333
|
+
return {
|
|
334
|
+
...incoming,
|
|
335
|
+
...(accountId ? { accountId } : {}),
|
|
336
|
+
lastAttemptAt: Math.max(existing.lastAttemptAt, incoming.lastAttemptAt),
|
|
337
|
+
lastRateLimitedAt: cooldownFromIncoming ? incoming.lastRateLimitedAt : existing.lastRateLimitedAt,
|
|
338
|
+
rateLimitedUntil: cooldownFromIncoming ? incoming.rateLimitedUntil : existing.rateLimitedUntil,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
211
342
|
export function upsertSubscription(providerId: ManagedProviderId, next: SubscriptionCredential): SubscriptionCredential[] {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
343
|
+
return mutateSubscriptionPool(providerId, (pool) => {
|
|
344
|
+
const index = pool.findIndex((entry) => entry.label.toLowerCase() === next.label.toLowerCase());
|
|
345
|
+
if (index === -1) return [...pool, next];
|
|
346
|
+
const merged = [...pool];
|
|
347
|
+
merged[index] = mergeUpsert(pool[index], next);
|
|
348
|
+
return merged;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Thrown from inside the locked add transform when a label or Codex account already exists. A
|
|
353
|
+
// typed error so the /provider-sub handler surfaces a precise, user-facing message while the
|
|
354
|
+
// uniqueness decision itself stays atomic under the pool lock.
|
|
355
|
+
export class DuplicateSubscriptionError extends Error {
|
|
356
|
+
constructor(message: string) {
|
|
357
|
+
super(message);
|
|
358
|
+
this.name = "DuplicateSubscriptionError";
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// The ONE account-uniqueness gate, shared by every identity write (explicit add, explicit
|
|
363
|
+
// reauth, default (re)login). Called INSIDE a `mutateSubscriptionPool` transform so it sees
|
|
364
|
+
// fresh-under-lock state. `exceptLabel` is the slot being updated -- excluded from the scan so
|
|
365
|
+
// a reauth/relogin of a slot into the SAME account it already holds is not a self-collision.
|
|
366
|
+
// A missing `accountId` makes no dedupe claim (Anthropic has no account claim, and legacy
|
|
367
|
+
// entries predate identity capture), so it is a no-op. Throws the typed error the handler
|
|
368
|
+
// surfaces to the user.
|
|
369
|
+
function findAccountCollision(pool: SubscriptionCredential[], accountId: string | undefined, exceptLabel: string): SubscriptionCredential | undefined {
|
|
370
|
+
if (!accountId) return undefined;
|
|
371
|
+
return pool.find((entry) => entry.label.toLowerCase() !== exceptLabel.toLowerCase() && entry.accountId === accountId);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function assertAccountAvailable(pool: SubscriptionCredential[], providerId: ManagedProviderId, accountId: string | undefined, exceptLabel: string): void {
|
|
375
|
+
const collision = findAccountCollision(pool, accountId, exceptLabel);
|
|
376
|
+
if (collision) {
|
|
377
|
+
throw new DuplicateSubscriptionError(
|
|
378
|
+
`This ${getProviderLabel(providerId)} account is already added as “${collision.label}” — it shares the same quota, so a second slot adds no redundancy.`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function findDefaultAccountCollision(pool: SubscriptionCredential[], providerId: ManagedProviderId): SubscriptionCredential | undefined {
|
|
384
|
+
const defaultSlot = pool.find((entry) => entry.label === providerId);
|
|
385
|
+
return findAccountCollision(pool, defaultSlot?.accountId, providerId);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function assertDefaultAccountAvailable(pool: SubscriptionCredential[], providerId: ManagedProviderId): void {
|
|
389
|
+
const defaultSlot = pool.find((entry) => entry.label === providerId);
|
|
390
|
+
assertAccountAvailable(pool, providerId, defaultSlot?.accountId, providerId);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Atomically append a NEW explicit subscription (non-default label), enforcing label AND Codex
|
|
394
|
+
// account uniqueness INSIDE the pool lock (design finding 2). The long OAuth flow stays outside
|
|
395
|
+
// the lock; only this final validate-then-append is serialized, so two brokers that both finish
|
|
396
|
+
// OAuth for the same account cannot each slip a second label onto one quota. Account uniqueness
|
|
397
|
+
// also catches a collision with the default slot, whose identity is seeded from auth.json.
|
|
398
|
+
export function addSubscription(providerId: ManagedProviderId, entry: SubscriptionCredential): SubscriptionCredential[] {
|
|
399
|
+
return mutateSubscriptionPool(providerId, (pool) => {
|
|
400
|
+
// Normalize the candidate label BEFORE the locked uniqueness check: `normalizeCredentials`
|
|
401
|
+
// trims on persist, so a raw " work " that passed an untrimmed check would land as a
|
|
402
|
+
// duplicate "work". Reject an empty/whitespace label here too -- the storage primitive
|
|
403
|
+
// stays defensive regardless of what the handler validated.
|
|
404
|
+
const label = entry.label.trim();
|
|
405
|
+
if (!label) throw new Error(`${getProviderLabel(providerId)} subscription label cannot be empty`);
|
|
406
|
+
if (pool.some((existing) => existing.label.toLowerCase() === label.toLowerCase())) {
|
|
407
|
+
throw new DuplicateSubscriptionError(`${getProviderLabel(providerId)} subscription already exists: ${label}`);
|
|
408
|
+
}
|
|
409
|
+
assertAccountAvailable(pool, providerId, entry.accountId, label);
|
|
410
|
+
return [...pool, { ...entry, label }];
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Identity-checked upsert for an EXPLICIT slot's reauth (label !== providerId). Same merge
|
|
415
|
+
// semantics as `upsertSubscription` (append-or-`mergeUpsert`, preserving cooldown causality),
|
|
416
|
+
// but gated by `assertAccountAvailable` first: reauthenticating a slot into an account already
|
|
417
|
+
// held by ANOTHER slot (default or explicit) throws instead of creating a duplicate quota entry.
|
|
418
|
+
// `next.label` is excluded from the uniqueness scan so a reauth into the slot's OWN account passes.
|
|
419
|
+
export function upsertSubscriptionWithUniqueAccount(providerId: ManagedProviderId, next: SubscriptionCredential): SubscriptionCredential[] {
|
|
420
|
+
return mutateSubscriptionPool(providerId, (pool) => {
|
|
421
|
+
assertAccountAvailable(pool, providerId, next.accountId, next.label);
|
|
422
|
+
const index = pool.findIndex((entry) => entry.label.toLowerCase() === next.label.toLowerCase());
|
|
423
|
+
if (index === -1) return [...pool, next];
|
|
424
|
+
const merged = [...pool];
|
|
425
|
+
merged[index] = mergeUpsert(pool[index], next);
|
|
426
|
+
return merged;
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// The token-bearing half of a default-slot (re)login, destined for auth.json ONLY -- never the
|
|
431
|
+
// pool (the default pool entry is metadata-only). `accountId` is optional (Anthropic carries none).
|
|
432
|
+
export type DefaultSlotLogin = {
|
|
433
|
+
refresh: string;
|
|
434
|
+
access: string;
|
|
435
|
+
expires: number;
|
|
436
|
+
accountId?: string;
|
|
437
|
+
// The default entry's rate-limit timestamp observed before OAuth began. A newer timestamp
|
|
438
|
+
// found under the commit lock belongs to a concurrent 429 and must survive a same-account login.
|
|
439
|
+
lastRateLimitedAt: number;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// Persist the default slot's freshly-logged-in credential into auth.json under the auth lock.
|
|
443
|
+
// Called ONLY from `commitDefaultIdentity`, while the pool lock is already held, so the effective
|
|
444
|
+
// order is pool-lock -> auth-lock (the legal direction). Writes the full oauth record (tokens +
|
|
445
|
+
// optional accountId); a blank/whitespace accountId is dropped.
|
|
446
|
+
function persistDefaultAuthRecordSync(providerId: ManagedProviderId, login: DefaultSlotLogin): void {
|
|
447
|
+
const accountId = login.accountId?.trim() || undefined;
|
|
448
|
+
new FileAuthStorageBackend().withLock((current) => {
|
|
449
|
+
// Corrupt credential storage is never equivalent to an empty file: parsing must fail before
|
|
450
|
+
// either store changes rather than silently deleting every other provider's credential.
|
|
451
|
+
const data = current ? (JSON.parse(current) as Record<string, unknown>) : {};
|
|
452
|
+
data[providerId] = { type: "oauth", refresh: login.refresh, access: login.access, expires: login.expires, ...(accountId ? { accountId } : {}) };
|
|
453
|
+
return { result: undefined, next: `${JSON.stringify(data, null, 2)}\n` };
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Atomically commit a (re)login of the DEFAULT slot (design finding 2). Under the pool lock it:
|
|
458
|
+
// (1) validates account-uniqueness EXCLUDING the default slot -- throwing BEFORE any auth write,
|
|
459
|
+
// so a rejection never partially switches auth.json; (2) persists the credential to auth.json
|
|
460
|
+
// under the nested auth lock (pool -> auth order); (3) reflects the new identity and causally
|
|
461
|
+
// reconciles the cooldown on the metadata-only default pool entry (NO tokens copied -- the entry
|
|
462
|
+
// stays metadata-only; scrubDefaultValue enforces this on write regardless). Seeds the default entry
|
|
463
|
+
// if it is absent. The account uniqueness catches a collision with any explicit slot.
|
|
464
|
+
export function commitDefaultIdentity(providerId: ManagedProviderId, login: DefaultSlotLogin): SubscriptionCredential[] {
|
|
465
|
+
const accountId = login.accountId?.trim() || undefined;
|
|
466
|
+
return mutateSubscriptionPool(providerId, (pool) => {
|
|
467
|
+
assertAccountAvailable(pool, providerId, accountId, providerId);
|
|
468
|
+
persistDefaultAuthRecordSync(providerId, login);
|
|
469
|
+
const index = pool.findIndex((entry) => entry.label === providerId);
|
|
470
|
+
const now = Date.now();
|
|
471
|
+
const existing = index === -1 ? undefined : pool[index];
|
|
472
|
+
// Cooldowns belong to an account identity. A login that actually switches accounts starts
|
|
473
|
+
// the new quota clean. For the same account, mirror mergeUpsert's causal rule: only a login
|
|
474
|
+
// whose snapshot includes the latest 429 may clear it; a strictly newer concurrent 429 wins.
|
|
475
|
+
const sameAccount = (existing?.accountId ?? undefined) === (accountId ?? undefined);
|
|
476
|
+
const loginCanClearCooldown = !existing || !sameAccount || login.lastRateLimitedAt >= existing.lastRateLimitedAt;
|
|
477
|
+
const base =
|
|
478
|
+
existing === undefined
|
|
479
|
+
? { label: providerId, rateLimitedUntil: 0, lastAttemptAt: now, lastRateLimitedAt: 0 }
|
|
480
|
+
: {
|
|
481
|
+
...existing,
|
|
482
|
+
rateLimitedUntil: loginCanClearCooldown ? 0 : existing.rateLimitedUntil,
|
|
483
|
+
lastAttemptAt: Math.max(existing.lastAttemptAt, now),
|
|
484
|
+
};
|
|
485
|
+
const entry: SubscriptionCredential = accountId
|
|
486
|
+
? { ...base, accountId }
|
|
487
|
+
: (() => {
|
|
488
|
+
const { accountId: _drop, ...rest } = base;
|
|
489
|
+
return rest;
|
|
490
|
+
})();
|
|
491
|
+
const next = [...pool];
|
|
492
|
+
if (index === -1) next.push(entry);
|
|
493
|
+
else next[index] = entry;
|
|
494
|
+
return next;
|
|
495
|
+
});
|
|
217
496
|
}
|
|
218
497
|
|
|
219
498
|
export function removeSubscription(providerId: ManagedProviderId, ref: string): SubscriptionCredential[] {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
499
|
+
return mutateSubscriptionPool(providerId, (pool) => {
|
|
500
|
+
const index = resolveCredentialIndex(pool, ref);
|
|
501
|
+
if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
|
|
502
|
+
const next = [...pool];
|
|
503
|
+
next.splice(index, 1);
|
|
504
|
+
return next;
|
|
505
|
+
});
|
|
225
506
|
}
|
|
226
507
|
|
|
227
508
|
export function promoteSubscription(providerId: ManagedProviderId, ref: string): SubscriptionCredential[] {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
509
|
+
return mutateSubscriptionPool(providerId, (pool) => {
|
|
510
|
+
const index = resolveCredentialIndex(pool, ref);
|
|
511
|
+
if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
|
|
512
|
+
if (index === 0) return pool;
|
|
513
|
+
const next = [...pool];
|
|
514
|
+
next.unshift(next.splice(index, 1)[0]);
|
|
515
|
+
return next;
|
|
516
|
+
});
|
|
233
517
|
}
|
|
234
518
|
|
|
235
519
|
export function markSubscriptionAttempt(providerId: ManagedProviderId, ref: string, attemptAt = Date.now()): SubscriptionCredential[] {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
...pool[index],
|
|
241
|
-
|
|
242
|
-
};
|
|
243
|
-
return writeSubscriptionPool(providerId, pool);
|
|
520
|
+
return mutateSubscriptionPool(providerId, (pool) => {
|
|
521
|
+
const index = resolveCredentialIndex(pool, ref);
|
|
522
|
+
if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
|
|
523
|
+
const next = [...pool];
|
|
524
|
+
next[index] = { ...pool[index], lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt) };
|
|
525
|
+
return next;
|
|
526
|
+
});
|
|
244
527
|
}
|
|
245
528
|
|
|
246
529
|
export function markSubscriptionRateLimited(
|
|
@@ -250,30 +533,34 @@ export function markSubscriptionRateLimited(
|
|
|
250
533
|
attemptAt = Date.now(),
|
|
251
534
|
rateLimitedAt = Date.now(),
|
|
252
535
|
): SubscriptionCredential[] {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
536
|
+
return mutateSubscriptionPool(providerId, (pool) => {
|
|
537
|
+
const index = resolveCredentialIndex(pool, ref);
|
|
538
|
+
if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
|
|
539
|
+
// A later rate-limit is authoritative until it expires: an out-of-order (stale) 429 whose
|
|
540
|
+
// basis predates the slot's newest cooldown must not reopen or shorten it.
|
|
541
|
+
if (rateLimitedAt < pool[index].lastRateLimitedAt) return undefined;
|
|
542
|
+
const next = [...pool];
|
|
543
|
+
next[index] = {
|
|
544
|
+
...pool[index],
|
|
545
|
+
lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt),
|
|
546
|
+
lastRateLimitedAt: Math.max(pool[index].lastRateLimitedAt, rateLimitedAt),
|
|
547
|
+
rateLimitedUntil: rateLimitedAt + Math.max(0, retryAfterMs),
|
|
548
|
+
};
|
|
549
|
+
return next;
|
|
550
|
+
});
|
|
264
551
|
}
|
|
265
552
|
|
|
266
553
|
export function markSubscriptionSuccess(providerId: ManagedProviderId, ref: string, attemptAt = Date.now()): SubscriptionCredential[] {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
rateLimitedUntil: 0
|
|
275
|
-
|
|
276
|
-
|
|
554
|
+
return mutateSubscriptionPool(providerId, (pool) => {
|
|
555
|
+
const index = resolveCredentialIndex(pool, ref);
|
|
556
|
+
if (index === -1) throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
|
|
557
|
+
// A success whose attempt started before the slot's newest 429 must not clear that cooldown
|
|
558
|
+
// (the memory rule): treat the later rate-limit as authoritative until it expires.
|
|
559
|
+
if (attemptAt < pool[index].lastRateLimitedAt) return undefined;
|
|
560
|
+
const next = [...pool];
|
|
561
|
+
next[index] = { ...pool[index], lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt), rateLimitedUntil: 0 };
|
|
562
|
+
return next;
|
|
563
|
+
});
|
|
277
564
|
}
|
|
278
565
|
|
|
279
566
|
export function findAvailableSubscription(providerId: ManagedProviderId, now = Date.now()): SubscriptionCredential | undefined {
|
|
@@ -291,7 +578,7 @@ export function parseRetryAfterHeader(value: string | undefined, now = Date.now(
|
|
|
291
578
|
}
|
|
292
579
|
|
|
293
580
|
function thinkingLevelOfRef(ref: string): ThinkingLevel | undefined {
|
|
294
|
-
const match = ref.trim().match(/:(thinking|off|minimal|low|medium|high|xhigh)$/i);
|
|
581
|
+
const match = ref.trim().match(/:(thinking|off|minimal|low|medium|high|xhigh|max)$/i);
|
|
295
582
|
if (!match) return undefined;
|
|
296
583
|
const level = match[1].toLowerCase();
|
|
297
584
|
return level === "off" || level === "thinking" ? undefined : (level as ThinkingLevel);
|
|
@@ -301,7 +588,7 @@ function normalizeModelRef(ref: string): string {
|
|
|
301
588
|
return ref
|
|
302
589
|
.trim()
|
|
303
590
|
.replace(/^[^/]+\//, "")
|
|
304
|
-
.replace(/:(?:thinking|off|minimal|low|medium|high|xhigh)$/i, "")
|
|
591
|
+
.replace(/:(?:thinking|off|minimal|low|medium|high|xhigh|max)$/i, "")
|
|
305
592
|
.toLowerCase();
|
|
306
593
|
}
|
|
307
594
|
|
|
@@ -313,7 +600,7 @@ function splitModelRef(ref: string): FallbackTarget {
|
|
|
313
600
|
throw new Error(`Unsupported fallback provider: ${providerId}`);
|
|
314
601
|
}
|
|
315
602
|
const rawModelId = slash === -1 ? trimmed : trimmed.slice(slash + 1).trim();
|
|
316
|
-
const thinkingMatch = rawModelId.match(/^(.*?)(?::(thinking|off|minimal|low|medium|high|xhigh))$/i);
|
|
603
|
+
const thinkingMatch = rawModelId.match(/^(.*?)(?::(thinking|off|minimal|low|medium|high|xhigh|max))$/i);
|
|
317
604
|
return {
|
|
318
605
|
providerId,
|
|
319
606
|
modelId: thinkingMatch ? thinkingMatch[1] : rawModelId,
|
|
@@ -325,28 +612,11 @@ function splitModelRef(ref: string): FallbackTarget {
|
|
|
325
612
|
};
|
|
326
613
|
}
|
|
327
614
|
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
const BUILTIN_MODEL_LADDERS: Record<LadderProviderId, ModelLadderConfig> = {
|
|
334
|
-
anthropic: {
|
|
335
|
-
ultra: "anthropic/claude-fable-5:high",
|
|
336
|
-
strong: "anthropic/claude-opus-4-8:high",
|
|
337
|
-
medium: "anthropic/claude-sonnet-5:high",
|
|
338
|
-
light: "anthropic/claude-haiku-4-5:high",
|
|
339
|
-
},
|
|
340
|
-
openai: {
|
|
341
|
-
ultra: "openai-codex/gpt-5.5:xhigh",
|
|
342
|
-
strong: "openai-codex/gpt-5.5:high",
|
|
343
|
-
medium: "openai-codex/gpt-5.5:medium",
|
|
344
|
-
light: "openai-codex/gpt-5.3-codex-spark",
|
|
345
|
-
},
|
|
346
|
-
};
|
|
347
|
-
|
|
348
|
-
// Layers ~/.crouter/config.json's `modelLadders` (if any) OVER the builtin
|
|
349
|
-
// defaults above, per provider/strength — mirroring crouter's own
|
|
615
|
+
// Layers ~/.crouter/config.json's `modelLadders` (if any) OVER crouter's
|
|
616
|
+
// builtin ladder (`defaultModelLaddersConfig()`, src/types.ts — imported
|
|
617
|
+
// directly: this package's canonical home is the crouter tree, so the
|
|
618
|
+
// relative import always resolves, to dist/types.js at runtime), per
|
|
619
|
+
// provider/strength — mirroring crouter's own
|
|
350
620
|
// `mergeModelLadders` (src/core/config.ts), which is what a node's launcher
|
|
351
621
|
// actually resolves models from. Without this fallback, a config-less user
|
|
352
622
|
// (the common case: fresh install, no ~/.crouter/config.json) saw an EMPTY
|
|
@@ -358,9 +628,10 @@ const BUILTIN_MODEL_LADDERS: Record<LadderProviderId, ModelLadderConfig> = {
|
|
|
358
628
|
function readModelLadders(): ModelLaddersConfig {
|
|
359
629
|
const config = readJsonFile<{ modelLadders?: Partial<Record<LadderProviderId, Partial<ModelLadderConfig>>> }>(CROUTER_CONFIG_FILE);
|
|
360
630
|
const overrides = config?.modelLadders;
|
|
631
|
+
const builtin = defaultModelLaddersConfig();
|
|
361
632
|
const result: ModelLaddersConfig = {};
|
|
362
633
|
for (const provider of ["anthropic", "openai"] as const) {
|
|
363
|
-
const normalized: ModelLadderConfig = { ...
|
|
634
|
+
const normalized: ModelLadderConfig = { ...builtin[provider] };
|
|
364
635
|
const override = overrides?.[provider];
|
|
365
636
|
if (override) {
|
|
366
637
|
for (const strength of ["ultra", "strong", "medium", "light"] as const) {
|
|
@@ -389,7 +660,7 @@ function findStrengthForModelId(
|
|
|
389
660
|
if (ref && normalizeModelRef(ref) === bareId) matches.push(strength);
|
|
390
661
|
}
|
|
391
662
|
if (matches.length === 0) return undefined;
|
|
392
|
-
// Rungs that share a bare model id (e.g. gpt-5.
|
|
663
|
+
// Rungs that share a bare model id (e.g. gpt-5.6-sol:max vs gpt-5.6-sol:high) are
|
|
393
664
|
// disambiguated by the live reasoning level so an ultra run doesn't collapse to strong.
|
|
394
665
|
if (matches.length > 1 && currentThinkingLevel) {
|
|
395
666
|
const exact = matches.find((strength) => thinkingLevelOfRef(source[strength]!) === currentThinkingLevel);
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "pi-
|
|
2
|
+
"name": "pi-crtr-extensions",
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
|
-
"name": "pi-
|
|
8
|
+
"name": "pi-crtr-extensions",
|
|
9
9
|
"version": "0.1.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|