@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.
- package/dist/api/dto/model-config.d.ts +25 -0
- package/dist/api/dto/model-config.js +1 -0
- package/dist/core/__tests__/model-pin-durability.test.js +31 -3
- package/dist/core/model-config.d.ts +20 -0
- package/dist/core/model-config.js +107 -0
- package/dist/core/model-routes.d.ts +5 -0
- package/dist/core/model-routes.js +5 -5
- package/dist/core/runtime/launch.d.ts +7 -2
- package/dist/core/runtime/launch.js +2 -1
- package/dist/daemon/api/__tests__/model-config.test.d.ts +1 -0
- package/dist/daemon/api/__tests__/model-config.test.js +73 -0
- package/dist/daemon/api/__tests__/node-model-validation.test.js +15 -7
- package/dist/daemon/api/handlers/model-config.d.ts +2 -0
- package/dist/daemon/api/handlers/model-config.js +44 -0
- package/dist/daemon/api/server.js +2 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -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
|
|
90
|
-
for (const requested of ['anthropic/
|
|
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
|
|
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
|
|
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 =
|
|
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' ?
|
|
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 =
|
|
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 =
|
|
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
|
|
72
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
123
|
-
assert.equal(after?.
|
|
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,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
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
9
|
+
"version": "0.3.281",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|