@mindot/will 0.7.0 → 0.8.0

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 (35) hide show
  1. package/README.md +87 -22
  2. package/dist/channels/discord.d.ts +1 -1
  3. package/dist/channels/whatsapp.d.ts +1 -1
  4. package/dist/cli.js +10823 -10454
  5. package/dist/cli.js.map +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +562 -226
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp/effectors.d.ts +1 -1
  10. package/dist/{will-DAW0l-lY.d.ts → will-cS6k4uiJ.d.ts} +470 -84
  11. package/package.json +1 -1
  12. package/src/cognition/agency/engines/action.selector.ts +2 -1
  13. package/src/cognition/agency/engines/reafference.engine.ts +12 -2
  14. package/src/cognition/agency/reconcile.learning.ts +16 -2
  15. package/src/cognition/agency/schemas/repertoire.ts +12 -5
  16. package/src/cognition/config.mirror.entities.ts +1 -1
  17. package/src/cognition/faculties/executive.engine/engine.ts +136 -58
  18. package/src/cognition/faculties/executive.engine/facet.ts +10 -2
  19. package/src/cognition/faculties/executive.engine/prompt.factory.ts +2 -1
  20. package/src/cognition/index.ts +4 -0
  21. package/src/cognition/memory/vector.embedder.ts +9 -5
  22. package/src/cognition/utilities/token.tracker.ts +191 -96
  23. package/src/host/boot.ts +78 -22
  24. package/src/index.ts +35 -0
  25. package/src/llm/index.ts +397 -96
  26. package/src/llm/routing.ts +198 -0
  27. package/src/llm/summarizer.ts +5 -1
  28. package/src/runners/thin-shim.runner.ts +18 -6
  29. package/src/sdk/will.ts +82 -16
  30. package/src/stem/guards/identity.coherence.ts +17 -6
  31. package/src/stem/index.ts +3 -3
  32. package/src/stem/mind.ts +155 -24
  33. package/src/stem/policy/arbiter.ts +49 -14
  34. package/src/stem/policy/rule.table.ts +2 -2
  35. package/src/stem/tracts/effector.controller.ts +56 -9
package/src/llm/index.ts CHANGED
@@ -6,72 +6,175 @@ import { logger } from '#core/logger'
6
6
  import type { Tick } from '#core/types'
7
7
  import type { SessionLogger } from '#stem/tracts/session.logger'
8
8
  import { writeFileSync, mkdirSync } from 'node:fs'
9
- import type { TokenTracker } from '#cognition/utilities/token.tracker'
9
+ import type {
10
+ TokenTracker, LLMCallCategory, LLMCallAttribute, LLMCallFunction,
11
+ } from '#cognition/utilities/token.tracker'
12
+ import { type ModelRouter, isNullRouter } from '#llm/routing'
10
13
  import { getCompletionRecorder, getCompletionSource } from '#core/completion.recorder'
11
14
  import type { LLMCompletionRecord } from '#core/completion.recorder'
12
15
  import { withGate } from '#llm/gate'
13
16
  import { matchConversationFocus, wrapReplyText } from '#llm/wire.contracts'
14
17
 
15
- export type LLMProvider = 'anthropic' | 'glm' | 'deepseek' | 'openai' | 'google'
18
+ /**
19
+ * The request/response dialect an endpoint speaks. This — not the provider's
20
+ * name — is what the transport actually branches on.
21
+ */
22
+ export type LLMWire = 'anthropic' | 'openai' | 'google'
23
+
24
+ /**
25
+ * The provider and model a mock (test-mode) Will reports.
26
+ *
27
+ * A mock Will never reaches a network, so it needs no credentials — but it
28
+ * still records completions, and the tape should say plainly that nothing real
29
+ * served them rather than borrow some vendor's name.
30
+ */
31
+ export const MOCK_PROVIDER = 'mock'
32
+ export const MOCK_MODEL = 'mock'
33
+
34
+ /** Providers with built-in defaults. Any other string is equally valid. */
35
+ export type KnownProvider =
36
+ | 'anthropic' // Claude
37
+ | 'glm' // Z.ai
38
+ | 'openai' // GPT + the embedding models
39
+ | 'google' // Gemini
40
+ | 'deepseek'
41
+ | 'moonshot' // Kimi
42
+ | 'qwen' // Alibaba Model Studio / DashScope
43
+ | 'xai' // Grok
44
+ | 'minimax'
45
+ | 'mistral'
46
+ | 'ollama' // local
47
+ | 'vllm' // local / self-hosted
16
48
 
17
49
  /**
18
- * Providers that speak the Anthropic Messages wire.
50
+ * A provider name. Deliberately open: the field of providers changes monthly,
51
+ * and a closed union meant a host reaching Kimi or Qwen had to masquerade as
52
+ * `openai`, which then lied on the completion tape and in cost attribution.
19
53
  *
20
- * Z.ai ships a real Anthropic-compatible endpoint for GLM it is what Claude
21
- * Code itself targets so GLM rides this path rather than the OpenAI scaffold.
22
- * That buys it everything the path already has: token streaming, the first-byte
23
- * deadline, prompt-cache breakpoints, and the structured-output contract. GLM is
24
- * therefore a second *production* provider, not a fifth scaffold.
54
+ * `(string & {})` keeps editor autocomplete for the known names while accepting
55
+ * anything. A provider outside {@link KNOWN_PROVIDERS} simply has to declare its
56
+ * `wire` and `baseUrl` see `WillLLMConfig.providers`.
25
57
  */
26
- const ANTHROPIC_WIRE = new Set<LLMProvider>( [ 'anthropic', 'glm' ] )
58
+ export type LLMProvider = KnownProvider | ( string & {} )
27
59
 
28
- /** Does this provider accept Anthropic-shaped requests? */
29
- export function speaksAnthropicWire( provider: LLMProvider ): boolean {
30
- return ANTHROPIC_WIRE.has( provider )
60
+ /**
61
+ * Built-in wire + base URL per provider. This is *data*, not support: it saves
62
+ * a host from looking up an endpoint, and nothing more. Any provider absent
63
+ * from this table works identically once the host declares `wire` + `baseUrl`
64
+ * on its `llm.providers` entry.
65
+ *
66
+ * WHY THIS TABLE SURVIVES WHEN THE PRICE TABLE DID NOT. A stale price is
67
+ * invisible: it produces a confident wrong number nobody doubts. A stale base
68
+ * URL fails on the first call, loudly, with the endpoint in the message. They
69
+ * also move on completely different clocks — vendors reprice quarterly, and
70
+ * change an API host about once a decade. Convenience is worth it when being
71
+ * wrong is self-announcing.
72
+ *
73
+ * REGIONAL ENDPOINTS. `moonshot`, `qwen` and `minimax` all run separate
74
+ * mainland-China hosts (`api.moonshot.cn`, `dashscope.aliyuncs.com`,
75
+ * `api.minimaxi.com`). The international host is the default here; a key issued
76
+ * on the other one authenticates nowhere, so a host on a China account must set
77
+ * `baseUrl` explicitly.
78
+ */
79
+ export const KNOWN_PROVIDERS: Record<string, { wire: LLMWire; baseUrl: string }> = {
80
+ // Never dialled — present so a test-mode Will resolves an endpoint without
81
+ // demanding a provider the run will never use.
82
+ [ MOCK_PROVIDER ]: { wire: 'anthropic', baseUrl: 'http://mock.invalid/v1' },
83
+
84
+ // ── Anthropic wire ──────────────────────────────────────────
85
+ anthropic: { wire: 'anthropic', baseUrl: 'https://api.anthropic.com/v1' },
86
+ // Z.ai documents the base as `…/api/anthropic` because the Anthropic SDK
87
+ // appends `/v1/messages`; this client appends `/messages`, so the version
88
+ // segment belongs here — verified against the live endpoint.
89
+ glm: { wire: 'anthropic', baseUrl: 'https://api.z.ai/api/anthropic/v1' },
90
+
91
+ // ── OpenAI wire ─────────────────────────────────────────────
92
+ openai: { wire: 'openai', baseUrl: 'https://api.openai.com/v1' },
93
+ deepseek: { wire: 'openai', baseUrl: 'https://api.deepseek.com/v1' },
94
+ moonshot: { wire: 'openai', baseUrl: 'https://api.moonshot.ai/v1' },
95
+ qwen: { wire: 'openai', baseUrl: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1' },
96
+ xai: { wire: 'openai', baseUrl: 'https://api.x.ai/v1' },
97
+ minimax: { wire: 'openai', baseUrl: 'https://api.minimax.io/v1' },
98
+ mistral: { wire: 'openai', baseUrl: 'https://api.mistral.ai/v1' },
99
+ // Local runtimes. The port is the project default; a host that moved it sets
100
+ // `baseUrl`. Both still want an `apiKey` — any non-empty string will do,
101
+ // since neither checks it.
102
+ ollama: { wire: 'openai', baseUrl: 'http://localhost:11434/v1' },
103
+ vllm: { wire: 'openai', baseUrl: 'http://localhost:8000/v1' },
104
+
105
+ // ── Google wire ─────────────────────────────────────────────
106
+ // Gemini also exposes an OpenAI-compatible surface; this client speaks the
107
+ // native one, which is where its caching and multimodal parts actually live.
108
+ google: { wire: 'google', baseUrl: 'https://generativelanguage.googleapis.com/v1beta' },
31
109
  }
32
110
 
33
- /** Official API base URL (including version segment) for a provider. */
34
- export function defaultBaseFor( provider: LLMProvider ): string {
35
- switch( provider ){
36
- case 'anthropic': return 'https://api.anthropic.com/v1'
37
- // Z.ai documents the base as `…/api/anthropic` because the Anthropic SDK
38
- // appends `/v1/messages`; this client appends `/messages`, so the version
39
- // segment belongs here verified against the live endpoint.
40
- case 'glm': return 'https://api.z.ai/api/anthropic/v1'
41
- case 'openai': return 'https://api.openai.com/v1'
42
- case 'deepseek': return 'https://api.deepseek.com/v1'
43
- case 'google': return 'https://generativelanguage.googleapis.com/v1beta'
44
- }
111
+ /**
112
+ * The conventional env var holding each provider's key.
113
+ *
114
+ * This is *not* the fallback that W9 removed. That one read `ANTHROPIC_API_KEY`
115
+ * whatever provider was configured, so a Will pointed at another vendor sent it
116
+ * an Anthropic key. This lookup is keyed by the resolved provider: a `moonshot`
117
+ * Will reads `MOONSHOT_API_KEY` and nothing else, and an unknown provider gets
118
+ * nothing rather than someone else's secret.
119
+ *
120
+ * `WILL_LLM_API_KEY` still wins over all of it — it is the explicit statement.
121
+ */
122
+ export const PROVIDER_KEY_ENV: Record<string, string> = {
123
+ anthropic: 'ANTHROPIC_API_KEY',
124
+ glm: 'ZAI_API_KEY',
125
+ openai: 'OPENAI_API_KEY',
126
+ google: 'GOOGLE_API_KEY',
127
+ deepseek: 'DEEPSEEK_API_KEY',
128
+ moonshot: 'MOONSHOT_API_KEY',
129
+ qwen: 'DASHSCOPE_API_KEY',
130
+ xai: 'XAI_API_KEY',
131
+ minimax: 'MINIMAX_API_KEY',
132
+ mistral: 'MISTRAL_API_KEY',
45
133
  }
46
134
 
47
135
  /**
48
- * The model the executive recruits when none is pinned.
136
+ * The provider's own key from the environment, if it has a conventional one.
49
137
  *
50
- * Provider-specific because the default is *sent* a GLM Will with no
51
- * `WILL_LLM_MODEL` would otherwise ask Z.ai for a Claude id and get a 404 it
52
- * could do nothing with. The scaffolded providers (openai/deepseek/google) keep
53
- * today's value: they need an explicit `WILL_LLM_MODEL` to work at all, and
54
- * inventing ids for them here would look like support that does not exist.
138
+ * An empty or blank value counts as absent. A `.env` that lists every provider
139
+ * and fills in one which is what the template invites leaves the rest as
140
+ * `KEY=`, and a present-but-empty key is not a key.
55
141
  */
56
- export function defaultModelFor( provider: LLMProvider ): string {
57
- return provider === 'glm' ? 'glm-5.2' : 'claude-sonnet-4-5-20250929'
142
+ export function providerKeyFromEnv( provider: LLMProvider ): string | undefined {
143
+ const name = PROVIDER_KEY_ENV[ provider ]
144
+ if( !name ) return undefined
145
+ // Gemini ships under two names in the wild; both mean the same account.
146
+ const value = nonBlank( process.env[ name ] )
147
+ ?? ( provider === 'google' ? nonBlank( process.env[ 'GEMINI_API_KEY' ] ) : undefined )
148
+ return value
149
+ }
150
+
151
+ const nonBlank = ( v: string | undefined ): string | undefined => v && v.trim() ? v : undefined
152
+
153
+ /** Built-in wire for a known provider, or undefined — the host must declare it. */
154
+ export function knownWireFor( provider: LLMProvider ): LLMWire | undefined {
155
+ return KNOWN_PROVIDERS[ provider ]?.wire
156
+ }
157
+
158
+ /** Built-in base URL for a known provider, or undefined — the host must declare it. */
159
+ export function defaultBaseFor( provider: LLMProvider ): string | undefined {
160
+ return KNOWN_PROVIDERS[ provider ]?.baseUrl
58
161
  }
59
162
 
60
163
  /**
61
164
  * Auth + version headers for the Anthropic wire.
62
165
  *
63
- * Anthropic authenticates with `x-api-key`. Z.ai's compat endpoint accepts
64
- * either that or the `Authorization: Bearer` its own docs describe (both were
65
- * probed against the live endpoint; each is read and validated). GLM sends both
66
- * same secret, same host so the mind keeps working whichever one Z.ai
67
- * eventually settles on.
166
+ * Anthropic authenticates with `x-api-key`. Compatible endpoints (Z.ai's GLM,
167
+ * and every third-party clone since) generally document
168
+ * `Authorization: Bearer`, and accept either. So we send `x-api-key` always,
169
+ * and add the bearer for everyone *except* Anthropic itself same secret, same
170
+ * host, and the mind keeps working whichever header the endpoint reads.
68
171
  */
69
172
  export function anthropicWireHeaders( provider: LLMProvider, apiKey: string ): Record<string, string> {
70
173
  return {
71
174
  'Content-Type': 'application/json',
72
175
  'anthropic-version': '2023-06-01',
73
176
  'x-api-key': apiKey,
74
- ...( provider === 'glm' ? { Authorization: `Bearer ${ apiKey }` } : {} ),
177
+ ...( provider === 'anthropic' ? {} : { Authorization: `Bearer ${ apiKey }` } ),
75
178
  }
76
179
  }
77
180
  export interface LLMDirectorConfig {
@@ -103,6 +206,49 @@ export interface LLMDirectorConfig {
103
206
  * replay runs). This replaces the former process-global getTokenTracker().
104
207
  */
105
208
  tokenTracker?: TokenTracker | null
209
+ /**
210
+ * MODEL_ROUTING W3 — per-call model selection. Absent (or NULL_ROUTER) means
211
+ * every call uses the default model below, exactly as before the seam existed.
212
+ * A router that throws, or names a provider with no usable credential, falls
213
+ * back to the default: a routing problem must never kill a running mind.
214
+ */
215
+ router?: ModelRouter | null
216
+ /**
217
+ * Per-provider credentials for routed calls. The top-level `apiKey`/`baseUrl`
218
+ * remain the default entry; a route to a provider absent from this map falls
219
+ * back to the default endpoint.
220
+ */
221
+ credentials?: Partial<Record<string, ProviderCredential>>
222
+ /**
223
+ * Dialect for the default provider. Required when the provider is not one of
224
+ * {@link KNOWN_PROVIDERS} — the engine will not guess how to talk to an
225
+ * endpoint it has never heard of.
226
+ */
227
+ wire?: LLMWire
228
+ }
229
+
230
+ /**
231
+ * Everything a single call needs to reach a model. Resolved once per call and
232
+ * threaded through the provider methods — never stored on the instance, because
233
+ * the concurrency gate lets several calls be in flight on one director at once
234
+ * and per-call state on `this` would race between them.
235
+ */
236
+ /** What a host supplies so a routed provider can be reached. */
237
+ export interface ProviderCredential {
238
+ apiKey: string
239
+ baseUrl?: string
240
+ /** Required for providers outside {@link KNOWN_PROVIDERS}. */
241
+ wire?: LLMWire
242
+ }
243
+
244
+ export interface CallEndpoint {
245
+ provider: LLMProvider
246
+ /** The dialect to speak. Resolved once; the transport branches on this. */
247
+ wire: LLMWire
248
+ model: string
249
+ apiKey: string
250
+ baseUrl: string
251
+ maxOutputTokens: number
106
252
  }
107
253
 
108
254
  // ── LLM call result ──────────────────────────────────────────
@@ -124,21 +270,91 @@ export interface LLMCallResult {
124
270
  * here, letting the TokenTracker break spend down per category for transparency.
125
271
  */
126
272
  export interface LLMCallMeta {
127
- /** Top-level cost bucket: 'executive' | 'summarizer' | 'embedding' | 'identity-guard' | … */
128
- category: string
129
- /** The actor/subsystem doing the work: 'master' | 'facet' | 'memory' | 'guard' | … */
130
- attribute: string
131
- /** The specific cognitive function: 'decision' | 'ideation' | 'conversation' | 'planning' | 'deliberation' | 'outreach' | 'consolidation' | 'recall' | 'index' | 'identity-coherence' | … */
132
- function: string
273
+ /** Top-level cost bucket. */
274
+ category: LLMCallCategory
275
+ /** The actor/subsystem doing the work. */
276
+ attribute: LLMCallAttribute
277
+ /** The specific cognitive function. */
278
+ function: LLMCallFunction
133
279
  /** Optional specific id or namespace: facet id, entity id, model name. */
134
280
  scope?: string
135
281
  /** Free-form human-readable label. Auto-composed from the axes when omitted. */
136
282
  label?: string
283
+ /**
284
+ * How much this call demands, 0..1 — MODEL_ROUTING W0.
285
+ *
286
+ * A *cognitive* measure, never a commercial one: it says how consequential or
287
+ * uncertain this moment is, never who is paying for it. Two faculties already
288
+ * compute it and simply forward what they have — the master and its facets
289
+ * pass `effortScore` (the a-priori effort gate: uncertainty, prior
290
+ * confidence, novelty, a pending reply, stress load), and deliberation passes
291
+ * the agency stakes of the choice under consideration. Structurally
292
+ * background work (summarising, guarding, embedding, delivery) reports a low
293
+ * constant, because it is background whether the mind is calm or in crisis.
294
+ *
295
+ * Absent means UNKNOWN, not zero: a consumer must fall back to its default
296
+ * rather than treat a missing value as "cheapest possible".
297
+ *
298
+ * This field is inert with respect to cognition. It rides along to whoever
299
+ * resolves the model for a call; no engine may read it back and behave
300
+ * differently, or the routing layer becomes a hidden input to the mind.
301
+ */
302
+ demand?: number
137
303
  }
138
304
 
305
+ /** Structurally background work — see `LLMCallMeta.demand`. */
306
+ export const BACKGROUND_DEMAND = 0.1
307
+
308
+ /**
309
+ * Escalation is elevated by construction: the buffer only fires once something
310
+ * has already failed to resolve on its own.
311
+ */
312
+ export const ESCALATION_DEMAND = 0.7
313
+
139
314
  /** Default attribution when a caller does not tag itself (back-compat). */
140
315
  const DEFAULT_CALL_META: LLMCallMeta = { category: 'executive', attribute: 'master', function: 'decision' }
141
316
 
317
+ /**
318
+ * Fill in wire and base URL, or say clearly what is missing.
319
+ *
320
+ * Known providers supply both from {@link KNOWN_PROVIDERS}; anything else must
321
+ * declare them. The engine refuses to guess how to talk to an endpoint it has
322
+ * never heard of — a wrong guess is a 404 at the worst possible moment, and
323
+ * previously the guess was "Anthropic", which is how a GLM Will could end up
324
+ * asking Z.ai for a Claude model id.
325
+ */
326
+ export function resolveEndpoint( spec: {
327
+ provider: LLMProvider
328
+ model: string
329
+ apiKey: string
330
+ baseUrl?: string | undefined
331
+ wire?: LLMWire | undefined
332
+ maxOutputTokens: number
333
+ } ): CallEndpoint {
334
+ const wire = spec.wire ?? knownWireFor( spec.provider )
335
+ if( !wire )
336
+ throw new Error(
337
+ `LLM provider "${spec.provider}" has no known wire. Declare it: ` +
338
+ `llm.providers['${spec.provider}'].wire = 'anthropic' | 'openai' | 'google'.`
339
+ )
340
+
341
+ const baseUrl = spec.baseUrl ?? defaultBaseFor( spec.provider )
342
+ if( !baseUrl )
343
+ throw new Error(
344
+ `LLM provider "${spec.provider}" has no known base URL. Declare it: ` +
345
+ `llm.providers['${spec.provider}'].baseUrl.`
346
+ )
347
+
348
+ return {
349
+ provider: spec.provider,
350
+ wire,
351
+ model: spec.model,
352
+ apiKey: spec.apiKey,
353
+ baseUrl,
354
+ maxOutputTokens: spec.maxOutputTokens,
355
+ }
356
+ }
357
+
142
358
  export class LLMDirector {
143
359
  private _willId: string
144
360
  private _model: string
@@ -150,6 +366,12 @@ export class LLMDirector {
150
366
  private _baseUrl: string | null
151
367
  private _timeoutMs: number
152
368
  private _tokenTracker: TokenTracker | null
369
+ private _router: ModelRouter | null
370
+ private _credentials: Partial<Record<string, ProviderCredential>>
371
+ /** Default endpoint — what every call used before the routing seam existed. */
372
+ private _defaultEndpoint: CallEndpoint
373
+ /** Routes already warned about (missing credential / bad provider) — log once. */
374
+ private _routeWarned = new Set<string>()
153
375
 
154
376
  constructor( config: LLMDirectorConfig ) {
155
377
  this._willId = config.willId
@@ -162,6 +384,75 @@ export class LLMDirector {
162
384
  this._baseUrl = config.baseUrl ?? null
163
385
  this._timeoutMs = config.timeoutMs ?? 90_000
164
386
  this._tokenTracker = config.tokenTracker ?? null
387
+ this._router = config.router ?? null
388
+ this._credentials = config.credentials ?? {}
389
+ this._defaultEndpoint = resolveEndpoint( {
390
+ provider: this._provider,
391
+ model: this._model,
392
+ apiKey: this._apiKey,
393
+ baseUrl: this._baseUrl ?? undefined,
394
+ wire: config.wire,
395
+ maxOutputTokens: this._maxOutputTokens,
396
+ } )
397
+ }
398
+
399
+ /**
400
+ * Resolve which model serves this call. Falls back to the default endpoint
401
+ * whenever the router has no opinion, throws, or names a provider we hold no
402
+ * credential for — degrade, never crash.
403
+ */
404
+ private _resolveEndpoint( meta: LLMCallMeta ): CallEndpoint {
405
+ if( isNullRouter( this._router ) ) return this._defaultEndpoint
406
+
407
+ let route
408
+ try { route = this._router!.route( meta ) }
409
+ catch( err ){
410
+ this._warnRouteOnce(`throw:${this._router!.name}`,
411
+ `router "${this._router!.name}" threw — using the default model`, err )
412
+ return this._defaultEndpoint
413
+ }
414
+ if( !route ) return this._defaultEndpoint
415
+
416
+ // A route with no provider means "same vendor, different model" — the whole
417
+ // shape of the per-role model map, and the common case for a host swapping
418
+ // in a cheaper model for background work.
419
+ const provider = route.provider ?? this._defaultEndpoint.provider
420
+
421
+ // The default provider's credential is reused when the route names it;
422
+ // otherwise the route needs its own entry.
423
+ const cred = provider === this._defaultEndpoint.provider
424
+ ? { apiKey: this._defaultEndpoint.apiKey, baseUrl: this._defaultEndpoint.baseUrl, wire: this._defaultEndpoint.wire }
425
+ : this._credentials[ provider ]
426
+
427
+ if( !cred?.apiKey ){
428
+ this._warnRouteOnce(`cred:${provider}`,
429
+ `no credential for routed provider "${provider}" — using the default model` )
430
+ return this._defaultEndpoint
431
+ }
432
+
433
+ try {
434
+ return resolveEndpoint( {
435
+ provider,
436
+ model: route.model,
437
+ apiKey: cred.apiKey,
438
+ baseUrl: route.baseUrl ?? cred.baseUrl,
439
+ wire: cred.wire,
440
+ maxOutputTokens: route.maxOutputTokens ?? this._defaultEndpoint.maxOutputTokens,
441
+ } )
442
+ }
443
+ catch( err ){
444
+ // An undeclared wire or base URL for a routed provider is a config gap,
445
+ // not a reason to fail the call.
446
+ this._warnRouteOnce(`resolve:${provider}`,
447
+ `cannot reach routed provider "${provider}" — using the default model`, err )
448
+ return this._defaultEndpoint
449
+ }
450
+ }
451
+
452
+ private _warnRouteOnce( key: string, message: string, err?: unknown ): void {
453
+ if( this._routeWarned.has( key ) ) return
454
+ this._routeWarned.add( key )
455
+ logger.warn(`[llm.routing] ${message}`, err instanceof Error ? err.message : '')
165
456
  }
166
457
 
167
458
  // ── Mock response (test mode) ────────────────────────────
@@ -268,20 +559,27 @@ export class LLMDirector {
268
559
  return { text: replay.text, inputTok: replay.inputTok, outputTok: replay.outputTok }
269
560
  }
270
561
 
562
+ // MODEL_ROUTING W3 — resolve once, then thread it: several calls can be in
563
+ // flight on this director at once, so the endpoint must travel with the call
564
+ // rather than live on `this`. Resolved before the mock branch so a mock run
565
+ // records the endpoint that WOULD have served the call — the tape then says
566
+ // the same thing in mock and live runs.
567
+ const ep = this._resolveEndpoint( meta )
568
+
271
569
  if( this._mock ){
272
570
  const result = this._mockResponse( tick, userMessage )
273
571
  // In mock mode we don't stream raw internal text — the response will be
274
572
  // emitted from the outbox content by the SSE layer. onChunk is intentionally
275
573
  // not called here so no internal [REPLY] / JSON format leaks to the client.
276
- this._recordCompletion( systemPrompt, userMessage, tick, result, Date.now() - start, true )
574
+ this._recordCompletion( systemPrompt, userMessage, tick, result, Date.now() - start, true, ep )
277
575
  return result
278
576
  }
279
577
 
280
- const result = speaksAnthropicWire( this._provider )
281
- ? await this._callAnthropicStream( systemPrompt, userMessage, onChunk, temperature )
578
+ const result = ep.wire === 'anthropic'
579
+ ? await this._callAnthropicStream( ep, systemPrompt, userMessage, onChunk, temperature )
282
580
  : await ( async () => {
283
581
  // Other providers: fall back to regular call, emit whole response as one chunk
284
- const r = await this._callProvider( systemPrompt, userMessage, temperature )
582
+ const r = await this._callProvider( ep, systemPrompt, userMessage, temperature )
285
583
  onChunk( r.text )
286
584
  return r
287
585
  } )()
@@ -289,8 +587,8 @@ export class LLMDirector {
289
587
  // Token tracking lives here too: streamed calls (conversation facets, the
290
588
  // master when broadcasting) previously bypassed the tracker entirely, so all
291
589
  // streamed spend was invisible. Record it with the caller's attribution.
292
- this._track( result, meta, tick, Date.now() - start, this._estPromptTokens( systemPrompt, userMessage ) )
293
- this._recordCompletion( systemPrompt, userMessage, tick, result, Date.now() - start, false )
590
+ this._track( result, meta, tick, Date.now() - start, this._estPromptTokens( systemPrompt, userMessage ), ep )
591
+ this._recordCompletion( systemPrompt, userMessage, tick, result, Date.now() - start, false, ep )
294
592
  return result
295
593
  }
296
594
 
@@ -300,9 +598,14 @@ export class LLMDirector {
300
598
  * mock/replay directors, so the call is simply skipped. Cache read/write tokens
301
599
  * are forwarded so the tracker prices them at 0.1× / 1.25× input.
302
600
  */
303
- private _track( result: LLMCallResult, meta: LLMCallMeta, tick: Tick, latencyMs: number, estPromptTokens?: number ): void {
601
+ private _track( result: LLMCallResult, meta: LLMCallMeta, tick: Tick, latencyMs: number, estPromptTokens?: number, ep: CallEndpoint = this._defaultEndpoint ): void {
304
602
  this._tokenTracker?.recordUsage({
305
- model: this._model,
603
+ // The endpoint that actually served this call — routed or default.
604
+ // Pricing must follow the real model, or routed spend is attributed
605
+ // wrongly; the provider rides along because the same model id can be
606
+ // reached from several vendors at very different prices.
607
+ model: ep.model,
608
+ provider: ep.provider,
306
609
  promptTokens: result.inputTok,
307
610
  completionTokens: result.outputTok,
308
611
  totalTokens: result.inputTok + result.outputTok,
@@ -337,14 +640,17 @@ export class LLMDirector {
337
640
  result: LLMCallResult,
338
641
  latencyMs: number,
339
642
  mock: boolean,
643
+ ep: CallEndpoint = this._defaultEndpoint,
340
644
  ): void {
341
645
  try {
342
646
  getCompletionRecorder( this._willId )?.recordCompletion({
343
647
  tick,
344
648
  willId: this._willId,
345
- provider: this._provider,
346
- model: this._model,
347
- maxOutputTokens: this._maxOutputTokens,
649
+ // Record the endpoint that actually served the call: the tape is what
650
+ // replay re-feeds, so it must say which model produced this text.
651
+ provider: ep.provider,
652
+ model: ep.model,
653
+ maxOutputTokens: ep.maxOutputTokens,
348
654
  systemPrompt,
349
655
  userMessage,
350
656
  text: result.text,
@@ -374,6 +680,7 @@ export class LLMDirector {
374
680
  }
375
681
 
376
682
  private async _callAnthropicStream(
683
+ ep: CallEndpoint,
377
684
  systemPrompt: string,
378
685
  userMessage: string,
379
686
  onChunk: ( chunk: string ) => void,
@@ -387,12 +694,12 @@ export class LLMDirector {
387
694
 
388
695
  let res: Response
389
696
  try {
390
- res = await fetch(`${this._resolvedBase()}/messages`, {
697
+ res = await fetch(`${this._resolvedBase( ep )}/messages`, {
391
698
  method: 'POST',
392
- headers: anthropicWireHeaders( this._provider, this._apiKey ),
699
+ headers: anthropicWireHeaders( ep.provider, ep.apiKey ),
393
700
  body: JSON.stringify({
394
- model: this._model,
395
- max_tokens: this._maxOutputTokens,
701
+ model: ep.model,
702
+ max_tokens: ep.maxOutputTokens,
396
703
  ...( temperature !== undefined ? { temperature } : {} ),
397
704
  stream: true,
398
705
  system: this._systemField( systemPrompt ),
@@ -404,7 +711,7 @@ export class LLMDirector {
404
711
  catch( err ){
405
712
  clearTimeout( timer )
406
713
  if( controller.signal.aborted )
407
- throw new Error(`LLM stream to ${this._provider} timed out after ${this._timeoutMs}ms (no response)`)
714
+ throw new Error(`LLM stream to ${ep.provider} timed out after ${this._timeoutMs}ms (no response)`)
408
715
  throw err
409
716
  }
410
717
 
@@ -489,9 +796,11 @@ export class LLMDirector {
489
796
  if( replay )
490
797
  return { text: replay.text, inputTok: replay.inputTok, outputTok: replay.outputTok }
491
798
 
799
+ const ep = this._resolveEndpoint( meta )
800
+
492
801
  if( this._mock ){
493
802
  const result = this._mockResponse( tick, userMessage )
494
- this._recordCompletion( systemPrompt, userMessage, tick, result, Date.now() - llmStart, true )
803
+ this._recordCompletion( systemPrompt, userMessage, tick, result, Date.now() - llmStart, true, ep )
495
804
  return result
496
805
  }
497
806
 
@@ -502,44 +811,36 @@ export class LLMDirector {
502
811
  // accumulated text; live token chunks go through callStream(). Other
503
812
  // providers keep the whole-request deadline.
504
813
  const result = await withGate(
505
- () => speaksAnthropicWire( this._provider )
506
- ? this._callAnthropicStream( systemPrompt, userMessage, () => {}, temperature )
507
- : this._callProvider( systemPrompt, userMessage, temperature ),
814
+ () => ep.wire === 'anthropic'
815
+ ? this._callAnthropicStream( ep, systemPrompt, userMessage, () => {}, temperature )
816
+ : this._callProvider( ep, systemPrompt, userMessage, temperature ),
508
817
  'executive/direct',
509
818
  )
510
819
 
511
820
  // Record token usage + cost into this Will's injected tracker (R4), tagged
512
821
  // with the caller's attribution. Optional — absent on mock/replay directors.
513
- this._track( result, meta, tick, Date.now() - llmStart, this._estPromptTokens( systemPrompt, userMessage ) )
822
+ this._track( result, meta, tick, Date.now() - llmStart, this._estPromptTokens( systemPrompt, userMessage ), ep )
514
823
 
515
- this._recordCompletion( systemPrompt, userMessage, tick, result, Date.now() - llmStart, false )
824
+ this._recordCompletion( systemPrompt, userMessage, tick, result, Date.now() - llmStart, false, ep )
516
825
  return result
517
826
  }
518
827
 
519
828
  private _callProvider(
829
+ ep: CallEndpoint,
520
830
  systemPrompt: string,
521
831
  userMessage: string,
522
832
  temperature?: number,
523
833
  ): Promise<LLMCallResult> {
524
- switch( this._provider ){
525
- case 'anthropic': return this._callAnthropic( systemPrompt, userMessage, temperature )
526
- case 'glm': return this._callAnthropic( systemPrompt, userMessage, temperature )
527
- case 'deepseek': return this._callOpenAI( systemPrompt, userMessage, temperature )
528
- case 'openai': return this._callOpenAI( systemPrompt, userMessage, temperature )
529
- case 'google': return this._callGoogle( systemPrompt, userMessage, temperature )
530
- default: throw new Error(`Unknown LLM provider: ${this._provider}`)
834
+ switch( ep.wire ){
835
+ case 'anthropic': return this._callAnthropic( ep, systemPrompt, userMessage, temperature )
836
+ case 'openai': return this._callOpenAI( ep, systemPrompt, userMessage, temperature )
837
+ case 'google': return this._callGoogle( ep, systemPrompt, userMessage, temperature )
838
+ default: throw new Error(`Unknown LLM wire: ${ep.wire}`)
531
839
  }
532
840
  }
533
841
 
534
- /** Default API base URL (including version segment) for a provider. */
535
- private _baseFor( provider: LLMProvider ): string {
536
- return defaultBaseFor( provider )
537
- }
538
-
539
842
  /** Resolved API base: explicit override wins, else the provider default. */
540
- private _resolvedBase(): string {
541
- return this._baseUrl ?? this._baseFor( this._provider )
542
- }
843
+ private _resolvedBase( ep: CallEndpoint ): string { return ep.baseUrl }
543
844
 
544
845
  /**
545
846
  * fetch() with a hard per-request deadline. A hung connection is aborted
@@ -569,23 +870,23 @@ export class LLMDirector {
569
870
  return [ { type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } } ]
570
871
  }
571
872
 
572
- private async _callAnthropic( systemPrompt: string, userMessage: string, temperature?: number ): Promise<LLMCallResult> {
873
+ private async _callAnthropic( ep: CallEndpoint, systemPrompt: string, userMessage: string, temperature?: number ): Promise<LLMCallResult> {
573
874
  const body = {
574
- model: this._model,
575
- max_tokens: this._maxOutputTokens,
875
+ model: ep.model,
876
+ max_tokens: ep.maxOutputTokens,
576
877
  ...( temperature !== undefined ? { temperature } : {} ),
577
878
  system: this._systemField( systemPrompt ),
578
879
  messages: [{ role: 'user', content: userMessage }]
579
880
  }
580
881
 
581
- const res = await this._fetchWithTimeout(`${this._resolvedBase()}/messages`, {
882
+ const res = await this._fetchWithTimeout(`${this._resolvedBase( ep )}/messages`, {
582
883
  method: 'POST',
583
- headers: anthropicWireHeaders( this._provider, this._apiKey ),
884
+ headers: anthropicWireHeaders( ep.provider, ep.apiKey ),
584
885
  body: JSON.stringify( body )
585
886
  })
586
887
 
587
888
  if( !res.ok )
588
- throw new Error(`${this._provider} API ${res.status}: ${( await res.text() ).slice(0, 300)}`)
889
+ throw new Error(`${ep.provider} API ${res.status}: ${( await res.text() ).slice(0, 300)}`)
589
890
 
590
891
  const
591
892
  data = await res.json() as {
@@ -603,10 +904,10 @@ export class LLMDirector {
603
904
  }
604
905
  }
605
906
 
606
- private async _callOpenAI( systemPrompt: string, userMessage: string, temperature?: number ): Promise<LLMCallResult> {
907
+ private async _callOpenAI( ep: CallEndpoint, systemPrompt: string, userMessage: string, temperature?: number ): Promise<LLMCallResult> {
607
908
  const body = {
608
- model: this._model,
609
- max_completion_tokens: this._maxOutputTokens,
909
+ model: ep.model,
910
+ max_completion_tokens: ep.maxOutputTokens,
610
911
  ...( temperature !== undefined ? { temperature } : {} ),
611
912
  messages: [
612
913
  { role: 'system', content: systemPrompt },
@@ -614,11 +915,11 @@ export class LLMDirector {
614
915
  ]
615
916
  }
616
917
 
617
- const res = await this._fetchWithTimeout(`${this._resolvedBase()}/chat/completions`, {
918
+ const res = await this._fetchWithTimeout(`${this._resolvedBase( ep )}/chat/completions`, {
618
919
  method: 'POST',
619
920
  headers: {
620
921
  'Content-Type': 'application/json',
621
- 'Authorization': `Bearer ${this._apiKey}`,
922
+ 'Authorization': `Bearer ${ep.apiKey}`,
622
923
  },
623
924
  body: JSON.stringify(body),
624
925
  })
@@ -640,25 +941,25 @@ export class LLMDirector {
640
941
  }
641
942
  }
642
943
 
643
- private async _callGoogle( systemPrompt: string, userMessage: string, temperature?: number ): Promise<LLMCallResult> {
944
+ private async _callGoogle( ep: CallEndpoint, systemPrompt: string, userMessage: string, temperature?: number ): Promise<LLMCallResult> {
644
945
  // Gemini carries the system prompt in a dedicated `systemInstruction`
645
946
  // field and the conversation in `contents`.
646
947
  const body = {
647
948
  systemInstruction: { parts: [ { text: systemPrompt } ] },
648
949
  contents: [ { role: 'user', parts: [ { text: userMessage } ] } ],
649
950
  generationConfig: {
650
- maxOutputTokens: this._maxOutputTokens,
951
+ maxOutputTokens: ep.maxOutputTokens,
651
952
  ...( temperature !== undefined ? { temperature } : {} ),
652
953
  },
653
954
  }
654
955
 
655
956
  const res = await this._fetchWithTimeout(
656
- `${this._resolvedBase()}/models/${this._model}:generateContent`,
957
+ `${this._resolvedBase( ep )}/models/${ep.model}:generateContent`,
657
958
  {
658
959
  method: 'POST',
659
960
  headers: {
660
961
  'Content-Type': 'application/json',
661
- 'x-goog-api-key': this._apiKey,
962
+ 'x-goog-api-key': ep.apiKey,
662
963
  },
663
964
  body: JSON.stringify( body ),
664
965
  }