@crouton-kit/crouter 0.3.78 → 0.3.80
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/dist/build-root.d.ts +12 -4
- package/dist/build-root.js +25 -6
- package/dist/builtin-memory/crouter-development/plugins.md +82 -5
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1228 -12
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +3 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -787
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -3
- package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +9 -1
- package/dist/clients/attach/attach-cmd.d.ts +9 -1
- package/dist/clients/attach/attach-cmd.js +847 -801
- package/dist/clients/attach/auth-pickers.d.ts +0 -12
- package/dist/clients/attach/auth-pickers.js +64 -15
- package/dist/clients/attach/chat-view.d.ts +4 -0
- package/dist/clients/attach/chat-view.js +10 -0
- package/dist/clients/attach/graph-overlay.d.ts +12 -2
- package/dist/clients/attach/graph-overlay.js +83 -33
- package/dist/clients/attach/input-controller.d.ts +19 -0
- package/dist/clients/attach/input-controller.js +55 -11
- package/dist/clients/attach/titled-editor.d.ts +21 -0
- package/dist/clients/attach/titled-editor.js +101 -0
- package/dist/commands/human/queue.js +3 -4
- package/dist/commands/pkg/plugin-inspect.js +22 -1
- package/dist/commands/pkg/plugin-manage.js +31 -9
- package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
- package/dist/commands/sys/doctor.js +42 -4
- package/dist/commands/sys/setup-core.d.ts +37 -0
- package/dist/commands/sys/setup-core.js +138 -1
- package/dist/commands/sys/setup.d.ts +88 -0
- package/dist/commands/sys/setup.js +915 -171
- package/dist/commands/view-pick.d.ts +4 -0
- package/dist/commands/view-pick.js +17 -7
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
- package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
- package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins.test.js +444 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
- package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
- package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
- package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
- package/dist/core/canvas/browse/app.d.ts +6 -0
- package/dist/core/canvas/browse/app.js +71 -41
- package/dist/core/command-plugins/adapter.d.ts +15 -0
- package/dist/core/command-plugins/adapter.js +145 -0
- package/dist/core/command-plugins/compose.d.ts +5 -0
- package/dist/core/command-plugins/compose.js +56 -0
- package/dist/core/command-plugins/discovery.d.ts +104 -0
- package/dist/core/command-plugins/discovery.js +565 -0
- package/dist/core/config.d.ts +2 -5
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
- package/dist/core/keybindings/catalog.d.ts +3 -3
- package/dist/core/keybindings/catalog.js +2 -1
- package/dist/core/profiles/select.d.ts +6 -0
- package/dist/core/profiles/select.js +90 -59
- package/dist/core/provider-management.d.ts +12 -0
- package/dist/core/provider-management.js +24 -0
- package/dist/core/runtime/banner.d.ts +13 -0
- package/dist/core/runtime/banner.js +51 -0
- package/dist/core/runtime/broker.js +7 -6
- package/dist/core/runtime/pi-vendored.d.ts +8 -0
- package/dist/core/runtime/pi-vendored.js +14 -0
- package/dist/core/runtime/recap.d.ts +1 -1
- package/dist/core/runtime/recap.js +50 -25
- package/dist/core/runtime/session-list-cache.d.ts +10 -0
- package/dist/core/runtime/session-list-cache.js +94 -26
- package/dist/core/runtime/spawn.js +5 -17
- package/dist/core/runtime/tmux.js +2 -1
- package/dist/core/scope.js +27 -4
- package/dist/core/subscription-state.d.ts +90 -0
- package/dist/core/subscription-state.js +762 -0
- package/dist/daemon/crtrd.js +253 -12
- package/dist/pi-extensions/canvas-recap.js +43 -17
- package/dist/types.d.ts +6 -13
- package/dist/types.js +0 -3
- package/package.json +7 -3
|
@@ -1,787 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
5
|
-
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
6
|
-
import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
|
|
9
|
-
export const ANTHROPIC_PROVIDER_ID = "anthropic";
|
|
10
|
-
export const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
|
|
11
|
-
|
|
12
|
-
export type ManagedProviderId = typeof ANTHROPIC_PROVIDER_ID | typeof OPENAI_CODEX_PROVIDER_ID;
|
|
13
|
-
export type LadderProviderId = "anthropic" | "openai";
|
|
14
|
-
export type ModelStrength = "ultra" | "strong" | "medium" | "light";
|
|
15
|
-
|
|
16
|
-
// The default label's slot (`label === providerId`) is the OAuth-login-linked credential --
|
|
17
|
-
// its live value (`refresh`/`access`/`expires`) is owned exclusively by pi's auth.json (see
|
|
18
|
-
// provider-rotation.ts's `refreshDefaultSlotCredential`/`reauthenticateCredential`), so those
|
|
19
|
-
// fields are absent on that entry. Any OTHER explicitly-added account (`/provider-sub add`,
|
|
20
|
-
// label !== providerId) keeps the full value here, unchanged.
|
|
21
|
-
export type SubscriptionCredential = {
|
|
22
|
-
label: string;
|
|
23
|
-
refresh?: string;
|
|
24
|
-
access?: string;
|
|
25
|
-
expires?: number;
|
|
26
|
-
rateLimitedUntil: number;
|
|
27
|
-
lastAttemptAt: number;
|
|
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;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export type ModelRef = {
|
|
37
|
-
providerId: ManagedProviderId;
|
|
38
|
-
modelId: string;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
export type RotationConfig = {
|
|
42
|
-
defaultFallbackStrength?: ModelStrength;
|
|
43
|
-
revertWhenAvailable?: boolean;
|
|
44
|
-
preferredModel?: ModelRef;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
type RotationConfigUpdate = {
|
|
48
|
-
defaultFallbackStrength?: ModelStrength;
|
|
49
|
-
revertWhenAvailable?: boolean;
|
|
50
|
-
preferredModel?: ModelRef | null;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
export type FallbackTarget = {
|
|
54
|
-
providerId: ManagedProviderId;
|
|
55
|
-
modelId: string;
|
|
56
|
-
label: string;
|
|
57
|
-
strength: ModelStrength;
|
|
58
|
-
thinkingLevel?: ThinkingLevel;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
type ModelLadderConfig = Partial<Record<ModelStrength, string>>;
|
|
62
|
-
type ModelLaddersConfig = Partial<Record<LadderProviderId, ModelLadderConfig>>;
|
|
63
|
-
const AGENT_DIR = join(homedir(), ".pi", "agent");
|
|
64
|
-
const ROTATION_CONFIG_FILE = join(AGENT_DIR, "provider-rotation.json");
|
|
65
|
-
const CROUTER_CONFIG_FILE = join(homedir(), ".crouter", "config.json");
|
|
66
|
-
const DEFAULT_FALLBACK_STRENGTH: ModelStrength = "strong";
|
|
67
|
-
|
|
68
|
-
const PROVIDERS: Record<
|
|
69
|
-
ManagedProviderId,
|
|
70
|
-
{
|
|
71
|
-
label: string;
|
|
72
|
-
ladderProviderId: LadderProviderId;
|
|
73
|
-
poolFile: string;
|
|
74
|
-
}
|
|
75
|
-
> = {
|
|
76
|
-
[ANTHROPIC_PROVIDER_ID]: {
|
|
77
|
-
label: "Claude",
|
|
78
|
-
ladderProviderId: "anthropic",
|
|
79
|
-
poolFile: join(AGENT_DIR, "anthropic-subscriptions.json"),
|
|
80
|
-
},
|
|
81
|
-
[OPENAI_CODEX_PROVIDER_ID]: {
|
|
82
|
-
label: "OpenAI Codex",
|
|
83
|
-
ladderProviderId: "openai",
|
|
84
|
-
poolFile: join(AGENT_DIR, "openai-codex-subscriptions.json"),
|
|
85
|
-
},
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
function readJsonFile<T>(path: string): T | undefined {
|
|
89
|
-
if (!existsSync(path)) return undefined;
|
|
90
|
-
try {
|
|
91
|
-
const raw = readFileSync(path, "utf8").trim();
|
|
92
|
-
if (!raw) return undefined;
|
|
93
|
-
return JSON.parse(raw) as T;
|
|
94
|
-
} catch {
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function writeJsonFile(path: string, value: unknown, mode?: number): void {
|
|
100
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
101
|
-
const tmp = `${path}.${process.pid}.tmp`;
|
|
102
|
-
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, mode === undefined ? "utf8" : { encoding: "utf8", mode });
|
|
103
|
-
if (mode !== undefined) chmodSync(tmp, mode);
|
|
104
|
-
renameSync(tmp, path);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function normalizeStrength(value: unknown): ModelStrength | undefined {
|
|
108
|
-
return value === "ultra" || value === "strong" || value === "medium" || value === "light" ? value : undefined;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function normalizeTiming(value: unknown): number {
|
|
112
|
-
const numeric = Number(value);
|
|
113
|
-
return Number.isFinite(numeric) ? numeric : 0;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function normalizeCredential(value: Partial<SubscriptionCredential> & { label?: string }): SubscriptionCredential | undefined {
|
|
117
|
-
const label = typeof value.label === "string" ? value.label.trim() : "";
|
|
118
|
-
if (!label) return undefined;
|
|
119
|
-
const refresh = typeof value.refresh === "string" ? value.refresh.trim() : "";
|
|
120
|
-
const access = typeof value.access === "string" ? value.access.trim() : "";
|
|
121
|
-
const accountId = typeof value.accountId === "string" ? value.accountId.trim() : "";
|
|
122
|
-
const expires = Number(value.expires);
|
|
123
|
-
const rateLimitedUntil = Number(value.rateLimitedUntil);
|
|
124
|
-
return {
|
|
125
|
-
label,
|
|
126
|
-
...(refresh && access ? { refresh, access, expires: Number.isFinite(expires) ? expires : 0 } : {}),
|
|
127
|
-
...(accountId ? { accountId } : {}),
|
|
128
|
-
rateLimitedUntil: Number.isFinite(rateLimitedUntil) ? rateLimitedUntil : 0,
|
|
129
|
-
lastAttemptAt: normalizeTiming(value.lastAttemptAt),
|
|
130
|
-
lastRateLimitedAt: normalizeTiming(value.lastRateLimitedAt),
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function normalizeCredentials(values: unknown): SubscriptionCredential[] {
|
|
135
|
-
if (!Array.isArray(values)) return [];
|
|
136
|
-
return values.map((entry) => normalizeCredential(entry as Partial<SubscriptionCredential> & { label?: string })).filter((entry): entry is SubscriptionCredential => Boolean(entry));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function getCurrentModelRef(model?: { provider: string; id: string }): ModelRef | undefined {
|
|
140
|
-
return isManagedProvider(model?.provider) ? { providerId: model.provider, modelId: model.id } : undefined;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
type AuthFileData = Record<string, { type?: string; accountId?: unknown }>;
|
|
144
|
-
|
|
145
|
-
function readAuthCredential(providerId: ManagedProviderId): { type?: string; accountId?: string } | undefined {
|
|
146
|
-
return new FileAuthStorageBackend().withLock((current) => {
|
|
147
|
-
if (!current) return { result: undefined };
|
|
148
|
-
const data = JSON.parse(current) as AuthFileData;
|
|
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 } };
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
|
|
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.
|
|
172
|
-
function ensureDefaultSubscriptionMetadata(providerId: ManagedProviderId, pool: SubscriptionCredential[]): SubscriptionCredential[] {
|
|
173
|
-
const authCred = readAuthCredential(providerId);
|
|
174
|
-
if (authCred?.type !== "oauth") return pool;
|
|
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;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// The default label's value lives exclusively in auth.json, so its pool entry must never
|
|
200
|
-
// carry `refresh`/`access`/`expires`; this strips those fields on every read and write,
|
|
201
|
-
// self-healing any pool file that has them physically present on disk. `changed` tells
|
|
202
|
-
// the caller whether anything was actually stripped, so `readSubscriptionPool` can
|
|
203
|
-
// persist the metadata-only result immediately rather than leaving a stale copy on disk
|
|
204
|
-
// until some unrelated write.
|
|
205
|
-
function scrubDefaultValue(providerId: ManagedProviderId, pool: SubscriptionCredential[]): { pool: SubscriptionCredential[]; changed: boolean } {
|
|
206
|
-
let changed = false;
|
|
207
|
-
const next = pool.map((entry) => {
|
|
208
|
-
if (entry.label !== providerId || (entry.refresh === undefined && entry.access === undefined && entry.expires === undefined)) return entry;
|
|
209
|
-
changed = true;
|
|
210
|
-
const { refresh: _refresh, access: _access, expires: _expires, ...meta } = entry;
|
|
211
|
-
return meta;
|
|
212
|
-
});
|
|
213
|
-
return { pool: next, changed };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export function isManagedProvider(providerId: string | undefined): providerId is ManagedProviderId {
|
|
217
|
-
return providerId === ANTHROPIC_PROVIDER_ID || providerId === OPENAI_CODEX_PROVIDER_ID;
|
|
218
|
-
}
|
|
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
|
-
|
|
229
|
-
export function getProviderLabel(providerId: ManagedProviderId): string {
|
|
230
|
-
return PROVIDERS[providerId].label;
|
|
231
|
-
}
|
|
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
|
-
|
|
289
|
-
export function readSubscriptionPool(providerId: ManagedProviderId): SubscriptionCredential[] {
|
|
290
|
-
const poolFile = PROVIDERS[providerId].poolFile;
|
|
291
|
-
const raw = normalizeCredentials(readJsonFile<unknown>(poolFile));
|
|
292
|
-
const { pool: scrubbed, changed: wasScrubbed } = scrubDefaultValue(providerId, raw);
|
|
293
|
-
const withDefaultSlot = ensureDefaultSubscriptionMetadata(providerId, scrubbed);
|
|
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
|
-
}
|
|
304
|
-
return withDefaultSlot;
|
|
305
|
-
}
|
|
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.
|
|
310
|
-
export function writeSubscriptionPool(providerId: ManagedProviderId, next: SubscriptionCredential[]): SubscriptionCredential[] {
|
|
311
|
-
return mutateSubscriptionPool(providerId, () => next);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function resolveCredentialIndex(pool: SubscriptionCredential[], ref: string): number {
|
|
315
|
-
const needle = ref.trim().toLowerCase();
|
|
316
|
-
const numeric = Number.parseInt(needle, 10);
|
|
317
|
-
if (Number.isFinite(numeric) && String(numeric) === needle && numeric >= 1 && numeric <= pool.length) {
|
|
318
|
-
return numeric - 1;
|
|
319
|
-
}
|
|
320
|
-
return pool.findIndex((entry) => entry.label.toLowerCase() === needle);
|
|
321
|
-
}
|
|
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
|
-
|
|
342
|
-
export function upsertSubscription(providerId: ManagedProviderId, next: SubscriptionCredential): SubscriptionCredential[] {
|
|
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
|
-
});
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
export function removeSubscription(providerId: ManagedProviderId, ref: string): SubscriptionCredential[] {
|
|
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
|
-
});
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
export function promoteSubscription(providerId: ManagedProviderId, ref: string): SubscriptionCredential[] {
|
|
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
|
-
});
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
export function markSubscriptionAttempt(providerId: ManagedProviderId, ref: string, attemptAt = Date.now()): SubscriptionCredential[] {
|
|
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
|
-
});
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
export function markSubscriptionRateLimited(
|
|
530
|
-
providerId: ManagedProviderId,
|
|
531
|
-
ref: string,
|
|
532
|
-
retryAfterMs: number,
|
|
533
|
-
attemptAt = Date.now(),
|
|
534
|
-
rateLimitedAt = Date.now(),
|
|
535
|
-
): SubscriptionCredential[] {
|
|
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
|
-
});
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
export function markSubscriptionSuccess(providerId: ManagedProviderId, ref: string, attemptAt = Date.now()): SubscriptionCredential[] {
|
|
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
|
-
});
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
export function findAvailableSubscription(providerId: ManagedProviderId, now = Date.now()): SubscriptionCredential | undefined {
|
|
567
|
-
return readSubscriptionPool(providerId).find((entry) => !entry.rateLimitedUntil || entry.rateLimitedUntil <= now);
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
export function parseRetryAfterHeader(value: string | undefined, now = Date.now()): number | undefined {
|
|
571
|
-
if (!value) return undefined;
|
|
572
|
-
const trimmed = value.trim();
|
|
573
|
-
if (!trimmed) return undefined;
|
|
574
|
-
const seconds = Number(trimmed);
|
|
575
|
-
if (Number.isFinite(seconds)) return Math.max(0, Math.round(seconds * 1000));
|
|
576
|
-
const parsed = Date.parse(trimmed);
|
|
577
|
-
return Number.isFinite(parsed) ? Math.max(0, parsed - now) : undefined;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
function thinkingLevelOfRef(ref: string): ThinkingLevel | undefined {
|
|
581
|
-
const match = ref.trim().match(/:(thinking|off|minimal|low|medium|high|xhigh|max)$/i);
|
|
582
|
-
if (!match) return undefined;
|
|
583
|
-
const level = match[1].toLowerCase();
|
|
584
|
-
return level === "off" || level === "thinking" ? undefined : (level as ThinkingLevel);
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
function normalizeModelRef(ref: string): string {
|
|
588
|
-
return ref
|
|
589
|
-
.trim()
|
|
590
|
-
.replace(/^[^/]+\//, "")
|
|
591
|
-
.replace(/:(?:thinking|off|minimal|low|medium|high|xhigh|max)$/i, "")
|
|
592
|
-
.toLowerCase();
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
function splitModelRef(ref: string): FallbackTarget {
|
|
596
|
-
const trimmed = ref.trim();
|
|
597
|
-
const slash = trimmed.indexOf("/");
|
|
598
|
-
const providerId = slash === -1 ? OPENAI_CODEX_PROVIDER_ID : trimmed.slice(0, slash).trim();
|
|
599
|
-
if (!isManagedProvider(providerId)) {
|
|
600
|
-
throw new Error(`Unsupported fallback provider: ${providerId}`);
|
|
601
|
-
}
|
|
602
|
-
const rawModelId = slash === -1 ? trimmed : trimmed.slice(slash + 1).trim();
|
|
603
|
-
const thinkingMatch = rawModelId.match(/^(.*?)(?::(thinking|off|minimal|low|medium|high|xhigh|max))$/i);
|
|
604
|
-
return {
|
|
605
|
-
providerId,
|
|
606
|
-
modelId: thinkingMatch ? thinkingMatch[1] : rawModelId,
|
|
607
|
-
label: `${providerId}/${thinkingMatch ? thinkingMatch[1] : rawModelId}${thinkingMatch?.[2] ? `:${thinkingMatch[2].toLowerCase()}` : ""}`,
|
|
608
|
-
strength: DEFAULT_FALLBACK_STRENGTH,
|
|
609
|
-
thinkingLevel: thinkingMatch?.[2] && thinkingMatch[2].toLowerCase() !== "off" && thinkingMatch[2].toLowerCase() !== "thinking"
|
|
610
|
-
? (thinkingMatch[2].toLowerCase() as ThinkingLevel)
|
|
611
|
-
: undefined,
|
|
612
|
-
};
|
|
613
|
-
}
|
|
614
|
-
|
|
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
|
|
620
|
-
// `mergeModelLadders` (src/core/config.ts), which is what a node's launcher
|
|
621
|
-
// actually resolves models from. Without this fallback, a config-less user
|
|
622
|
-
// (the common case: fresh install, no ~/.crouter/config.json) saw an EMPTY
|
|
623
|
-
// ladder here while crtr itself launched them on the builtin defaults —
|
|
624
|
-
// rotation could then never resolve a strength tier or a cross-provider
|
|
625
|
-
// fallback for a model it was already running. Always returns a fully
|
|
626
|
-
// populated ladder for both providers; a config.json override only replaces
|
|
627
|
-
// the specific strengths it sets.
|
|
628
|
-
function readModelLadders(): ModelLaddersConfig {
|
|
629
|
-
const config = readJsonFile<{ modelLadders?: Partial<Record<LadderProviderId, Partial<ModelLadderConfig>>> }>(CROUTER_CONFIG_FILE);
|
|
630
|
-
const overrides = config?.modelLadders;
|
|
631
|
-
const builtin = defaultModelLaddersConfig();
|
|
632
|
-
const result: ModelLaddersConfig = {};
|
|
633
|
-
for (const provider of ["anthropic", "openai"] as const) {
|
|
634
|
-
const normalized: ModelLadderConfig = { ...builtin[provider] };
|
|
635
|
-
const override = overrides?.[provider];
|
|
636
|
-
if (override) {
|
|
637
|
-
for (const strength of ["ultra", "strong", "medium", "light"] as const) {
|
|
638
|
-
const value = typeof override[strength] === "string" ? override[strength]?.trim() : "";
|
|
639
|
-
if (value) normalized[strength] = value;
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
result[provider] = normalized;
|
|
643
|
-
}
|
|
644
|
-
return result;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
function findStrengthForModelId(
|
|
648
|
-
providerId: ManagedProviderId,
|
|
649
|
-
modelId: string | undefined,
|
|
650
|
-
defaultStrength: ModelStrength,
|
|
651
|
-
ladders = readModelLadders(),
|
|
652
|
-
currentThinkingLevel?: string,
|
|
653
|
-
): ModelStrength | undefined {
|
|
654
|
-
const source = ladders?.[PROVIDERS[providerId].ladderProviderId];
|
|
655
|
-
const bareId = modelId ? normalizeModelRef(modelId) : undefined;
|
|
656
|
-
if (!source || !bareId) return undefined;
|
|
657
|
-
const matches: ModelStrength[] = [];
|
|
658
|
-
for (const strength of ["ultra", "strong", "medium", "light"] as const) {
|
|
659
|
-
const ref = source[strength];
|
|
660
|
-
if (ref && normalizeModelRef(ref) === bareId) matches.push(strength);
|
|
661
|
-
}
|
|
662
|
-
if (matches.length === 0) return undefined;
|
|
663
|
-
// Rungs that share a bare model id (e.g. gpt-5.6-sol:max vs gpt-5.6-sol:high) are
|
|
664
|
-
// disambiguated by the live reasoning level so an ultra run doesn't collapse to strong.
|
|
665
|
-
if (matches.length > 1 && currentThinkingLevel) {
|
|
666
|
-
const exact = matches.find((strength) => thinkingLevelOfRef(source[strength]!) === currentThinkingLevel);
|
|
667
|
-
if (exact) return exact;
|
|
668
|
-
}
|
|
669
|
-
return matches.includes(defaultStrength) ? defaultStrength : matches[0];
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
export function readRotationConfig(): RotationConfig {
|
|
673
|
-
const raw = readJsonFile<RotationConfig>(ROTATION_CONFIG_FILE) ?? {};
|
|
674
|
-
const preferredModel =
|
|
675
|
-
raw.preferredModel && isManagedProvider(raw.preferredModel.providerId) && typeof raw.preferredModel.modelId === "string"
|
|
676
|
-
? { providerId: raw.preferredModel.providerId, modelId: raw.preferredModel.modelId.trim() }
|
|
677
|
-
: undefined;
|
|
678
|
-
return {
|
|
679
|
-
defaultFallbackStrength: normalizeStrength(raw.defaultFallbackStrength) ?? DEFAULT_FALLBACK_STRENGTH,
|
|
680
|
-
revertWhenAvailable: raw.revertWhenAvailable ?? true,
|
|
681
|
-
preferredModel,
|
|
682
|
-
};
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
export function writeRotationConfig(next: RotationConfigUpdate): RotationConfig {
|
|
686
|
-
const current = readRotationConfig();
|
|
687
|
-
const merged: RotationConfig = {
|
|
688
|
-
defaultFallbackStrength: normalizeStrength(next.defaultFallbackStrength) ?? current.defaultFallbackStrength ?? DEFAULT_FALLBACK_STRENGTH,
|
|
689
|
-
revertWhenAvailable: next.revertWhenAvailable ?? current.revertWhenAvailable ?? true,
|
|
690
|
-
preferredModel: Object.prototype.hasOwnProperty.call(next, "preferredModel")
|
|
691
|
-
? next.preferredModel || undefined
|
|
692
|
-
: current.preferredModel,
|
|
693
|
-
};
|
|
694
|
-
writeJsonFile(ROTATION_CONFIG_FILE, merged);
|
|
695
|
-
return merged;
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
export function clearPreferredModel(): RotationConfig {
|
|
699
|
-
return writeRotationConfig({ preferredModel: null });
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
export function rememberPreferredModel(model: ModelRef): RotationConfig {
|
|
703
|
-
return writeRotationConfig({ preferredModel: model });
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// `readModelLadders()` always fills both providers from the builtin defaults, so this
|
|
707
|
-
// only returns undefined (never throws) in the defensive case where a rung is still
|
|
708
|
-
// genuinely missing; callers must treat that as a graceful "no fallback available" case
|
|
709
|
-
// rather than a config error to surface mid-turn.
|
|
710
|
-
export function resolveFallbackTarget(
|
|
711
|
-
currentProviderId: ManagedProviderId,
|
|
712
|
-
currentModelId?: string,
|
|
713
|
-
config = readRotationConfig(),
|
|
714
|
-
currentThinkingLevel?: string,
|
|
715
|
-
): FallbackTarget | undefined {
|
|
716
|
-
const ladders = readModelLadders();
|
|
717
|
-
const defaultStrength = config.defaultFallbackStrength ?? DEFAULT_FALLBACK_STRENGTH;
|
|
718
|
-
const sourceStrength = findStrengthForModelId(currentProviderId, currentModelId, defaultStrength, ladders, currentThinkingLevel);
|
|
719
|
-
const strength = sourceStrength ?? defaultStrength;
|
|
720
|
-
const targetProvider = currentProviderId === ANTHROPIC_PROVIDER_ID ? OPENAI_CODEX_PROVIDER_ID : ANTHROPIC_PROVIDER_ID;
|
|
721
|
-
const targetLadder = ladders?.[PROVIDERS[targetProvider].ladderProviderId];
|
|
722
|
-
const candidates = strength === defaultStrength ? [strength] : [strength, defaultStrength];
|
|
723
|
-
const fallbackRef = candidates.map((candidate) => targetLadder?.[candidate]).find((ref): ref is string => typeof ref === "string" && ref.trim().length > 0);
|
|
724
|
-
if (!fallbackRef) {
|
|
725
|
-
return undefined;
|
|
726
|
-
}
|
|
727
|
-
const parsed = splitModelRef(fallbackRef);
|
|
728
|
-
const resolvedStrength = candidates.find((candidate) => targetLadder?.[candidate] === fallbackRef) ?? strength;
|
|
729
|
-
return { ...parsed, strength: resolvedStrength };
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
export function shouldRestorePreferredModel(config = readRotationConfig(), now = Date.now()): boolean {
|
|
733
|
-
if (!config.revertWhenAvailable || !config.preferredModel) return false;
|
|
734
|
-
return Boolean(findAvailableSubscription(config.preferredModel.providerId, now));
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
export async function restorePreferredModelIfPossible(
|
|
738
|
-
piApi: ExtensionAPI,
|
|
739
|
-
ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
|
|
740
|
-
): Promise<boolean> {
|
|
741
|
-
const config = readRotationConfig();
|
|
742
|
-
if (!shouldRestorePreferredModel(config, Date.now())) return false;
|
|
743
|
-
const preferred = config.preferredModel;
|
|
744
|
-
if (!preferred) return false;
|
|
745
|
-
const current = getCurrentModelRef(ctx.model);
|
|
746
|
-
if (current?.providerId === preferred.providerId && current.modelId === preferred.modelId) {
|
|
747
|
-
clearPreferredModel();
|
|
748
|
-
return true;
|
|
749
|
-
}
|
|
750
|
-
const model = ctx.modelRegistry.find(preferred.providerId, preferred.modelId);
|
|
751
|
-
if (!model) return false;
|
|
752
|
-
const switched = await piApi.setModel(model);
|
|
753
|
-
if (switched) clearPreferredModel();
|
|
754
|
-
return switched;
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
export async function switchToFallbackIfPossible(
|
|
758
|
-
piApi: ExtensionAPI,
|
|
759
|
-
ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
|
|
760
|
-
preferredModel = getCurrentModelRef(ctx.model),
|
|
761
|
-
): Promise<FallbackTarget | undefined> {
|
|
762
|
-
if (!preferredModel) return undefined;
|
|
763
|
-
rememberPreferredModel(preferredModel);
|
|
764
|
-
const currentThinkingLevel = piApi.getThinkingLevel?.();
|
|
765
|
-
const target = resolveFallbackTarget(preferredModel.providerId, preferredModel.modelId, readRotationConfig(), currentThinkingLevel);
|
|
766
|
-
if (!target) return undefined;
|
|
767
|
-
const current = getCurrentModelRef(ctx.model);
|
|
768
|
-
const model = ctx.modelRegistry.find(target.providerId, target.modelId);
|
|
769
|
-
if (!model) return undefined;
|
|
770
|
-
if (current?.providerId !== target.providerId || current.modelId !== target.modelId) {
|
|
771
|
-
const switched = await piApi.setModel(model);
|
|
772
|
-
if (!switched) return undefined;
|
|
773
|
-
}
|
|
774
|
-
if (target.thinkingLevel) piApi.setThinkingLevel(target.thinkingLevel);
|
|
775
|
-
return target;
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
export function formatStatusLine(providerId: ManagedProviderId, now = Date.now()): string {
|
|
779
|
-
const pool = readSubscriptionPool(providerId);
|
|
780
|
-
const ready = pool.filter((entry) => !entry.rateLimitedUntil || entry.rateLimitedUntil <= now);
|
|
781
|
-
const label = getProviderLabel(providerId);
|
|
782
|
-
if (ready.length === 0) {
|
|
783
|
-
const fallback = resolveFallbackTarget(providerId, undefined, readRotationConfig());
|
|
784
|
-
return fallback ? `${label} pool exhausted -> ${fallback.label}` : `${label} pool exhausted (no fallback configured)`;
|
|
785
|
-
}
|
|
786
|
-
return `${label} ${ready[0]?.label ?? providerId} (${ready.length}/${pool.length} ready)`;
|
|
787
|
-
}
|
|
1
|
+
// Provider pool state belongs to crouter core so every human surface and the rotation runtime share one invariant-preserving implementation.
|
|
2
|
+
export * from "../../../core/subscription-state.js";
|