@north-light/crouter 0.3.160 → 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 (50) 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/__tests__/markdown-headings.test.js +8 -0
  10. package/dist/clients/attach/render/glyph-codepoints.d.ts +3 -0
  11. package/dist/clients/attach/render/glyph-codepoints.js +42 -0
  12. package/dist/clients/attach/render/markdown-headings.js +15 -4
  13. package/dist/clients/attach/viewer.js +558 -558
  14. package/dist/core/__tests__/broker-launch-candidates.test.js +13 -3
  15. package/dist/core/__tests__/broker-sdk-wiring.test.js +53 -25
  16. package/dist/core/__tests__/full/broker-provider-retry.test.js +6 -122
  17. package/dist/core/__tests__/model-pin-durability.test.js +7 -5
  18. package/dist/core/__tests__/model-routes.test.d.ts +1 -0
  19. package/dist/core/__tests__/model-routes.test.js +41 -0
  20. package/dist/core/__tests__/review-model-floor.test.js +2 -2
  21. package/dist/core/auth-file.d.ts +3 -2
  22. package/dist/core/auth-file.js +5 -3
  23. package/dist/core/canvas/labels.d.ts +5 -3
  24. package/dist/core/canvas/labels.js +7 -4
  25. package/dist/core/canvas/types.d.ts +6 -1
  26. package/dist/core/config.d.ts +3 -1
  27. package/dist/core/config.js +88 -2
  28. package/dist/core/model-routes.d.ts +37 -0
  29. package/dist/core/model-routes.js +140 -0
  30. package/dist/core/runtime/broker.d.ts +0 -4
  31. package/dist/core/runtime/broker.js +116 -372
  32. package/dist/core/runtime/launch.d.ts +9 -11
  33. package/dist/core/runtime/launch.js +15 -21
  34. package/dist/core/runtime/managed-provider-cooling.d.ts +0 -14
  35. package/dist/core/runtime/managed-provider-cooling.js +0 -19
  36. package/dist/core/runtime/model-swap.js +7 -1
  37. package/dist/core/runtime/naming.js +21 -14
  38. package/dist/core/runtime/promote.js +1 -0
  39. package/dist/core/subscription-state.d.ts +12 -36
  40. package/dist/core/subscription-state.js +19 -203
  41. package/dist/daemon/api/__tests__/full/api-server.test.js +9 -6
  42. package/dist/daemon/api/__tests__/full/b10-attach-modelauth.test.js +123 -1
  43. package/dist/daemon/api/handlers/modelauth.js +51 -2
  44. package/dist/daemon/api/handlers/nodes.js +2 -0
  45. package/dist/types.d.ts +17 -0
  46. package/dist/web-client/assets/{index-B76ZKfT_.js → index-NIuSCOHM.js} +1 -1
  47. package/dist/web-client/index.html +1 -1
  48. package/dist/web-client/sw.js +1 -1
  49. package/package.json +8 -8
  50. package/runtime.lock.json +161 -161
@@ -128,15 +128,18 @@ test('A3: messages append inbox.jsonl; final report writes + notifies subscriber
128
128
  assert.equal(h.node(B)?.status, 'done', 'row status is done after final');
129
129
  });
130
130
  // ---------------------------------------------------------------------------
131
- // A4 — WS attach → welcome.snapshot byte-identical vs a direct ViewSocketClient;
132
- // ?revive=0 on a dormant node → 409 without launching a broker
131
+ // A4 — WS attach reconstructs the same settled broker snapshot as a direct
132
+ // ViewSocketClient; ?revive=0 on a dormant node → 409 without launching a broker
133
133
  // ---------------------------------------------------------------------------
134
134
  test('A4a: WS attach welcome.snapshot is byte-identical to a direct ViewSocketClient snapshot', async () => {
135
135
  const root = h.spawnRoot('a4 root');
136
136
  const id = (await client.createNode({ kind: 'developer', parent: root, prompt: 'a4 worker' })).node_id;
137
137
  await h.awaitBoot(id);
138
- // Direct capture via the production ViewSocketClient (the attach kit).
139
- const direct = await kit.attach(id, 'observer', 'a4-direct');
138
+ // Await the fake engine's startup turn settling before either transport is
139
+ // compared. `awaitBoot()` proves the process started, not that its initial
140
+ // prompt has stopped streaming; comparing it with a later WS welcome races
141
+ // two real broker instants.
142
+ const direct = await kit.attachUntil(id, 'observer', 'a4-direct', (attached) => attached.welcome.snapshot.state.isStreaming === false, 'settled direct broker snapshot');
140
143
  // WS capture over the unix socket at the attach path. The node is already
141
144
  // live+booted, so ?revive=0 is fine (the socket is listenable).
142
145
  const wsWelcome = await new Promise((resolveWelcome, rejectWelcome) => {
@@ -164,8 +167,8 @@ test('A4a: WS attach welcome.snapshot is byte-identical to a direct ViewSocketCl
164
167
  rejectWelcome(err);
165
168
  });
166
169
  });
167
- // Compare the SNAPSHOT field only (role differs controller/observer between the
168
- // two connections; the reconstructed snapshot of an idle broker is stable).
170
+ // Compare the SNAPSHOT field only (role is outside the snapshot). Both
171
+ // transports captured the same settled broker state.
169
172
  assert.equal(JSON.stringify(wsWelcome.snapshot), JSON.stringify(direct.welcome.snapshot), 'WS welcome.snapshot is byte-identical to the direct ViewSocketClient snapshot');
170
173
  direct.close();
171
174
  });
@@ -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