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