@north-light/crouter 0.3.161 → 0.3.162

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/api/client.d.ts +3 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/modelauth.d.ts +32 -0
  4. package/dist/api/dto/modelauth.js +2 -1
  5. package/dist/api/routes.d.ts +1 -0
  6. package/dist/api/routes.js +1 -0
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +55 -395
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +204 -419
  9. package/dist/clients/attach/viewer.js +592 -592
  10. package/dist/core/__tests__/broker-launch-candidates.test.js +13 -3
  11. package/dist/core/__tests__/broker-sdk-wiring.test.js +53 -25
  12. package/dist/core/__tests__/full/broker-provider-retry.test.js +6 -122
  13. package/dist/core/__tests__/model-pin-durability.test.js +7 -5
  14. package/dist/core/__tests__/model-routes.test.d.ts +1 -0
  15. package/dist/core/__tests__/model-routes.test.js +41 -0
  16. package/dist/core/__tests__/review-model-floor.test.js +2 -2
  17. package/dist/core/auth-file.d.ts +3 -2
  18. package/dist/core/auth-file.js +5 -3
  19. package/dist/core/canvas/types.d.ts +6 -1
  20. package/dist/core/config.d.ts +3 -1
  21. package/dist/core/config.js +88 -2
  22. package/dist/core/model-routes.d.ts +37 -0
  23. package/dist/core/model-routes.js +140 -0
  24. package/dist/core/runtime/broker.d.ts +0 -4
  25. package/dist/core/runtime/broker.js +116 -372
  26. package/dist/core/runtime/launch.d.ts +9 -11
  27. package/dist/core/runtime/launch.js +15 -21
  28. package/dist/core/runtime/managed-provider-cooling.d.ts +0 -14
  29. package/dist/core/runtime/managed-provider-cooling.js +0 -19
  30. package/dist/core/runtime/model-swap.js +7 -1
  31. package/dist/core/runtime/promote.js +1 -0
  32. package/dist/core/subscription-state.d.ts +12 -36
  33. package/dist/core/subscription-state.js +19 -203
  34. package/dist/daemon/api/__tests__/full/api-server.test.js +9 -6
  35. package/dist/daemon/api/__tests__/full/b10-attach-modelauth.test.js +123 -1
  36. package/dist/daemon/api/handlers/modelauth.js +51 -2
  37. package/dist/daemon/api/handlers/nodes.js +2 -0
  38. package/dist/types.d.ts +17 -0
  39. package/dist/web-client/assets/{index-B76ZKfT_.js → index-NIuSCOHM.js} +1 -1
  40. package/dist/web-client/index.html +1 -1
  41. package/dist/web-client/sw.js +1 -1
  42. package/package.json +1 -1
  43. package/runtime.lock.json +2 -2
@@ -34,7 +34,8 @@ import assert from 'node:assert/strict';
34
34
  import { existsSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
35
35
  import { tmpdir } from 'node:os';
36
36
  import { join } from 'node:path';
37
- import { setAuthCredential } from '../../../../core/auth-file.js';
37
+ import { readAuthCredentials, setAuthCredential } from '../../../../core/auth-file.js';
38
+ import { readSubscriptionPool, writeSubscriptionPool } from '../../../../core/subscription-state.js';
38
39
  import { createHeadlessHarness } from '../../../../core/__tests__/helpers/harness.js';
39
40
  import { createAttachKit } from '../../../../core/__tests__/helpers/broker-clients.js';
40
41
  import { CrtrClient } from '../../../../api/index.js';
@@ -218,3 +219,124 @@ test('B2-modelauth-oauth: installCredential(oauth) preserves extra fields (enter
218
219
  rmSync(apiDir, { recursive: true, force: true });
219
220
  }
220
221
  });
222
+ // ---------------------------------------------------------------------------
223
+ // Credential truth/removal — GET exposes only connection state and DELETE
224
+ // clears exactly its provider's durable store.
225
+ // ---------------------------------------------------------------------------
226
+ test('B2-modelauth-status: list is sanitized and delete clears only the selected provider', async () => {
227
+ const savedPiDir = process.env['PI_CODING_AGENT_DIR'];
228
+ const apiDir = mkdtempSync(join(tmpdir(), 'crtr-b10-status-'));
229
+ process.env['PI_CODING_AGENT_DIR'] = apiDir;
230
+ const expires = 1_900_000_000_000;
231
+ const cooldown = 1_900_000_100_000;
232
+ try {
233
+ await writeSubscriptionPool('anthropic', [
234
+ {
235
+ label: 'Claude One',
236
+ refresh: 'anthropic-refresh-secret',
237
+ access: 'anthropic-access-secret',
238
+ expires,
239
+ rateLimitedUntil: cooldown,
240
+ lastAttemptAt: 0,
241
+ lastRateLimitedAt: 0,
242
+ authFailure: 'invalid_grant',
243
+ accountId: 'anthropic-account-id',
244
+ },
245
+ {
246
+ label: 'Claude Two',
247
+ refresh: 'second-refresh-secret',
248
+ access: 'second-access-secret',
249
+ expires,
250
+ rateLimitedUntil: 0,
251
+ lastAttemptAt: 0,
252
+ lastRateLimitedAt: 0,
253
+ },
254
+ ]);
255
+ await writeSubscriptionPool('openai-codex', [
256
+ {
257
+ label: 'ChatGPT',
258
+ refresh: 'codex-refresh-secret',
259
+ access: 'codex-access-secret',
260
+ expires,
261
+ rateLimitedUntil: 0,
262
+ lastAttemptAt: 0,
263
+ lastRateLimitedAt: 0,
264
+ accountId: 'codex-account-id',
265
+ },
266
+ ]);
267
+ await setAuthCredential('github-copilot', {
268
+ type: 'oauth',
269
+ access: 'copilot-access-secret',
270
+ refresh: 'copilot-refresh-secret',
271
+ expires,
272
+ }, join(apiDir, 'auth.json'));
273
+ // A managed provider may also have a hand-configured API key. It remains
274
+ // intentionally absent from status, but DELETE must remove it too.
275
+ await setAuthCredential('anthropic', { type: 'api_key', key: 'anthropic-api-key-secret' }, join(apiDir, 'auth.json'));
276
+ const status = await client.listModelAuth();
277
+ assert.deepEqual(status.providers, [
278
+ {
279
+ provider: 'anthropic',
280
+ managed: true,
281
+ configured: true,
282
+ accounts: [
283
+ {
284
+ label: 'Claude One',
285
+ expires_at: new Date(expires).toISOString(),
286
+ rate_limited_until: new Date(cooldown).toISOString(),
287
+ auth_failure: 'invalid_grant',
288
+ account_id: 'anthropic-account-id',
289
+ },
290
+ {
291
+ label: 'Claude Two',
292
+ expires_at: new Date(expires).toISOString(),
293
+ rate_limited_until: new Date(0).toISOString(),
294
+ },
295
+ ],
296
+ },
297
+ {
298
+ provider: 'openai-codex',
299
+ managed: true,
300
+ configured: true,
301
+ accounts: [
302
+ {
303
+ label: 'ChatGPT',
304
+ expires_at: new Date(expires).toISOString(),
305
+ rate_limited_until: new Date(0).toISOString(),
306
+ account_id: 'codex-account-id',
307
+ },
308
+ ],
309
+ },
310
+ { provider: 'github-copilot', managed: false, configured: true, kind: 'oauth' },
311
+ ]);
312
+ const wire = JSON.stringify(status);
313
+ for (const secret of [
314
+ 'anthropic-refresh-secret',
315
+ 'anthropic-access-secret',
316
+ 'second-refresh-secret',
317
+ 'second-access-secret',
318
+ 'codex-refresh-secret',
319
+ 'codex-access-secret',
320
+ 'copilot-refresh-secret',
321
+ 'copilot-access-secret',
322
+ 'anthropic-api-key-secret',
323
+ ]) {
324
+ assert.ok(!wire.includes(secret), `status never exposes ${secret}`);
325
+ }
326
+ const removedManaged = await client.removeCredential('anthropic');
327
+ assert.deepEqual(removedManaged, { provider: 'anthropic', removed_accounts: 3 });
328
+ assert.deepEqual(readSubscriptionPool('anthropic'), [], 'selected managed pool is empty');
329
+ assert.ok(!Object.hasOwn(await readAuthCredentials(join(apiDir, 'auth.json')), 'anthropic'), 'managed API key is removed');
330
+ assert.equal(readSubscriptionPool('openai-codex').length, 1, 'other managed pool is unchanged');
331
+ const removedOauth = await client.removeCredential('github-copilot');
332
+ assert.deepEqual(removedOauth, { provider: 'github-copilot', removed_accounts: 1 });
333
+ assert.deepEqual((await client.listModelAuth()).providers.map((provider) => provider.provider), ['anthropic', 'openai-codex'], 'deleted auth.json credential no longer appears in status');
334
+ }
335
+ finally {
336
+ if (savedPiDir === undefined)
337
+ delete process.env['PI_CODING_AGENT_DIR'];
338
+ else
339
+ process.env['PI_CODING_AGENT_DIR'] = savedPiDir;
340
+ rmSync(apiDir, { recursive: true, force: true });
341
+ }
342
+ });
@@ -8,12 +8,14 @@
8
8
  // fans `reload_auth` to live brokers — a write here is picked up by that watcher
9
9
  // (do NOT re-implement the fan).
10
10
  //
11
+ // GET /v1/model-auth sanitized credential presence
11
12
  // PUT /v1/model-auth/:provider install/rotate a credential (idempotent)
13
+ // DELETE /v1/model-auth/:provider remove all credentials for one provider
12
14
  import { homedir } from 'node:os';
13
15
  import { join } from 'node:path';
14
- import { setAuthCredential } from '../../../core/auth-file.js';
16
+ import { deleteAuthCredential, readAuthCredentials, setAuthCredential } from '../../../core/auth-file.js';
15
17
  import { ApiError } from '../../../api/index.js';
16
- import { ANTHROPIC_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, commitManagedLogin, readSubscriptionPool, } from '../../../core/subscription-state.js';
18
+ import { ANTHROPIC_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID, commitManagedLogin, readSubscriptionPool, removeAllManagedAccounts, } from '../../../core/subscription-state.js';
17
19
  /** Resolve the shared pi credentials file, matching the daemon's auth.json
18
20
  * mtime watcher exactly (`PI_CODING_AGENT_DIR` env, else `~/.pi/agent`) so a
19
21
  * write here is the file that watcher fans a `reload_auth` for. */
@@ -43,6 +45,37 @@ function requireField(kind, field, value) {
43
45
  }
44
46
  return value;
45
47
  }
48
+ function toManagedAccountStatus(account) {
49
+ return {
50
+ label: account.label,
51
+ expires_at: new Date(account.expires).toISOString(),
52
+ rate_limited_until: new Date(account.rateLimitedUntil).toISOString(),
53
+ ...(account.authFailure ? { auth_failure: account.authFailure } : {}),
54
+ ...(account.accountId ? { account_id: account.accountId } : {}),
55
+ };
56
+ }
57
+ function managedStatus(provider) {
58
+ const accounts = readSubscriptionPool(provider).map(toManagedAccountStatus);
59
+ return { provider, managed: true, configured: accounts.length > 0, accounts };
60
+ }
61
+ /** `GET /v1/model-auth` — credential truth projected without tokens, keys, or
62
+ * other auth-file material. Managed providers always reflect their complete
63
+ * pool rows, while non-managed presence reflects supported auth.json kinds. */
64
+ async function handleList() {
65
+ const credentials = await readAuthCredentials(authPath());
66
+ const providers = [
67
+ managedStatus(ANTHROPIC_PROVIDER_ID),
68
+ managedStatus(OPENAI_CODEX_PROVIDER_ID),
69
+ ];
70
+ for (const [provider, credential] of Object.entries(credentials)) {
71
+ if (isManagedProvider(provider))
72
+ continue;
73
+ if (credential.type !== 'api_key' && credential.type !== 'oauth')
74
+ continue;
75
+ providers.push({ provider, managed: false, configured: true, kind: credential.type });
76
+ }
77
+ return { status: 200, body: { providers } };
78
+ }
46
79
  /** `PUT /v1/model-auth/:provider` — idempotent upsert/rotate keyed by provider
47
80
  * (and `account_id` for a managed account). */
48
81
  async function handleInstall(ctx) {
@@ -111,6 +144,22 @@ async function handleInstall(ctx) {
111
144
  throw new ApiError(400, 'invalid_request', `unknown credential kind: ${String(req.kind)}`);
112
145
  }
113
146
  }
147
+ /** `DELETE /v1/model-auth/:provider` — remove all durable user credentials for
148
+ * one provider. Managed OAuth accounts are cleared through their pool lock and
149
+ * any managed-provider API key through the auth.json lock; other providers use
150
+ * the auth.json owner alone. */
151
+ async function handleDelete(ctx) {
152
+ const provider = ctx.params['provider'];
153
+ if (isManagedProvider(provider)) {
154
+ const removedAccounts = await removeAllManagedAccounts(provider);
155
+ const removedApiKey = await deleteAuthCredential(provider, authPath());
156
+ return { status: 200, body: { provider, removed_accounts: removedAccounts + (removedApiKey ? 1 : 0) } };
157
+ }
158
+ const removedAccounts = await deleteAuthCredential(provider, authPath()) ? 1 : 0;
159
+ return { status: 200, body: { provider, removed_accounts: removedAccounts } };
160
+ }
114
161
  export const modelAuthRoutes = [
162
+ { method: 'GET', pattern: '/v1/model-auth', handler: handleList },
115
163
  { method: 'PUT', pattern: '/v1/model-auth/:provider', handler: handleInstall },
164
+ { method: 'DELETE', pattern: '/v1/model-auth/:provider', handler: handleDelete },
116
165
  ];
@@ -365,6 +365,7 @@ function handleDemote(ctx) {
365
365
  ...(meta.launch?.modelProviderPinned !== undefined
366
366
  ? { modelProviderPinned: meta.launch.modelProviderPinned }
367
367
  : {}),
368
+ ...(meta.launch?.modelIntent !== undefined ? { modelIntent: meta.launch.modelIntent } : {}),
368
369
  cwd: meta.cwd,
369
370
  profileId: meta.profile_id ?? null,
370
371
  });
@@ -485,6 +486,7 @@ async function handleConfig(ctx) {
485
486
  hasManager: meta.parent !== null,
486
487
  ...(meta.model_override != null ? { model: meta.model_override } : {}),
487
488
  ...(meta.launch?.modelProviderPinned !== undefined ? { modelProviderPinned: meta.launch.modelProviderPinned } : {}),
489
+ ...(meta.launch?.modelIntent !== undefined ? { modelIntent: meta.launch.modelIntent } : {}),
488
490
  cwd: meta.cwd,
489
491
  profileId: meta.profile_id ?? null,
490
492
  });
package/dist/types.d.ts CHANGED
@@ -99,6 +99,19 @@ export interface ModelLaddersConfig {
99
99
  anthropic: Record<ModelStrength, string>;
100
100
  openai: Record<ModelStrength, string>;
101
101
  }
102
+ /** A declared provider/model route. Route ids are the keys of `modelRoutes` so
103
+ * scopes can override one route without restating the registry. */
104
+ export interface ModelRouteConfig {
105
+ family: string;
106
+ credentialSource: string;
107
+ providerId: string;
108
+ models: Partial<Record<ModelStrength, string>>;
109
+ }
110
+ export interface ModelRoutingConfig {
111
+ credentialSourceOrder?: string[];
112
+ familyOrder?: string[];
113
+ strengthFallback?: ModelStrength[];
114
+ }
102
115
  /** Launch metadata for one kind (a top-level kind like `developer`, or a full
103
116
  * sub-kind string like `plan/reviewers/security`) — the settings-file
104
117
  * replacement for per-kind persona frontmatter. `model`/`tools`/`extensions`
@@ -159,6 +172,10 @@ export interface ScopeConfig {
159
172
  fold_finished_tools: boolean;
160
173
  keybindings: KeybindingOverrides;
161
174
  modelLadders: ModelLaddersConfig;
175
+ /** Declarative provider/model routes. Absent uses the synthesized ladder registry. */
176
+ modelRoutes?: Record<string, ModelRouteConfig>;
177
+ /** Ordering overrides for declared model routes. */
178
+ modelRouting?: ModelRoutingConfig;
162
179
  /** The kind registry (spec §1.5): kind existence + launch knobs, keyed by
163
180
  * full kind string (top-level e.g. `developer`, or sub-kind e.g.
164
181
  * `plan/reviewers/security`). Builtins ship a default registry via