@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
@@ -2,6 +2,7 @@ import { mkdirSync, rmSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
3
  import { isDeepStrictEqual } from 'node:util';
4
4
  import { CONFIG_FILE, STATE_FILE, CONDENSED_HISTORY_MODES, defaultScopeConfig, defaultScopeState, defaultModelLaddersConfig, defaultKindsConfig, defaultRemoteCanvasConfig } from '../types.js';
5
+ import { emitEvent } from './events/emit.js';
5
6
  import { atomicWriteJson, readJsonIfExists, writeJson, ensureDir } from './fs-utils.js';
6
7
  import { scopeRoot, requireScopeRoot, findProjectScopeRoots } from './scope.js';
7
8
  import { profileRoot } from './profiles/manifest.js';
@@ -172,6 +173,83 @@ function mergeModelLadders(raw, base = defaultModelLaddersConfig()) {
172
173
  }
173
174
  return out;
174
175
  }
176
+ function validStrength(value) {
177
+ return value === 'ultra' || value === 'strong' || value === 'medium' || value === 'light';
178
+ }
179
+ /** Validate and overlay route entries. Invalid declarations are loud but local:
180
+ * a typo must not prevent unrelated launch configuration from resolving. */
181
+ function mergeModelRoutes(raw, base = {}) {
182
+ const out = { ...base };
183
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
184
+ return out;
185
+ for (const [routeId, value] of Object.entries(raw)) {
186
+ const patch = value;
187
+ const invalid = (rule) => { emitEvent({ level: 'warn', event: 'config.model_routes.invalid', fields: { route_id: routeId, rule } }); };
188
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
189
+ invalid('route must be an object');
190
+ continue;
191
+ }
192
+ const route = { ...out[routeId], ...patch, models: { ...out[routeId]?.models, ...patch.models } };
193
+ if (typeof route.family !== 'string' || route.family.trim() === '') {
194
+ invalid('family must be a non-empty string');
195
+ continue;
196
+ }
197
+ if (typeof route.credentialSource !== 'string' || route.credentialSource.trim() === '') {
198
+ invalid('credentialSource must be a non-empty string');
199
+ continue;
200
+ }
201
+ if (typeof route.providerId !== 'string' || route.providerId.trim() === '') {
202
+ invalid('providerId must be a non-empty string');
203
+ continue;
204
+ }
205
+ if (!route.models || typeof route.models !== 'object' || Array.isArray(route.models)) {
206
+ invalid('models must be an object');
207
+ continue;
208
+ }
209
+ const models = {};
210
+ let rule;
211
+ for (const [strength, spec] of Object.entries(route.models)) {
212
+ if (!validStrength(strength)) {
213
+ rule = `models.${strength} is not a model strength`;
214
+ break;
215
+ }
216
+ if (typeof spec !== 'string' || spec.trim() === '') {
217
+ rule = `models.${strength} must be a non-empty provider/id ref`;
218
+ break;
219
+ }
220
+ const slash = spec.indexOf('/');
221
+ if (slash <= 0 || slash === spec.length - 1) {
222
+ rule = `models.${strength} must be provider/id`;
223
+ break;
224
+ }
225
+ if (spec.slice(0, slash) !== route.providerId) {
226
+ rule = `models.${strength} provider prefix must equal providerId`;
227
+ break;
228
+ }
229
+ models[strength] = spec;
230
+ }
231
+ if (rule !== undefined) {
232
+ invalid(rule);
233
+ continue;
234
+ }
235
+ out[routeId] = { family: route.family, credentialSource: route.credentialSource, providerId: route.providerId, models };
236
+ }
237
+ return out;
238
+ }
239
+ function mergeModelRouting(raw, base) {
240
+ if (raw === undefined)
241
+ return base;
242
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
243
+ return base;
244
+ const value = raw;
245
+ const strings = (items) => Array.isArray(items) && items.every((item) => typeof item === 'string' && item.trim() !== '') ? [...items] : undefined;
246
+ const strengths = Array.isArray(value.strengthFallback) && value.strengthFallback.every((item) => validStrength(item)) ? [...value.strengthFallback] : undefined;
247
+ return {
248
+ ...(strings(value.credentialSourceOrder) !== undefined ? { credentialSourceOrder: strings(value.credentialSourceOrder) } : {}),
249
+ ...(strings(value.familyOrder) !== undefined ? { familyOrder: strings(value.familyOrder) } : {}),
250
+ ...(strengths !== undefined ? { strengthFallback: strengths } : {}),
251
+ };
252
+ }
175
253
  /** True when `pattern` (a `spawnEnv.allow` entry) matches EVERY possible env
176
254
  * var name — a bare `*`, or any run of nothing but `*` characters (`**`,
177
255
  * `***`, …). This is the one glob shape that reopens the inherit-all hole
@@ -362,10 +440,12 @@ function mergeConfig(partial) {
362
440
  const fold_finished_tools = typeof partial.fold_finished_tools === 'boolean' ? partial.fold_finished_tools : defaults.fold_finished_tools;
363
441
  const keybindings = normalizeKeybindings(partial.keybindings);
364
442
  const modelLadders = mergeModelLadders(partial.modelLadders);
443
+ const modelRoutes = mergeModelRoutes(partial.modelRoutes);
444
+ const modelRouting = mergeModelRouting(partial.modelRouting, undefined);
365
445
  const kinds = mergeKinds(partial.kinds);
366
446
  const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
367
447
  const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
368
- return { schema_version, marketplaces, plugins, configuredClis, auto_update, max_panes_per_window, brokerThresholds, completion_bell, live_cycles, condensed_history, fold_finished_tools, keybindings, modelLadders, kinds, remoteCanvas, spawnEnv };
448
+ return { schema_version, marketplaces, plugins, configuredClis, auto_update, max_panes_per_window, brokerThresholds, completion_bell, live_cycles, condensed_history, fold_finished_tools, keybindings, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), kinds, remoteCanvas, spawnEnv };
369
449
  }
370
450
  /** Raw (un-defaulted) partial config for one scope, or null if the scope has
371
451
  * no root or no config.json. Used by `readMergedLaunchConfig` to layer
@@ -450,18 +530,24 @@ export function readMergedLaunchConfig(targetCwd = process.cwd(), targetProfileI
450
530
  const projectRawsFarthestFirst = readRawProjectScopeConfigs(targetCwd, targetProfileId);
451
531
  let kinds = mergeKinds(userRaw?.kinds, defaults.kinds);
452
532
  let modelLadders = mergeModelLadders(userRaw?.modelLadders, defaults.modelLadders);
533
+ let modelRoutes = mergeModelRoutes(userRaw?.modelRoutes);
534
+ let modelRouting = mergeModelRouting(userRaw?.modelRouting, undefined);
453
535
  let spawnEnv = mergeSpawnEnv(userRaw?.spawnEnv, defaults.spawnEnv?.allow);
454
536
  if (profileRaw !== null) {
455
537
  kinds = mergeKinds(profileRaw.kinds, kinds);
456
538
  modelLadders = mergeModelLadders(profileRaw.modelLadders, modelLadders);
539
+ modelRoutes = mergeModelRoutes(profileRaw.modelRoutes, modelRoutes);
540
+ modelRouting = mergeModelRouting(profileRaw.modelRouting, modelRouting);
457
541
  spawnEnv = mergeSpawnEnv(profileRaw.spawnEnv, spawnEnv.allow);
458
542
  }
459
543
  for (const raw of projectRawsFarthestFirst) {
460
544
  kinds = mergeKinds(raw.kinds, kinds);
461
545
  modelLadders = mergeModelLadders(raw.modelLadders, modelLadders);
546
+ modelRoutes = mergeModelRoutes(raw.modelRoutes, modelRoutes);
547
+ modelRouting = mergeModelRouting(raw.modelRouting, modelRouting);
462
548
  spawnEnv = mergeSpawnEnv(raw.spawnEnv, spawnEnv.allow);
463
549
  }
464
- return { kinds, modelLadders, spawnEnv };
550
+ return { kinds, modelLadders, ...(Object.keys(modelRoutes).length > 0 ? { modelRoutes } : {}), ...(modelRouting !== undefined ? { modelRouting } : {}), spawnEnv };
465
551
  }
466
552
  /** The effective `KindConfig` for one full kind string (top-level, e.g.
467
553
  * `developer`, or sub-kind, e.g. `plan/reviewers/security`), across
@@ -0,0 +1,37 @@
1
+ import type { ModelStrength } from '../types.js';
2
+ export type ModelRequest = {
3
+ kind: 'logical';
4
+ family: string;
5
+ strength: ModelStrength;
6
+ spec: string;
7
+ } | {
8
+ kind: 'concrete';
9
+ spec: string;
10
+ } | {
11
+ kind: 'exact';
12
+ spec: string;
13
+ };
14
+ export type RoutedCandidate = {
15
+ routeId: string;
16
+ credentialSource: string;
17
+ family: string;
18
+ strength: ModelStrength;
19
+ providerId: string;
20
+ modelId: string;
21
+ thinkingLevel?: string;
22
+ rank: number;
23
+ };
24
+ export type RouteAvailability = {
25
+ authenticated: boolean;
26
+ coolingUntil?: number;
27
+ registered: boolean;
28
+ };
29
+ export interface ModelRegistryLike {
30
+ find(providerId: string, modelId: string): unknown;
31
+ hasConfiguredAuth(model: unknown): boolean;
32
+ }
33
+ /** Expand config-derived route order only. Availability never affects rank. */
34
+ export declare function expandModelCandidates(request: ModelRequest, cwd?: string, profileId?: string | null): RoutedCandidate[];
35
+ /** Probe the common availability facts consumed unchanged by resolveProviderCandidates. */
36
+ export declare function probeRouteAvailability(candidate: RoutedCandidate, registry: ModelRegistryLike, now?: number): RouteAvailability;
37
+ export declare function modelRequestFromConfig(model: string | undefined, modelIntent: string | undefined, exact: boolean): ModelRequest | undefined;
@@ -0,0 +1,140 @@
1
+ import { readMergedLaunchConfig } from './config.js';
2
+ import { isManagedProvider, readRotationConfig, readSubscriptionPool } from './subscription-state.js';
3
+ const STRENGTHS = ['ultra', 'strong', 'medium', 'light'];
4
+ const THINKING_SUFFIXES = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
5
+ function parseSpec(spec) {
6
+ const slash = spec.indexOf('/');
7
+ if (slash <= 0 || slash === spec.length - 1)
8
+ return undefined;
9
+ const providerId = spec.slice(0, slash);
10
+ const rawId = spec.slice(slash + 1);
11
+ const colon = rawId.lastIndexOf(':');
12
+ const suffix = colon > 0 ? rawId.slice(colon + 1).toLowerCase() : undefined;
13
+ return {
14
+ providerId,
15
+ modelId: suffix !== undefined && THINKING_SUFFIXES.has(suffix) ? rawId.slice(0, colon) : rawId,
16
+ ...(suffix !== undefined && THINKING_SUFFIXES.has(suffix) && suffix !== 'off' ? { thinkingLevel: suffix } : {}),
17
+ };
18
+ }
19
+ function synthesizeRoutes(ladders) {
20
+ const route = (family) => {
21
+ const providerId = ladders[family].strong.slice(0, ladders[family].strong.indexOf('/'));
22
+ return { family, credentialSource: 'default', providerId, models: ladders[family] };
23
+ };
24
+ return { anthropic: route('anthropic'), openai: route('openai') };
25
+ }
26
+ function ordered(first, rest) {
27
+ return [first, ...rest.filter((item) => item !== first)];
28
+ }
29
+ function strengths(requested, configured) {
30
+ return ordered(requested, configured).filter((value, index, all) => all.indexOf(value) === index);
31
+ }
32
+ /** Expand config-derived route order only. Availability never affects rank. */
33
+ export function expandModelCandidates(request, cwd, profileId) {
34
+ const cfg = readMergedLaunchConfig(cwd, profileId ?? null);
35
+ const routes = cfg.modelRoutes && Object.keys(cfg.modelRoutes).length > 0 ? cfg.modelRoutes : synthesizeRoutes(cfg.modelLadders);
36
+ const routeEntries = Object.entries(routes);
37
+ const fallback = cfg.modelRouting?.strengthFallback ?? [readRotationConfig().defaultFallbackStrength ?? 'strong'];
38
+ const declarationFamilies = routeEntries.map(([, route]) => route.family).filter((value, index, all) => all.indexOf(value) === index);
39
+ const declarationSources = routeEntries.map(([, route]) => route.credentialSource).filter((value, index, all) => all.indexOf(value) === index);
40
+ if (request.kind === 'exact') {
41
+ const parsed = parseSpec(request.spec);
42
+ return parsed ? [{ routeId: 'exact', credentialSource: 'exact', family: parsed.providerId, strength: 'strong', ...parsed, rank: 0 }] : [];
43
+ }
44
+ const concrete = request.kind === 'concrete' ? parseSpec(request.spec) : undefined;
45
+ const logical = request.kind === 'logical' ? request : undefined;
46
+ const matchesConcrete = (spec, exactThinking) => {
47
+ const parsed = parseSpec(spec);
48
+ return parsed !== undefined
49
+ && concrete !== undefined
50
+ && parsed.providerId === concrete.providerId
51
+ && parsed.modelId === concrete.modelId
52
+ && (!exactThinking || parsed.thinkingLevel === concrete.thinkingLevel);
53
+ };
54
+ const concreteRoute = concrete === undefined ? undefined : routeEntries.find(([, route]) => Object.values(route.models).some((spec) => matchesConcrete(spec, false)));
55
+ const requestedStrength = logical?.strength ?? (() => {
56
+ if (!concrete)
57
+ return 'strong';
58
+ // Mirrors the former ladder back-map: preserve a precise thinking-level
59
+ // match, then map a drifting/omitted suffix to the weakest same-model cell.
60
+ for (const exactThinking of [true, false]) {
61
+ for (const strength of exactThinking ? STRENGTHS : [...STRENGTHS].reverse()) {
62
+ if (routeEntries.some(([, route]) => {
63
+ const spec = route.models[strength];
64
+ return spec !== undefined && matchesConcrete(spec, exactThinking);
65
+ }))
66
+ return strength;
67
+ }
68
+ }
69
+ return 'strong';
70
+ })();
71
+ const requestedFamily = logical?.family ?? concreteRoute?.[1].family ?? concrete?.providerId ?? 'anthropic';
72
+ const sourceOrder = concrete ? declarationSources : (cfg.modelRouting?.credentialSourceOrder ?? declarationSources);
73
+ const familyOrder = concrete ? [requestedFamily] : (cfg.modelRouting?.familyOrder ?? declarationFamilies);
74
+ const emitted = [];
75
+ const seen = new Set();
76
+ const append = (routeId, route, strength, spec) => {
77
+ const parsed = parseSpec(spec);
78
+ if (!parsed)
79
+ return;
80
+ if (concrete && parsed.modelId !== concrete.modelId)
81
+ return;
82
+ const key = `${parsed.providerId}/${parsed.modelId}:${parsed.thinkingLevel ?? ''}`;
83
+ if (seen.has(key))
84
+ return;
85
+ seen.add(key);
86
+ emitted.push({ routeId, credentialSource: route.credentialSource, family: route.family, strength, ...parsed, rank: emitted.length });
87
+ };
88
+ // Concrete requests retain their exact model/provider first even if a route has
89
+ // not been authored for it; declared routes then offer that identical model in
90
+ // other credential sources only.
91
+ if (concrete) {
92
+ const [routeId, route] = concreteRoute ?? [
93
+ 'concrete',
94
+ { family: requestedFamily, credentialSource: 'default', providerId: concrete.providerId, models: { [requestedStrength]: request.spec } },
95
+ ];
96
+ append(routeId, route, requestedStrength, request.spec);
97
+ }
98
+ for (const credentialSource of sourceOrder) {
99
+ for (const strength of concrete ? [requestedStrength] : strengths(requestedStrength, fallback)) {
100
+ for (const family of ordered(requestedFamily, familyOrder)) {
101
+ for (const [routeId, route] of routeEntries) {
102
+ if (route.credentialSource !== credentialSource || route.family !== family)
103
+ continue;
104
+ const spec = route.models[strength];
105
+ if (spec)
106
+ append(routeId, route, strength, spec);
107
+ }
108
+ }
109
+ }
110
+ }
111
+ return emitted.map((candidate, rank) => ({ ...candidate, rank }));
112
+ }
113
+ /** Probe the common availability facts consumed unchanged by resolveProviderCandidates. */
114
+ export function probeRouteAvailability(candidate, registry, now = Date.now()) {
115
+ const model = registry.find(candidate.providerId, candidate.modelId);
116
+ if (model === undefined)
117
+ return { registered: false, authenticated: false };
118
+ if (!isManagedProvider(candidate.providerId))
119
+ return { registered: true, authenticated: registry.hasConfiguredAuth(model) };
120
+ const pool = readSubscriptionPool(candidate.providerId).filter((entry) => entry.authFailure === undefined);
121
+ const cooling = pool.length > 0 && pool.every((entry) => (entry.rateLimitedUntil || 0) > now);
122
+ return {
123
+ registered: true,
124
+ authenticated: pool.length > 0,
125
+ ...(cooling ? { coolingUntil: Math.min(...pool.map((entry) => entry.rateLimitedUntil || 0)) } : {}),
126
+ };
127
+ }
128
+ export function modelRequestFromConfig(model, modelIntent, exact) {
129
+ if (!model)
130
+ return undefined;
131
+ if (exact)
132
+ return { kind: 'exact', spec: model };
133
+ if (modelIntent) {
134
+ const slash = modelIntent.indexOf('/');
135
+ const strength = slash > 0 ? modelIntent.slice(slash + 1) : '';
136
+ if (STRENGTHS.includes(strength))
137
+ return { kind: 'logical', family: modelIntent.slice(0, slash), strength: strength, spec: model };
138
+ }
139
+ return { kind: 'concrete', spec: model };
140
+ }
@@ -3,7 +3,6 @@ import { type AgentSessionRuntime, type AgentSessionServices, type CreateAgentSe
3
3
  import { type ImageContent } from '@earendil-works/pi-ai';
4
4
  import { type BrokerSdkConfig } from './launch.js';
5
5
  import { type BrokerEngine } from './broker-sdk.js';
6
- import { type ManagedProviderRotationPolicyControl } from './managed-provider-cooling.js';
7
6
  import { type KnownStreamWaitControl } from './stream-watchdog.js';
8
7
  import { FrameDecoder, type BrokerSnapshot, type BrokerToClient, type ClientRole, type RpcExtensionUIRequest, type RpcExtensionUIResponse } from './broker-protocol.js';
9
8
  /**
@@ -231,9 +230,6 @@ export interface BrokerDialogDeps {
231
230
  /** Broadcast a non-blocking display frame (setStatus/setWidget/setTitle) to ALL
232
231
  * viewers — the relay path for pi's fire-and-forget extension-UI surface. */
233
232
  broadcast: (frame: BrokerToClient) => void;
234
- /** Live provider-fallback policy. This is a callback because an explicit
235
- * set_model updates the running broker's pin state without a revive. */
236
- providerRotationPolicy?: ManagedProviderRotationPolicyControl;
237
233
  /** Optional broker-owned control for a deliberate, deadline-bounded provider
238
234
  * wait: pause the dead-stream watchdog while the provider extension sleeps to
239
235
  * a known finite future deadline, returning a release fn that resumes it. Put