@north-light/crouter 0.3.280 → 0.3.281

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.
@@ -86,8 +86,8 @@ test('only concrete provider/id tokens pin; logical family/tier refs remain pref
86
86
  assert.equal(isProviderPinnedModelToken('ultra'), false, 'a bare tier has no `/` and stays unpinned');
87
87
  assert.equal(isProviderPinnedModelToken('opus'), false, 'a bare family alias has no `/` and stays unpinned');
88
88
  });
89
- test('a new --model override resolves once and freezes portable and concrete requests', () => {
90
- for (const requested of ['anthropic/opus', 'strong', 'anthropic/claude-fable-5:high']) {
89
+ test('a new --model override freezes a concrete pick', () => {
90
+ for (const requested of ['anthropic/claude-fable-5:high', 'openai-codex/gpt-5.6-sol:max']) {
91
91
  const launch = buildSpec({ model: requested, modelOverride: true });
92
92
  assert.equal(launch.model, normalizeModel(requested, ladders), `${requested} resolves through the configured ladder`);
93
93
  assert.equal(launch.modelExact, true, `${requested} is exact after resolution`);
@@ -95,6 +95,21 @@ test('a new --model override resolves once and freezes portable and concrete req
95
95
  assert.equal(launch.env.CRTR_MODEL_INTENT, undefined, `${requested} cannot re-enable fallback on revive`);
96
96
  }
97
97
  });
98
+ // A strength word is a preference, not a model identity. Freezing it made every
99
+ // `--model medium` child fail closed on a canvas whose only authenticated route
100
+ // belonged to another provider, while a sibling that passed no --model routed
101
+ // fine. Exactness now follows the raw token's shape, not the mere presence of
102
+ // an override.
103
+ test('a new --model override keeps a portable ladder token routable', () => {
104
+ for (const requested of ['medium', 'strong', 'anthropic/opus', 'opus']) {
105
+ const launch = buildSpec({ model: requested, modelOverride: true });
106
+ assert.equal(launch.model, normalizeModel(requested, ladders), `${requested} resolves through the configured ladder`);
107
+ assert.equal(launch.modelExact, undefined, `${requested} is a preference, so it must not fail closed`);
108
+ assert.notEqual(launch.modelIntent, undefined, `${requested} keeps its route intent`);
109
+ assert.equal(launch.env.CRTR_MODEL_INTENT, `${launch.modelIntent.family}/${launch.modelIntent.strength}`, `${requested} hands the broker the intent route fallback needs`);
110
+ assert.equal(launch.modelProviderPinned, false, `${requested} names no provider to pin to`);
111
+ }
112
+ });
98
113
  // Direction 1: an EXPLICIT concrete pin must SURVIVE a config/lifecycle rebuild
99
114
  // even when the carried override has portablized back to a bare tier.
100
115
  test('an explicit concrete pin survives a config/lifecycle rebuild whose portablized override reads as a bare tier', () => {
@@ -112,7 +127,7 @@ test('an explicit concrete pin survives a config/lifecycle rebuild whose portabl
112
127
  // Direction 2: a deliberately TIER-run (unpinned) node must NOT be wrongly
113
128
  // re-pinned when a subsequent rebuild/persist normalizes its override to the
114
129
  // concrete cell it happens to share bytes with.
115
- test('reshape resolves and persists a new --model override as the exact revive recipe', () => {
130
+ test('reshape resolves a new --model override and keeps a family alias routable', () => {
116
131
  createNode(node('override-node', {
117
132
  model_override: 'strong',
118
133
  launch: { extensions: [], env: { CRTR_MODEL_INTENT: 'anthropic/strong' }, model: normalizeModel('strong', ladders), modelIntent: { family: 'anthropic', strength: 'strong' } },
@@ -122,6 +137,19 @@ test('reshape resolves and persists a new --model override as the exact revive r
122
137
  const expected = normalizeModel('anthropic/opus', ladders);
123
138
  assert.equal(after?.model_override, expected, 'the raw alias never becomes the durable override');
124
139
  assert.equal(after?.launch?.model, expected, 'revive replays the resolved provider/model');
140
+ assert.equal(after?.launch?.modelExact, undefined, 'a family alias is a preference, so revive may still route');
141
+ assert.deepEqual(after?.launch?.modelIntent, { family: 'anthropic', strength: 'strong' }, 'the route intent follows the new alias');
142
+ assert.equal(after?.launch?.env.CRTR_MODEL_INTENT, 'anthropic/strong');
143
+ });
144
+ test('reshape freezes a new concrete --model override as the exact revive recipe', () => {
145
+ createNode(node('concrete-override-node', {
146
+ model_override: 'strong',
147
+ launch: { extensions: [], env: { CRTR_MODEL_INTENT: 'anthropic/strong' }, model: normalizeModel('strong', ladders), modelIntent: { family: 'anthropic', strength: 'strong' } },
148
+ }));
149
+ reshapeNode('concrete-override-node', { model: 'anthropic/claude-fable-5:high' });
150
+ const after = getNode('concrete-override-node');
151
+ assert.equal(after?.model_override, 'anthropic/claude-fable-5:high', 'the concrete pick is the durable override');
152
+ assert.equal(after?.launch?.model, 'anthropic/claude-fable-5:high', 'revive replays the exact spec');
125
153
  assert.equal(after?.launch?.modelExact, true, 'retries and revives reject substitutes');
126
154
  assert.equal(after?.launch?.modelIntent, undefined);
127
155
  assert.equal(after?.launch?.env.CRTR_MODEL_INTENT, undefined);
@@ -68,8 +68,13 @@ export interface LaunchBuildOptions {
68
68
  /** A newly supplied `--model` override. Resolve it through the target
69
69
  * ladder and any quality floor once, then freeze the resulting concrete
70
70
  * provider/id[:thinking] spec into the launch recipe so every launch,
71
- * revive, and retry consults exactly that model. Existing recipes instead
72
- * carry `modelExact`/`modelIntent` below verbatim. */
71
+ * revive, and retry consults exactly that model — but ONLY when the raw
72
+ * token names a concrete model. A PORTABLE ladder token (`medium`,
73
+ * `anthropic/opus`) is a strength preference, not a model identity, so it
74
+ * keeps its route intent and stays eligible for fallback; freezing it
75
+ * would fail a node closed on a canvas whose only authenticated route is a
76
+ * different provider. Existing recipes instead carry
77
+ * `modelExact`/`modelIntent` below verbatim. */
73
78
  modelOverride?: boolean;
74
79
  /** The TARGET node's cwd — anchors the project-scope stack for kind/ladder
75
80
  * resolution. Defaults to this process's own cwd, which is correct only
@@ -251,7 +251,8 @@ function buildLaunchSpecFromMerged(kind, mode, opts, merged) {
251
251
  const ladders = merged.modelLadders;
252
252
  const kindModel = mode === 'orchestrator' ? kindConfig?.orchestratorModel ?? kindConfig?.model : kindConfig?.model;
253
253
  const chosenModel = opts.modelExact === true ? opts.model : floorReviewModel(kind, opts.model, kindModel, ladders);
254
- const exact = opts.modelExact === true || opts.modelOverride === true;
254
+ const exact = opts.modelExact === true
255
+ || (opts.modelOverride === true && opts.model !== undefined && !isPortableModelToken(opts.model));
255
256
  const resolvedModel = opts.modelExact === true
256
257
  ? chosenModel
257
258
  : (chosenModel !== undefined ? normalizeModel(chosenModel, ladders) : undefined);
@@ -103,7 +103,12 @@ test('promote and yield reject unregistered model recipes before reshaping the n
103
103
  assert.equal(after?.mode, before?.mode, `${operation} did not reshape the node`);
104
104
  }
105
105
  });
106
- test('promote and yield reject an unregistered resolved portable override before reshaping the node', async () => {
106
+ // A portable override names a strength, not a model identity, so it is a route
107
+ // request: the registry cannot pre-judge it, and the broker resolves it against
108
+ // whichever route is authenticated at launch. Gating it on the ladder cell being
109
+ // registered is what killed every `--model medium` child on a canvas whose only
110
+ // authenticated route belonged to another provider.
111
+ test('promote and yield accept a portable override whose ladder cell is unregistered', async () => {
107
112
  const cwd = join(home, 'target-project');
108
113
  mkdirSync(join(cwd, '.crouter'), { recursive: true });
109
114
  writeFileSync(join(cwd, '.crouter', 'config.json'), JSON.stringify({
@@ -113,14 +118,15 @@ test('promote and yield reject an unregistered resolved portable override before
113
118
  for (const operation of ['promote', 'yield']) {
114
119
  const id = `${operation}-portable-model`;
115
120
  createNode(node(id, cwd));
116
- const before = getNode(id);
117
121
  const request = ctx('POST', `/v1/nodes/${id}/${operation}`, id, { model: 'openai/strong' });
118
- await assert.rejects(() => operation === 'promote'
119
- ? handlePromote(request, { modelRegistry: registry })
120
- : handleYield(request, { modelRegistry: registry }), (error) => error instanceof CrtrError && error.code === 'usage' && /target-openai\/missing-strong/.test(error.message));
122
+ if (operation === 'promote')
123
+ await handlePromote(request, { modelRegistry: registry });
124
+ else
125
+ await handleYield(request, { modelRegistry: registry });
121
126
  const after = getNode(id);
122
- assert.deepEqual(after?.launch, before?.launch, `${operation} did not rewrite the launch recipe`);
123
- assert.equal(after?.mode, before?.mode, `${operation} did not reshape the node`);
127
+ assert.equal(after?.launch?.model, 'target-openai/missing-strong', `${operation} resolves the tier against the target scope`);
128
+ assert.equal(after?.launch?.modelExact, undefined, `${operation} leaves the strength request routable`);
129
+ assert.deepEqual(after?.launch?.modelIntent, { family: 'openai', strength: 'strong' }, `${operation} keeps the route intent`);
124
130
  }
125
131
  });
126
132
  test('config normalizes portable models against the target node scope', async () => {
@@ -151,5 +157,7 @@ test('config normalizes portable models against the target node scope', async ()
151
157
  await handleConfig(ctx('PATCH', `/v1/nodes/${id}/config`, id, { model: 'openai/strong' }), { modelRegistry: registry });
152
158
  assert.equal(getNode(id)?.model_override, 'target-openai/o-strong');
153
159
  assert.equal(getNode(id)?.launch?.model, 'target-openai/o-strong');
160
+ // `config --model` records exactness in the model-swap path (updateModelRecipe),
161
+ // not in buildLaunchSpec, so a live user's pick freezes whatever its shape.
154
162
  assert.equal(getNode(id)?.launch?.modelExact, true, 'a config override cannot route away on revive');
155
163
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.280",
3
+ "version": "0.3.281",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/runtime.lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.280",
3
+ "version": "0.3.281",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@north-light/crouter",
9
- "version": "0.3.280",
9
+ "version": "0.3.281",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {