@north-light/crouter 0.3.279 → 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.
@@ -0,0 +1,25 @@
1
+ export type ModelStrengthDTO = 'ultra' | 'strong' | 'medium' | 'light';
2
+ export interface ModelRouteConfigDTO {
3
+ family: string;
4
+ credentialSource: string;
5
+ providerId: string;
6
+ models: Record<ModelStrengthDTO, string>;
7
+ }
8
+ export interface ModelRoutingDTO {
9
+ credentialSourceOrder?: string[];
10
+ familyOrder?: string[];
11
+ strengthFallback?: ModelStrengthDTO[];
12
+ }
13
+ export interface PutModelConfigRequest {
14
+ config_version: string;
15
+ modelRoutes: Record<string, ModelRouteConfigDTO>;
16
+ modelRouting: ModelRoutingDTO;
17
+ kinds: Record<string, {
18
+ model: string;
19
+ orchestratorModel?: string;
20
+ }>;
21
+ }
22
+ export interface PutModelConfigResponse {
23
+ config_version: string;
24
+ delivered: true;
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -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);
@@ -0,0 +1,20 @@
1
+ import type { ModelRoutingConfig, ModelStrength } from '../types.js';
2
+ export type CoreModelRoute = {
3
+ family: string;
4
+ credentialSource: string;
5
+ providerId: string;
6
+ models: Record<ModelStrength, string>;
7
+ };
8
+ export type CoreKindAssignment = {
9
+ model: string;
10
+ orchestratorModel?: string;
11
+ };
12
+ export type CoreModelConfig = {
13
+ modelRoutes: Record<string, CoreModelRoute>;
14
+ modelRouting: ModelRoutingConfig;
15
+ kinds: Record<string, CoreKindAssignment>;
16
+ };
17
+ /** Validate the complete Core-owned payload before its single user-scope write. */
18
+ export declare function validateCoreModelConfig(value: unknown): CoreModelConfig;
19
+ /** Write Core-owned fields atomically while retaining unrelated config and kind fields. */
20
+ export declare function replaceCoreModelConfig(config: CoreModelConfig): void;
@@ -0,0 +1,107 @@
1
+ import { updateRawConfigAtomically } from './config.js';
2
+ const strengths = ['ultra', 'strong', 'medium', 'light'];
3
+ const logicalModel = /^[^/\s]+\/(ultra|strong|medium|light)$/;
4
+ function object(value, name) {
5
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
6
+ throw new Error(`${name} must be an object`);
7
+ return value;
8
+ }
9
+ function nonempty(value, name) {
10
+ if (typeof value !== 'string' || value.trim() === '')
11
+ throw new Error(`${name} must be a non-empty string`);
12
+ return value;
13
+ }
14
+ /** Validate the complete Core-owned payload before its single user-scope write. */
15
+ export function validateCoreModelConfig(value) {
16
+ const raw = object(value, 'model config');
17
+ const routes = object(raw.modelRoutes, 'modelRoutes');
18
+ const modelRoutes = {};
19
+ for (const [id, value] of Object.entries(routes)) {
20
+ nonempty(id, 'route id');
21
+ const route = object(value, `modelRoutes.${id}`);
22
+ const providerId = nonempty(route.providerId, `modelRoutes.${id}.providerId`);
23
+ const models = object(route.models, `modelRoutes.${id}.models`);
24
+ const complete = {};
25
+ for (const strength of strengths) {
26
+ const spec = nonempty(models[strength], `modelRoutes.${id}.models.${strength}`);
27
+ const slash = spec.indexOf('/');
28
+ if (slash <= 0 || slash === spec.length - 1 || spec.slice(0, slash) !== providerId) {
29
+ throw new Error(`modelRoutes.${id}.models.${strength} must be a ${providerId}/id ref`);
30
+ }
31
+ complete[strength] = spec;
32
+ }
33
+ for (const strength of Object.keys(models)) {
34
+ if (!strengths.includes(strength))
35
+ throw new Error(`modelRoutes.${id}.models.${strength} is not a model strength`);
36
+ }
37
+ modelRoutes[id] = {
38
+ family: nonempty(route.family, `modelRoutes.${id}.family`),
39
+ credentialSource: nonempty(route.credentialSource, `modelRoutes.${id}.credentialSource`),
40
+ providerId,
41
+ models: complete,
42
+ };
43
+ }
44
+ const modelRouting = object(raw.modelRouting, 'modelRouting');
45
+ for (const field of ['credentialSourceOrder', 'familyOrder']) {
46
+ if (modelRouting[field] !== undefined && (!Array.isArray(modelRouting[field]) || !modelRouting[field].every((item) => typeof item === 'string' && item.trim() !== ''))) {
47
+ throw new Error(`modelRouting.${field} must be an array of non-empty strings`);
48
+ }
49
+ }
50
+ if (modelRouting.strengthFallback !== undefined && (!Array.isArray(modelRouting.strengthFallback) || !modelRouting.strengthFallback.every((item) => strengths.includes(item)))) {
51
+ throw new Error('modelRouting.strengthFallback must be an array of model strengths');
52
+ }
53
+ const families = new Set(Object.values(modelRoutes).map((route) => route.family));
54
+ const credentialSources = new Set(Object.values(modelRoutes).map((route) => route.credentialSource));
55
+ for (const family of modelRouting.familyOrder ?? []) {
56
+ if (!families.has(family))
57
+ throw new Error(`modelRouting.familyOrder names unknown route family ${family}`);
58
+ }
59
+ for (const source of modelRouting.credentialSourceOrder ?? []) {
60
+ if (!credentialSources.has(source))
61
+ throw new Error(`modelRouting.credentialSourceOrder names unknown credential source ${source}`);
62
+ }
63
+ const rawKinds = object(raw.kinds, 'kinds');
64
+ const kinds = {};
65
+ for (const [kind, value] of Object.entries(rawKinds)) {
66
+ nonempty(kind, 'kind');
67
+ const assignment = object(value, `kinds.${kind}`);
68
+ const model = nonempty(assignment.model, `kinds.${kind}.model`);
69
+ if (!logicalModel.test(model))
70
+ throw new Error(`kinds.${kind}.model must be family/strength`);
71
+ if (!families.has(model.slice(0, model.lastIndexOf('/'))))
72
+ throw new Error(`kinds.${kind}.model names unknown route family`);
73
+ const orchestratorModel = assignment.orchestratorModel === undefined ? undefined : nonempty(assignment.orchestratorModel, `kinds.${kind}.orchestratorModel`);
74
+ if (orchestratorModel !== undefined && !logicalModel.test(orchestratorModel))
75
+ throw new Error(`kinds.${kind}.orchestratorModel must be family/strength`);
76
+ if (orchestratorModel !== undefined && !families.has(orchestratorModel.slice(0, orchestratorModel.lastIndexOf('/'))))
77
+ throw new Error(`kinds.${kind}.orchestratorModel names unknown route family`);
78
+ kinds[kind] = { model, ...(orchestratorModel === undefined ? {} : { orchestratorModel }) };
79
+ }
80
+ return { modelRoutes, modelRouting: structuredClone(modelRouting), kinds };
81
+ }
82
+ /** Write Core-owned fields atomically while retaining unrelated config and kind fields. */
83
+ export function replaceCoreModelConfig(config) {
84
+ updateRawConfigAtomically('user', (latest) => {
85
+ const currentKinds = latest.kinds !== null && typeof latest.kinds === 'object' && !Array.isArray(latest.kinds)
86
+ ? latest.kinds
87
+ : {};
88
+ const kinds = Object.fromEntries(Object.entries(currentKinds).map(([kind, value]) => {
89
+ const previous = value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
90
+ const { model: _model, orchestratorModel: _orchestratorModel, ...unowned } = previous;
91
+ return [kind, unowned];
92
+ }));
93
+ for (const [kind, assignment] of Object.entries(config.kinds)) {
94
+ const previous = kinds[kind] !== null && typeof kinds[kind] === 'object' && !Array.isArray(kinds[kind])
95
+ ? kinds[kind]
96
+ : {};
97
+ // Materialize Core's inheritance here: omission cannot clear a lower scope field.
98
+ kinds[kind] = { ...previous, model: assignment.model, orchestratorModel: assignment.orchestratorModel ?? assignment.model };
99
+ }
100
+ return {
101
+ ...latest,
102
+ modelRoutes: structuredClone(config.modelRoutes),
103
+ modelRouting: structuredClone(config.modelRouting),
104
+ kinds,
105
+ };
106
+ });
107
+ }
@@ -59,6 +59,11 @@ export interface UnregisteredConcreteModel {
59
59
  candidate: RoutedCandidate;
60
60
  reason: 'unregistered';
61
61
  }
62
+ export declare function parseModelSpec(spec: string): {
63
+ providerId: string;
64
+ modelId: string;
65
+ thinkingLevel?: string;
66
+ } | undefined;
62
67
  /** Expand config-derived route order only. Availability never affects rank. */
63
68
  export declare function expandModelCandidates(request: ModelRequest, cwd?: string, profileId?: string | null): RoutedCandidate[];
64
69
  /** Identify the one permanently invalid recipe shape: a concrete/exact request
@@ -2,7 +2,7 @@ import { readMergedLaunchConfig } from './config.js';
2
2
  import { isManagedProvider, readRotationConfig, readSubscriptionPool, resolveProviderCandidates, } from './subscription-state.js';
3
3
  const STRENGTHS = ['ultra', 'strong', 'medium', 'light'];
4
4
  const THINKING_SUFFIXES = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
5
- function parseSpec(spec) {
5
+ export function parseModelSpec(spec) {
6
6
  const slash = spec.indexOf('/');
7
7
  if (slash <= 0 || slash === spec.length - 1)
8
8
  return undefined;
@@ -43,13 +43,13 @@ export function expandModelCandidates(request, cwd, profileId) {
43
43
  const declarationFamilies = routeEntries.map(([, route]) => route.family).filter((value, index, all) => all.indexOf(value) === index);
44
44
  const declarationSources = routeEntries.map(([, route]) => route.credentialSource).filter((value, index, all) => all.indexOf(value) === index);
45
45
  if (request.kind === 'exact') {
46
- const parsed = parseSpec(request.spec);
46
+ const parsed = parseModelSpec(request.spec);
47
47
  return parsed ? [{ routeId: 'exact', credentialSource: 'exact', family: parsed.providerId, strength: 'strong', ...parsed, rank: 0 }] : [];
48
48
  }
49
- const concrete = request.kind === 'concrete' ? parseSpec(request.spec) : undefined;
49
+ const concrete = request.kind === 'concrete' ? parseModelSpec(request.spec) : undefined;
50
50
  const logical = request.kind === 'logical' ? request : undefined;
51
51
  const matchesConcrete = (spec, exactThinking) => {
52
- const parsed = parseSpec(spec);
52
+ const parsed = parseModelSpec(spec);
53
53
  return parsed !== undefined
54
54
  && concrete !== undefined
55
55
  && parsed.providerId === concrete.providerId
@@ -79,7 +79,7 @@ export function expandModelCandidates(request, cwd, profileId) {
79
79
  const emitted = [];
80
80
  const seen = new Set();
81
81
  const append = (routeId, route, strength, spec) => {
82
- const parsed = parseSpec(spec);
82
+ const parsed = parseModelSpec(spec);
83
83
  if (!parsed)
84
84
  return;
85
85
  if (concrete && parsed.modelId !== concrete.modelId)
@@ -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);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,73 @@
1
+ import { after, afterEach, beforeEach, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { ApiError } from '../../../api/errors.js';
7
+ import { resetScopeCache } from '../../../core/scope.js';
8
+ import { modelConfigRoutes } from '../handlers/model-config.js';
9
+ const previousHome = process.env.HOME;
10
+ const previousAgentDir = process.env['PI_CODING_AGENT_DIR'];
11
+ let home = '';
12
+ const payload = {
13
+ config_version: 'core-doc-digest',
14
+ modelRoutes: {
15
+ northlight: {
16
+ family: 'cloud', credentialSource: 'northlight', providerId: 'northlight',
17
+ models: { ultra: 'northlight/u', strong: 'northlight/s:high', medium: 'northlight/m', light: 'northlight/l' },
18
+ },
19
+ },
20
+ modelRouting: { familyOrder: ['cloud'], strengthFallback: ['strong', 'medium'] },
21
+ kinds: { developer: { model: 'cloud/medium' } },
22
+ };
23
+ beforeEach(() => {
24
+ home = mkdtempSync(join(tmpdir(), 'crtr-model-config-'));
25
+ process.env.HOME = home;
26
+ const agentDir = join(home, 'agent');
27
+ mkdirSync(agentDir);
28
+ process.env['PI_CODING_AGENT_DIR'] = agentDir;
29
+ writeFileSync(join(agentDir, 'models.json'), JSON.stringify({
30
+ providers: { northlight: { baseUrl: 'http://127.0.0.1:1', api: 'openai-completions', apiKey: 'test', models: ['u', 's', 'm', 'l'].map((id) => ({ id, name: id })) } },
31
+ }));
32
+ resetScopeCache();
33
+ });
34
+ afterEach(() => {
35
+ rmSync(home, { recursive: true, force: true });
36
+ resetScopeCache();
37
+ });
38
+ after(() => {
39
+ if (previousHome === undefined)
40
+ delete process.env.HOME;
41
+ else
42
+ process.env.HOME = previousHome;
43
+ if (previousAgentDir === undefined)
44
+ delete process.env['PI_CODING_AGENT_DIR'];
45
+ else
46
+ process.env['PI_CODING_AGENT_DIR'] = previousAgentDir;
47
+ resetScopeCache();
48
+ });
49
+ function putRoute() {
50
+ const route = modelConfigRoutes.find((entry) => entry.method === 'PUT' && entry.pattern === '/v1/model-config');
51
+ assert.ok(route, 'PUT /v1/model-config is registered');
52
+ return route;
53
+ }
54
+ function context(body) {
55
+ return { method: 'PUT', path: '/v1/model-config', params: {}, query: new URLSearchParams(), body };
56
+ }
57
+ test('PUT atomically replaces Core-owned blocks without touching unrelated config', async () => {
58
+ const configPath = join(home, '.crouter', 'config.json');
59
+ mkdirSync(join(home, '.crouter'));
60
+ writeFileSync(configPath, JSON.stringify({ completion_bell: false, kinds: { developer: { tools: ['bash'], model: 'old/strong', orchestratorModel: 'old/ultra' } } }));
61
+ const result = await putRoute().handler(context(payload));
62
+ assert.deepEqual(result, { status: 200, body: { config_version: 'core-doc-digest', delivered: true } });
63
+ const written = JSON.parse(readFileSync(configPath, 'utf8'));
64
+ assert.equal(written.completion_bell, false);
65
+ assert.deepEqual(written.kinds.developer, { tools: ['bash'], model: 'cloud/medium', orchestratorModel: 'cloud/medium' });
66
+ assert.deepEqual(written.modelRoutes, payload.modelRoutes);
67
+ });
68
+ test('PUT rejects invalid payloads and absent baked refs before writing', async () => {
69
+ const configPath = join(home, '.crouter', 'config.json');
70
+ await assert.rejects(async () => putRoute().handler(context({ ...payload, modelRoutes: { ...payload.modelRoutes, northlight: { ...payload.modelRoutes.northlight, models: { strong: 'northlight/s' } } } })), (error) => error instanceof ApiError && error.status === 400 && error.code === 'invalid_request');
71
+ await assert.rejects(async () => putRoute().handler(context({ ...payload, modelRoutes: { ...payload.modelRoutes, northlight: { ...payload.modelRoutes.northlight, models: { ...payload.modelRoutes.northlight.models, ultra: 'northlight/missing' } } } })), (error) => error instanceof ApiError && error.status === 422 && error.code === 'unprocessable');
72
+ assert.throws(() => readFileSync(configPath, 'utf8'), { code: 'ENOENT' });
73
+ });
@@ -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
  });
@@ -0,0 +1,2 @@
1
+ import type { RouteTable } from '../router.js';
2
+ export declare const modelConfigRoutes: RouteTable;
@@ -0,0 +1,44 @@
1
+ import { ApiError } from '../../../api/errors.js';
2
+ import { validateCoreModelConfig, replaceCoreModelConfig } from '../../../core/model-config.js';
3
+ import { parseModelSpec } from '../../../core/model-routes.js';
4
+ import { openModelRegistry } from '../../../core/runtime/model-registry.js';
5
+ function parseRequest(body) {
6
+ if (body === null || typeof body !== 'object' || Array.isArray(body))
7
+ throw new ApiError(400, 'invalid_request', 'model config must be an object');
8
+ const request = body;
9
+ if (typeof request.config_version !== 'string' || request.config_version.trim() === '') {
10
+ throw new ApiError(400, 'invalid_request', 'config_version must be a non-empty string');
11
+ }
12
+ try {
13
+ return { configVersion: request.config_version, config: validateCoreModelConfig(request) };
14
+ }
15
+ catch (error) {
16
+ throw new ApiError(400, 'invalid_request', error instanceof Error ? error.message : String(error));
17
+ }
18
+ }
19
+ /** Validate route refs against this image's baked descriptor catalog before disk changes. */
20
+ async function validateCatalog(config) {
21
+ const registry = await openModelRegistry();
22
+ for (const route of Object.values(config.modelRoutes)) {
23
+ for (const spec of Object.values(route.models)) {
24
+ const model = parseModelSpec(spec);
25
+ if (model === undefined || registry.find(model.providerId, model.modelId) === undefined) {
26
+ throw new ApiError(422, 'unprocessable', `route ref ${spec} is absent from the baked model registry`);
27
+ }
28
+ }
29
+ }
30
+ }
31
+ async function handlePut(ctx) {
32
+ const { configVersion, config } = parseRequest(ctx.body);
33
+ await validateCatalog(config);
34
+ try {
35
+ replaceCoreModelConfig(config);
36
+ }
37
+ catch (error) {
38
+ throw new ApiError(500, 'model_config_write_failed', error instanceof Error ? error.message : String(error));
39
+ }
40
+ return { status: 200, body: { config_version: configVersion, delivered: true } };
41
+ }
42
+ export const modelConfigRoutes = [
43
+ { method: 'PUT', pattern: '/v1/model-config', handler: handlePut },
44
+ ];
@@ -36,6 +36,7 @@ import { feedbackCommentRoutes } from './handlers/feedback-comments.js';
36
36
  import { memoryRoutes } from './handlers/memory.js';
37
37
  import { messageRoutes } from './handlers/messages.js';
38
38
  import { modelAuthRoutes } from './handlers/modelauth.js';
39
+ import { modelConfigRoutes } from './handlers/model-config.js';
39
40
  import { nodeRoutes } from './handlers/nodes.js';
40
41
  import { profileRoutes } from './handlers/profiles.js';
41
42
  import { prospectiveChatInventoryRoutes } from './handlers/prospective-chat-inventory.js';
@@ -67,6 +68,7 @@ function buildRouter() {
67
68
  .registerAll(cronRoutes)
68
69
  .registerAll(profileRoutes)
69
70
  .registerAll(modelAuthRoutes)
71
+ .registerAll(modelConfigRoutes)
70
72
  .registerAll(humanRoutes)
71
73
  .registerAll(reviewRoutes)
72
74
  .registerAll(reviewCommentRoutes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@north-light/crouter",
3
- "version": "0.3.279",
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.279",
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.279",
9
+ "version": "0.3.281",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {