@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
@@ -1,4 +1,4 @@
1
- import { test } from 'node:test';
1
+ import { after, before, test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { mkdtempSync, rmSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
@@ -9,6 +9,16 @@ import { buildBrokerSession, isUnknownModel } from '../runtime/broker.js';
9
9
  import { resolveProviderCandidates } from '../subscription-state.js';
10
10
  const realEngine = { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION };
11
11
  const C3_EXT = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'c3-custom-provider-ext.ts');
12
+ const previousModelIntent = process.env['CRTR_MODEL_INTENT'];
13
+ before(() => {
14
+ delete process.env['CRTR_MODEL_INTENT'];
15
+ });
16
+ after(() => {
17
+ if (previousModelIntent === undefined)
18
+ delete process.env['CRTR_MODEL_INTENT'];
19
+ else
20
+ process.env['CRTR_MODEL_INTENT'] = previousModelIntent;
21
+ });
12
22
  const requested = { provider: 'anthropic', id: 'requested' };
13
23
  const alternative = { provider: 'openai-codex', id: 'alternative' };
14
24
  const DEFAULT_MANAGED_PROVIDERS = new Set(['anthropic', 'openai-codex']);
@@ -92,7 +102,7 @@ test('launch candidate resolver selects a non-managed candidate as automatic fal
92
102
  { spec: 'missing/missing', reason: 'not registered' },
93
103
  ]);
94
104
  });
95
- test('broker launch falls back to a non-managed provider when no managed provider is authenticated', async () => {
105
+ test('broker launch keeps an unregistered concrete model pinned even when a non-managed provider is available', async () => {
96
106
  const cwd = mkdtempSync(join(tmpdir(), 'crtr-launch-candidate-'));
97
107
  try {
98
108
  const engine = {
@@ -111,7 +121,7 @@ test('broker launch falls back to a non-managed provider when no managed provide
111
121
  };
112
122
  const { session } = await buildBrokerSession(engine, { cwd, extensionPaths: [C3_EXT], model: 'missing/missing' });
113
123
  try {
114
- assert.equal(`${session.model?.provider}/${session.model?.id}`, 'c3prov/c3model');
124
+ assert.equal(`${session.model?.provider}/${session.model?.id}`, 'unknown/unknown');
115
125
  }
116
126
  finally {
117
127
  session.dispose();
@@ -18,7 +18,7 @@
18
18
  //
19
19
  // These run offline (no network/auth): registration + system-prompt assembly +
20
20
  // dialog routing are all local. No tmux, no broker process — direct unit drives.
21
- import { test } from 'node:test';
21
+ import { after, before, test } from 'node:test';
22
22
  import assert from 'node:assert/strict';
23
23
  import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
24
24
  import { spawnSync } from 'node:child_process';
@@ -27,7 +27,6 @@ import { join, dirname } from 'node:path';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION, } from '../runtime/broker-sdk.js';
29
29
  import { buildBrokerSession, makeBrokerUiContext, isLeadingEngineCommand, chooseGuidanceDeliveryMode, promptWithNextTurnGuidance, isHiddenJsonlBranchEntry, stripHiddenEntriesFromJsonlExport, engineLeadingCommandTokens, } from '../runtime/broker.js';
30
- import { readManagedProviderRotationPolicy } from '../runtime/managed-provider-cooling.js';
31
30
  import { CANVAS_EXTENSIONS } from '../runtime/launch.js';
32
31
  import { buildRefInventory } from '../memory/inline-ref-inventory.js';
33
32
  import { buildGuidance } from '../memory/inline-ref-guidance.js';
@@ -45,6 +44,32 @@ const realEngine = {
45
44
  const HERE = dirname(fileURLToPath(import.meta.url));
46
45
  const C3_EXT = join(HERE, 'fixtures', 'c3-custom-provider-ext.ts');
47
46
  const C5_EXT = join(HERE, 'fixtures', 'c5-command-boundary-ext.ts');
47
+ const priorModelIntent = process.env['CRTR_MODEL_INTENT'];
48
+ const priorNodeCwd = process.env['CRTR_NODE_CWD'];
49
+ const priorProfileId = process.env['CRTR_PROFILE_ID'];
50
+ before(() => {
51
+ // buildBrokerSession normally receives its environment through buildBrokerEnv,
52
+ // which strips the launcher's CRTR_* values before applying the target recipe.
53
+ // These direct SDK tests bypass that boundary, so clear the ambient node's
54
+ // routing scope and intent to exercise only each fixture's explicit config.
55
+ delete process.env['CRTR_MODEL_INTENT'];
56
+ delete process.env['CRTR_NODE_CWD'];
57
+ delete process.env['CRTR_PROFILE_ID'];
58
+ });
59
+ after(() => {
60
+ if (priorModelIntent === undefined)
61
+ delete process.env['CRTR_MODEL_INTENT'];
62
+ else
63
+ process.env['CRTR_MODEL_INTENT'] = priorModelIntent;
64
+ if (priorNodeCwd === undefined)
65
+ delete process.env['CRTR_NODE_CWD'];
66
+ else
67
+ process.env['CRTR_NODE_CWD'] = priorNodeCwd;
68
+ if (priorProfileId === undefined)
69
+ delete process.env['CRTR_PROFILE_ID'];
70
+ else
71
+ process.env['CRTR_PROFILE_ID'] = priorProfileId;
72
+ });
48
73
  function brokerExts() {
49
74
  return [...CANVAS_EXTENSIONS, C3_EXT];
50
75
  }
@@ -138,7 +163,7 @@ function runIsolatedManagedLaunch(pool) {
138
163
  const script = `
139
164
  const { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION } = await import(${JSON.stringify(new URL('../runtime/broker-sdk.ts', import.meta.url).href)});
140
165
  const { buildBrokerSession } = await import(${JSON.stringify(new URL('../runtime/broker.ts', import.meta.url).href)});
141
- const { writeSubscriptionPool, readRotationConfig } = await import(${JSON.stringify(new URL('../subscription-state.ts', import.meta.url).href)});
166
+ const { writeSubscriptionPool } = await import(${JSON.stringify(new URL('../subscription-state.ts', import.meta.url).href)});
142
167
  await writeSubscriptionPool('anthropic', ${JSON.stringify(pool)});
143
168
  const realEngine = { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION };
144
169
  const engine = {
@@ -160,7 +185,7 @@ function runIsolatedManagedLaunch(pool) {
160
185
  },
161
186
  };
162
187
  const { session } = await buildBrokerSession(engine, { cwd: ${JSON.stringify(cwd)}, extensionPaths: [${JSON.stringify(C3_EXT)}], model: 'anthropic/cooling-model' });
163
- console.log(JSON.stringify({ provider: session.model?.provider, id: session.model?.id, preferred: readRotationConfig().preferredModel }));
188
+ console.log(JSON.stringify({ provider: session.model?.provider, id: session.model?.id }));
164
189
  session.dispose();
165
190
  `;
166
191
  try {
@@ -177,8 +202,23 @@ function runIsolatedManagedLaunch(pool) {
177
202
  rmSync(cwd, { recursive: true, force: true });
178
203
  }
179
204
  }
180
- test('C3f — broker launch falls back to a non-managed provider when no managed provider is authenticated', async () => {
205
+ test('C3f — a logical request can reach a non-managed raw-registry fallback when no managed provider is authenticated', async () => {
181
206
  const cwd = mkdtempSync(join(tmpdir(), 'crtr-c3c-'));
207
+ const priorIntent = process.env['CRTR_MODEL_INTENT'];
208
+ const priorNodeCwd = process.env['CRTR_NODE_CWD'];
209
+ process.env['CRTR_MODEL_INTENT'] = 'custom/strong';
210
+ process.env['CRTR_NODE_CWD'] = cwd;
211
+ mkdirSync(join(cwd, '.crouter'), { recursive: true });
212
+ writeFileSync(join(cwd, '.crouter', 'config.json'), `${JSON.stringify({
213
+ modelRoutes: {
214
+ c3: {
215
+ family: 'custom',
216
+ credentialSource: 'user',
217
+ providerId: 'c3prov',
218
+ models: { strong: 'c3prov/c3model' },
219
+ },
220
+ },
221
+ })}\n`);
182
222
  try {
183
223
  const deterministicEngine = {
184
224
  ...realEngine,
@@ -209,6 +249,14 @@ test('C3f — broker launch falls back to a non-managed provider when no managed
209
249
  }
210
250
  }
211
251
  finally {
252
+ if (priorIntent === undefined)
253
+ delete process.env['CRTR_MODEL_INTENT'];
254
+ else
255
+ process.env['CRTR_MODEL_INTENT'] = priorIntent;
256
+ if (priorNodeCwd === undefined)
257
+ delete process.env['CRTR_NODE_CWD'];
258
+ else
259
+ process.env['CRTR_NODE_CWD'] = priorNodeCwd;
212
260
  rmSync(cwd, { recursive: true, force: true });
213
261
  }
214
262
  });
@@ -218,7 +266,6 @@ test('C3g — an explicit managed subscription pool authenticates a registered r
218
266
  ]);
219
267
  assert.equal(selected.provider, 'anthropic');
220
268
  assert.equal(selected.id, 'cooling-model');
221
- assert.equal(selected.preferred, undefined);
222
269
  });
223
270
  test('C3h — a cooling managed launch target boots model-less rather than falling back to a non-managed provider', () => {
224
271
  // The managed anthropic target is registered + pool-authenticated but COOLING
@@ -233,7 +280,6 @@ test('C3h — a cooling managed launch target boots model-less rather than falli
233
280
  ]);
234
281
  assert.equal(selected.provider, 'unknown');
235
282
  assert.equal(selected.id, 'unknown');
236
- assert.equal(selected.preferred, undefined);
237
283
  });
238
284
  // (C4 — project guidance now comes from the document substrate: crouter keeps
239
285
  // `noContextFiles: true` in buildBrokerSession, and buildContextIntro's
@@ -271,24 +317,6 @@ test('C2 — zero-writable UI context resolves dialogs to deny/cancel immediatel
271
317
  assert.equal(await ctx.input('name'), undefined, 'C2: input() cancels (undefined)');
272
318
  assert.equal(await ctx.editor('edit'), undefined, 'C2: editor() cancels (undefined)');
273
319
  });
274
- test('broker UI exposes the live provider pin policy to package extensions', () => {
275
- let pinned = false;
276
- const ctx = makeBrokerUiContext({
277
- writable: () => [],
278
- forward: () => { },
279
- pending: new Map(),
280
- broadcast: () => { },
281
- providerRotationPolicy: () => pinned
282
- ? { allowCrossProviderFallback: false, reason: 'provider-pinned' }
283
- : { allowCrossProviderFallback: true },
284
- });
285
- assert.deepEqual(readManagedProviderRotationPolicy(ctx), { allowCrossProviderFallback: true });
286
- pinned = true;
287
- assert.deepEqual(readManagedProviderRotationPolicy(ctx), {
288
- allowCrossProviderFallback: false,
289
- reason: 'provider-pinned',
290
- }, 'the same running UI context observes a live set_model pin change');
291
- });
292
320
  // ===========================================================================
293
321
  // M2 (review mq5wkqep / T4) — REPLACES the Wave-0 M-1 cancel-on-detach. A dialog
294
322
  // forwarded to a writable viewer that then DETACHES must NOT be cancelled: it
@@ -5,14 +5,13 @@
5
5
  // exercises runBroker's live retry handler rather than its helpers in isolation.
6
6
  import { test, before, after, afterEach } from 'node:test';
7
7
  import assert from 'node:assert/strict';
8
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { createHeadlessHarness } from '../helpers/harness.js';
11
11
  import { createAttachKit } from '../helpers/broker-clients.js';
12
12
  import { defaultModelLaddersConfig } from '../../../types.js';
13
13
  import { readFault } from '../../runtime/fault.js';
14
14
  import { normalizeModel } from '../../runtime/launch.js';
15
- import { setModelLive } from '../../runtime/model-swap.js';
16
15
  const MODEL_LADDERS = defaultModelLaddersConfig();
17
16
  function resolvedModel(spec) {
18
17
  const normalized = normalizeModel(spec, MODEL_LADDERS);
@@ -21,9 +20,8 @@ function resolvedModel(spec) {
21
20
  assert.notEqual(slash, -1, `${spec} must normalize to a qualified provider/model spec`);
22
21
  const provider = modelSpec.slice(0, slash);
23
22
  const id = modelSpec.slice(slash + 1);
24
- return { spec: normalized, model: { provider, id }, pin: { providerId: provider, modelId: id } };
23
+ return { spec: normalized, model: { provider, id } };
25
24
  }
26
- const ANTHROPIC_STRONG = resolvedModel('anthropic/strong');
27
25
  const OPENAI_STRONG = resolvedModel('openai/strong');
28
26
  let h;
29
27
  const kit = createAttachKit(() => h);
@@ -39,20 +37,6 @@ function seedPool(provider, cooling) {
39
37
  rateLimitedUntil: cooling ? Date.now() + 60_000 : 0, lastAttemptAt: 0, lastRateLimitedAt: 0,
40
38
  }]));
41
39
  }
42
- function rotationPath() {
43
- return join(h.agentHome, '.pi', 'agent', 'provider-rotation.json');
44
- }
45
- function clearRotation() {
46
- rmSync(rotationPath(), { force: true });
47
- }
48
- function preferred() {
49
- try {
50
- return JSON.parse(readFileSync(rotationPath(), 'utf8')).preferredModel;
51
- }
52
- catch {
53
- return undefined;
54
- }
55
- }
56
40
  function model(id) {
57
41
  return JSON.parse(readFileSync(join(h.home, 'nodes', id, 'fake-pi.model.json'), 'utf8'));
58
42
  }
@@ -76,28 +60,6 @@ function agentStartCount(id) {
76
60
  return 0;
77
61
  }
78
62
  }
79
- async function autoRetry(id, expected) {
80
- h.fakeCmd(id, { cmd: 'auto_retry', errorMessage: 'rate limit exceeded' });
81
- await h.waitFor(() => {
82
- try {
83
- return assert.deepEqual(model(id), expected) === undefined ? true : false;
84
- }
85
- catch {
86
- return false;
87
- }
88
- }, { label: `auto-retry switch for ${id}` });
89
- }
90
- // The auto-retry receipt file's timestamp, so re-firing auto_retry a second/
91
- // third time in one test doesn't false-positive on a STALE receipt that
92
- // already existed from an earlier fire in the same test.
93
- function autoRetryAt(id) {
94
- try {
95
- return JSON.parse(readFileSync(join(h.home, 'nodes', id, 'fake-pi.auto-retry.json'), 'utf8')).at;
96
- }
97
- catch {
98
- return 0;
99
- }
100
- }
101
63
  before(async () => {
102
64
  h = await createHeadlessHarness({ sessionPrefix: 'crtr-provider-retry' });
103
65
  });
@@ -108,61 +70,12 @@ after(async () => {
108
70
  afterEach(() => {
109
71
  kit.closeAll();
110
72
  });
111
- test('broker auto-retry preserves the launch requested pin, clears it on restoration, and never persists the temporary fallback', { timeout: 30_000 }, async () => {
112
- const parent = h.spawnRoot('provider retry parent');
113
- seedPool('anthropic', true);
114
- seedPool('openai-codex', false);
115
- clearRotation();
116
- const child = await h.spawnHeadlessChild(parent, 'launch fallback then retry', { model: ANTHROPIC_STRONG.spec });
117
- assert.deepEqual(model(child), OPENAI_STRONG.model, 'a cooling requested managed launch starts its viable equivalent');
118
- assert.deepEqual(preferred(), ANTHROPIC_STRONG.pin, 'launch retains the requested managed model as its restoration pin');
119
- const before = h.node(child);
120
- seedPool('anthropic', false);
121
- await autoRetry(child, ANTHROPIC_STRONG.model);
122
- await h.waitFor(() => preferred() === undefined, { label: 'launch preferred pin cleared after restoration' });
123
- assert.deepEqual(preferred(), undefined, 'reaching the launch-requested provider fulfils and clears its temporary pin');
124
- const after = h.node(child);
125
- assert.equal(after.model_override, before.model_override, 'turn-time fallback never overwrites the durable model override');
126
- assert.equal(after.launch?.model, before.launch?.model, 'turn-time fallback never overwrites the durable launch recipe');
127
- });
128
- test('broker auto-retry records its current managed model only when no restoration pin exists', { timeout: 30_000 }, async () => {
129
- const parent = h.spawnRoot('normal retry parent');
130
- seedPool('anthropic', false);
131
- seedPool('openai-codex', false);
132
- clearRotation();
133
- // Establish the exact qualified OpenAI ladder cell, then explicitly release
134
- // its structural pin so this test reaches the retry resolver by construction.
135
- const child = await h.spawnHeadlessChild(parent, 'normal retry', { model: OPENAI_STRONG.spec });
136
- await setModelLive(child, OPENAI_STRONG.spec, false);
137
- clearRotation();
138
- const before = h.node(child);
139
- assert.deepEqual(model(child), OPENAI_STRONG.model);
140
- await autoRetry(child, ANTHROPIC_STRONG.model);
141
- await h.waitFor(() => {
142
- try {
143
- assert.deepEqual(preferred(), OPENAI_STRONG.pin);
144
- return true;
145
- }
146
- catch {
147
- return false;
148
- }
149
- }, { label: 'normal retry preferred model recorded' });
150
- assert.deepEqual(preferred(), OPENAI_STRONG.pin, 'a normal automatic switch records its failed managed model for later restoration');
151
- const after = h.node(child);
152
- assert.equal(after.model_override, before.model_override);
153
- assert.equal(after.launch?.model, before.launch?.model);
154
- });
155
73
  test('a managed-cooling terminal authors a rate-limit auto fault whose deadline drives exactly one broker re-drive', { timeout: 30_000 }, async () => {
156
74
  const parent = h.spawnRoot('cooling terminal parent');
157
75
  // The terminal fault path is pool-independent — the stophook classifies purely
158
76
  // off the errorMessage text + the cooling diagnostic, so pool state is inert.
159
- // A qualified openai-codex id now pins structurally, but that has no effect
160
- // here either way: this test never triggers retry/not-found policy (only the
161
- // stophook fault path + launch-time pinnedToFirst, which is a no-op because
162
- // the pool isn't cooling at spawn).
163
77
  seedPool('anthropic', false);
164
78
  seedPool('openai-codex', false);
165
- clearRotation();
166
79
  const child = await h.spawnHeadlessChild(parent, 'cooling terminal', { model: OPENAI_STRONG.spec });
167
80
  await h.waitFor(() => agentStartCount(child) >= 1, { label: 'initial cooling fixture turn started' });
168
81
  const baselineStarts = agentStartCount(child);
@@ -332,47 +245,18 @@ test('raw overflow settling without compaction terminates instead of hanging', {
332
245
  await h.tick();
333
246
  assert.equal(h.status(child), 'dead', 'settled raw overflow cannot leave the child hanging');
334
247
  });
335
- test("a live set_model decision updates the running broker's pin policy immediately, in both directions, without a revive", { timeout: 30_000 }, async () => {
336
- const parent = h.spawnRoot('live pin policy parent');
337
- seedPool('anthropic', false);
338
- seedPool('openai-codex', false);
339
- clearRotation();
340
- // Establish the desired qualified Anthropic ladder cell while explicitly
341
- // keeping its live retry policy unpinned, independent of ambient defaults.
342
- const child = await h.spawnHeadlessChild(parent, 'live pin policy', { model: ANTHROPIC_STRONG.spec });
343
- await setModelLive(child, ANTHROPIC_STRONG.spec, false);
344
- clearRotation();
345
- assert.deepEqual(model(child), ANTHROPIC_STRONG.model, 'explicit setup selects the current Anthropic strong model');
346
- // false → true, LIVE: an explicit raw qualified re-pick pins the SAME
347
- // resolved model — no model value changes, only the live pin decision. The
348
- // running broker's retry-fallback policy (broker.ts's cfg.modelProviderPinned
349
- // read) enforces it on the VERY NEXT retry, with no revive in between: the
350
- // auto_retry below is suppressed rather than allowed to rotate the model.
351
- await setModelLive(child, ANTHROPIC_STRONG.spec, true);
352
- assert.deepEqual(model(child), ANTHROPIC_STRONG.model, 'the explicit re-pick keeps the same resolved model');
353
- const priorAt1 = autoRetryAt(child);
354
- h.fakeCmd(child, { cmd: 'auto_retry', errorMessage: 'rate limit exceeded' });
355
- await h.waitFor(() => autoRetryAt(child) > priorAt1, { label: 'pinned auto-retry processed' });
356
- // Settle past the fallback's own async continuation window.
357
- await new Promise((resolve) => setTimeout(resolve, 500));
358
- assert.deepEqual(model(child), ANTHROPIC_STRONG.model, 'a live pin suppresses turn-time provider rotation immediately, without revive');
359
- assert.deepEqual(preferred(), undefined, 'a suppressed retry records no restoration pin');
360
- // true → false, LIVE: an explicit re-pick of the same qualified model with
361
- // pinned=false releases the pin. The next retry must now be free to rotate.
362
- await setModelLive(child, ANTHROPIC_STRONG.spec, false);
363
- await autoRetry(child, OPENAI_STRONG.model);
364
- assert.deepEqual(preferred(), ANTHROPIC_STRONG.pin, 'the released pin lets the next retry rotate and record a restoration pin');
365
- });
366
- test('a picker-originated set_model frame with no pinned field derives the pin from the real registry provider namespace; an internal no-raw-token path preserves it', { timeout: 30_000 }, async () => {
248
+ test('an explicit picker choice pins and clears prior logical routing; an internal no-raw-token path preserves that decision', { timeout: 30_000 }, async () => {
367
249
  const parent = h.spawnRoot('picker frame parent');
368
250
  seedPool('anthropic', false);
369
251
  seedPool('openai-codex', false);
370
- const child = await h.spawnHeadlessChild(parent, 'picker frame child', { model: 'strong' }); // unpinned boot
252
+ const child = await h.spawnHeadlessChild(parent, 'picker frame child', { model: 'openai/strong' }); // logical route boot
371
253
  const ctrl = await kit.attach(child, 'controller', 'picker-test');
372
254
  ctrl.send({ type: 'set_model', model: OPENAI_STRONG.spec }); // exactly what buildModelPicker sends — no `pinned`
373
255
  await ctrl.waitFrame((f) => f.type === 'ack' && f.for === 'set_model', 'picker set_model acked');
374
256
  let after = h.node(child);
375
257
  assert.equal(after.launch?.modelProviderPinned, true, 'the omitted-pinned frame derives pinned from the real openai-codex registry namespace');
258
+ assert.equal(after.launch?.modelIntent, undefined, 'the explicit concrete choice clears the logical launch intent');
259
+ assert.equal(after.launch?.env.CRTR_MODEL_INTENT, undefined, 'a revive cannot restore the stale logical route intent');
376
260
  // `cycle_ladder` — an internal no-raw-token path (no `model`/`pinned` on the
377
261
  // frame at all) — reuses the SAME resolve+setModel machinery `set_model` does
378
262
  // (real `nextLadderModel`/`resolveModelQuery`, the fake engine's real
@@ -2,8 +2,8 @@
2
2
  //
3
3
  // `modelProviderPinned` is durable node state with ONE derivation rule: it is
4
4
  // decided exactly once, at an explicit user model change, from that change's
5
- // RAW pre-normalize token — a qualified `<provider>/<model-id>` or
6
- // `<provider>/<tier>` token pins; a bare tier (`ultra`) or bare family alias
5
+ // RAW pre-normalize token — a concrete `<provider>/<model-id>` token pins;
6
+ // logical `<family>/<tier>` refs, bare tiers (`ultra`), and family aliases
7
7
  // (`opus`/`sonnet`/`haiku`) unpins. Every other rebuild or persist (a
8
8
  // polymorph/lifecycle change with no new `--model`, a dormant model write whose
9
9
  // spec happens to normalize to a concrete cell, cycle_model/cycle_ladder,
@@ -77,11 +77,11 @@ test('fixture: a bare tier is unpinned; its normalized concrete cell reads as pi
77
77
  assert.equal(isProviderPinnedModelToken('ultra'), false, 'a bare tier is unpinned');
78
78
  assert.equal(isProviderPinnedModelToken(ULTRA_CELL), true, 'a qualified concrete cell reads as pinned');
79
79
  });
80
- test('a qualified provider/id or provider/tier token pins structurally, across real registry provider namespaces', () => {
80
+ test('only concrete provider/id tokens pin; logical family/tier refs remain preferences', () => {
81
81
  assert.equal(isProviderPinnedModelToken('anthropic/claude-fable-5:high'), true, 'a concrete anthropic id pins');
82
82
  assert.equal(isProviderPinnedModelToken('openai-codex/gpt-5.6-sol:max'), true, 'the real openai-codex registry namespace pins, not just the family alias `openai`');
83
83
  assert.equal(isProviderPinnedModelToken('amazon-bedrock/some-model-id'), true, 'any qualified provider/id pins — no undocumented exception for a provider outside the three built-in families');
84
- assert.equal(isProviderPinnedModelToken('openai/ultra'), true, 'a qualified provider/tier pins too');
84
+ assert.equal(isProviderPinnedModelToken('openai/ultra'), false, 'a qualified family/tier ref is logical and unpinned');
85
85
  assert.equal(isProviderPinnedModelToken('ultra'), false, 'a bare tier has no `/` and stays unpinned');
86
86
  assert.equal(isProviderPinnedModelToken('opus'), false, 'a bare family alias has no `/` and stays unpinned');
87
87
  });
@@ -114,11 +114,13 @@ test('a deliberately tier-run node is not wrongly re-pinned when a rebuild or pe
114
114
  // the concrete spec ULTRA_CELL normalizes to.
115
115
  createNode(node('tier-node', {
116
116
  model_override: ULTRA_CELL,
117
- launch: { extensions: [], env: {}, model: ULTRA_CELL, modelProviderPinned: true },
117
+ launch: { extensions: [], env: { CRTR_MODEL_INTENT: 'openai/ultra' }, model: ULTRA_CELL, modelIntent: { family: 'openai', strength: 'ultra' }, modelProviderPinned: true },
118
118
  }));
119
119
  persistDormantModel('tier-node', ULTRA_CELL, false);
120
120
  const after = getNode('tier-node');
121
121
  assert.equal(after?.launch?.model, ULTRA_CELL, 'the concrete spec is persisted as the launch model');
122
122
  assert.equal(after?.model_override, ULTRA_CELL, 'the concrete spec is persisted as the override');
123
+ assert.equal(after?.launch?.modelIntent, undefined, 'an explicit concrete choice clears the prior logical route intent');
124
+ assert.equal(after?.launch?.env.CRTR_MODEL_INTENT, undefined, 'the revived broker does not inherit the prior logical route intent');
123
125
  assert.equal(after?.launch?.modelProviderPinned, false, 'an explicit bare-tier decision clears a prior pin — the raw-token decision wins over the concrete spec shape');
124
126
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,41 @@
1
+ import { after, before, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { expandModelCandidates } from '../model-routes.js';
7
+ const previousHome = process.env.HOME;
8
+ let home = '';
9
+ before(() => {
10
+ home = mkdtempSync(join(tmpdir(), 'crtr-model-routes-'));
11
+ process.env.HOME = home;
12
+ mkdirSync(join(home, '.crouter'), { recursive: true });
13
+ writeFileSync(join(home, '.crouter', 'config.json'), JSON.stringify({
14
+ modelRoutes: {
15
+ 'anthropic-user': { family: 'anthropic', credentialSource: 'user', providerId: 'anthropic', models: { strong: 'anthropic/a-strong', medium: 'anthropic/a-medium', light: 'anthropic/a-light' } },
16
+ 'openai-user': { family: 'openai', credentialSource: 'user', providerId: 'openai-codex', models: { strong: 'openai-codex/o-strong', medium: 'openai-codex/o-medium', light: 'openai-codex/o-light' } },
17
+ 'anthropic-funded': { family: 'anthropic', credentialSource: 'northlight', providerId: 'funded-anthropic', models: { strong: 'funded-anthropic/a-strong', medium: 'funded-anthropic/a-medium', light: 'funded-anthropic/a-light' } },
18
+ 'openai-funded': { family: 'openai', credentialSource: 'northlight', providerId: 'funded-openai', models: { strong: 'funded-openai/o-strong', medium: 'funded-openai/o-medium', light: 'funded-openai/o-light' } },
19
+ },
20
+ modelRouting: { credentialSourceOrder: ['user', 'northlight'], familyOrder: ['anthropic', 'openai'], strengthFallback: ['medium', 'light'] },
21
+ }));
22
+ });
23
+ after(() => {
24
+ if (previousHome === undefined)
25
+ delete process.env.HOME;
26
+ else
27
+ process.env.HOME = previousHome;
28
+ rmSync(home, { recursive: true, force: true });
29
+ });
30
+ test('declared routes expand credential source, strength, then family preference', () => {
31
+ const candidates = expandModelCandidates({ kind: 'logical', family: 'openai', strength: 'strong', spec: 'openai-codex/o-strong' }, home, null);
32
+ assert.deepEqual(candidates.map((candidate) => `${candidate.credentialSource}/${candidate.family}/${candidate.strength}`), [
33
+ 'user/openai/strong', 'user/anthropic/strong', 'user/openai/medium', 'user/anthropic/medium', 'user/openai/light', 'user/anthropic/light',
34
+ 'northlight/openai/strong', 'northlight/anthropic/strong', 'northlight/openai/medium', 'northlight/anthropic/medium', 'northlight/openai/light', 'northlight/anthropic/light',
35
+ ]);
36
+ assert.deepEqual(candidates.map((candidate) => candidate.rank), [...Array(12).keys()]);
37
+ });
38
+ test('concrete routes retain provider/model first and only move to same-model credential sources', () => {
39
+ const candidates = expandModelCandidates({ kind: 'concrete', spec: 'anthropic/a-strong' }, home, null);
40
+ assert.deepEqual(candidates.map((candidate) => `${candidate.providerId}/${candidate.modelId}`), ['anthropic/a-strong', 'funded-anthropic/a-strong']);
41
+ });
@@ -68,7 +68,7 @@ test('review still permits equal-or-stronger explicit models', () => {
68
68
  assert.equal(spec('review', 'strong'), expected('strong'));
69
69
  assert.equal(spec('review', 'ultra'), expected('ultra'));
70
70
  });
71
- test('explicit provider-qualified overrides preserve their provider pin through launch construction', () => {
71
+ test('concrete overrides stay pinned while logical provider/strength overrides remain unpinned', () => {
72
72
  const qualified = buildLaunchSpec('developer', 'base', {
73
73
  lifecycle: 'terminal', hasManager: true, cwd: home, profileId: null, model: 'anthropic/ultra',
74
74
  }).launch;
@@ -78,7 +78,7 @@ test('explicit provider-qualified overrides preserve their provider pin through
78
78
  const bare = buildLaunchSpec('developer', 'base', {
79
79
  lifecycle: 'terminal', hasManager: true, cwd: home, profileId: null, model: 'ultra',
80
80
  }).launch;
81
- assert.equal(qualified.modelProviderPinned, true);
81
+ assert.equal(qualified.modelProviderPinned, false);
82
82
  assert.equal(exact.modelProviderPinned, true);
83
83
  assert.equal(bare.modelProviderPinned, false);
84
84
  });
@@ -20,8 +20,9 @@ export declare function readAuthCredentials(authPath?: string): Promise<AuthFile
20
20
  export declare function readAuthCredential(providerId: string, authPath?: string): Promise<Credential | undefined>;
21
21
  /** Persist one provider's credential under the auth.json lock, leaving every other entry intact. */
22
22
  export declare function setAuthCredential(providerId: string, credential: Credential, authPath?: string): Promise<void>;
23
- /** Remove one provider's credential (logout) under the auth.json lock. */
24
- export declare function deleteAuthCredential(providerId: string, authPath?: string): Promise<void>;
23
+ /** Remove one provider's credential (logout) under the auth.json lock. Returns
24
+ * whether a record was removed from the exact snapshot this write replaced. */
25
+ export declare function deleteAuthCredential(providerId: string, authPath?: string): Promise<boolean>;
25
26
  /**
26
27
  * The `CredentialStore` crouter hands to `ModelRuntime`. Backed directly by auth.json rather
27
28
  * than by an in-memory cache, so `modify` is a real cross-process read-modify-write: pi's
@@ -126,12 +126,14 @@ export async function setAuthCredential(providerId, credential, authPath = defau
126
126
  return { result: undefined, next: serialize(data) };
127
127
  });
128
128
  }
129
- /** Remove one provider's credential (logout) under the auth.json lock. */
129
+ /** Remove one provider's credential (logout) under the auth.json lock. Returns
130
+ * whether a record was removed from the exact snapshot this write replaced. */
130
131
  export async function deleteAuthCredential(providerId, authPath = defaultAuthPath()) {
131
- await new LockedJsonFile(authPath).withLockAsync(async (current) => {
132
+ return new LockedJsonFile(authPath).withLockAsync(async (current) => {
132
133
  const data = parse(current);
134
+ const removed = Object.hasOwn(data, providerId);
133
135
  delete data[providerId];
134
- return { result: undefined, next: serialize(data) };
136
+ return { result: removed, next: serialize(data) };
135
137
  });
136
138
  }
137
139
  /**
@@ -25,7 +25,12 @@ export type EdgeType = 'subscribes_to' | 'spawned_by';
25
25
  export interface LaunchSpec {
26
26
  /** Model id/pattern passed to pi `--model`. */
27
27
  model?: string;
28
- /** True only for a caller's explicit `provider/tier` or `provider/id` override. */
28
+ /** Original logical family/strength preference, retained after normalization. */
29
+ modelIntent?: {
30
+ family: string;
31
+ strength: import('../../types.js').ModelStrength;
32
+ };
33
+ /** True only for a caller's explicit concrete `provider/id` override. */
29
34
  modelProviderPinned?: boolean;
30
35
  /** True for an exact-consult launch: `model` is the verbatim captured
31
36
  * `provider/id:thinking` spec, bypassing the review floor/normalize and
@@ -1,4 +1,4 @@
1
- import type { Scope, ScopeConfig, ScopeState, KindConfig, ModelLaddersConfig, ModelProvider, ModelStrength } from '../types.js';
1
+ import type { Scope, ScopeConfig, ScopeState, KindConfig, ModelLaddersConfig, ModelProvider, ModelStrength, ModelRouteConfig, ModelRoutingConfig } from '../types.js';
2
2
  export declare function configPath(scope: Scope): string | null;
3
3
  export declare function statePath(scope: Scope): string | null;
4
4
  export declare function readConfig(scope: Scope): ScopeConfig;
@@ -33,6 +33,8 @@ export declare function readRawScopeConfig(scope: Scope): Partial<ScopeConfig> |
33
33
  export interface MergedLaunchConfig {
34
34
  kinds: Record<string, KindConfig>;
35
35
  modelLadders: ModelLaddersConfig;
36
+ modelRoutes?: Record<string, ModelRouteConfig>;
37
+ modelRouting?: ModelRoutingConfig;
36
38
  /** The merged `spawnEnv.allow` set (project stack > profile > user >
37
39
  * builtin, additive) — read by `buildOperationalEnvBase`
38
40
  * (`core/runtime/spawn-env.ts`) as source C of the broker spawn-env