@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
@@ -16,6 +16,7 @@
16
16
  * Exposes as metrics so the orchestrator and runner can log costs,
17
17
  * and the ParameterOptimizer can factor cost into optimization decisions.
18
18
  */
19
+ import { logger } from '#core/logger'
19
20
  import type {
20
21
  Duration,
21
22
  Tick,
@@ -31,56 +32,46 @@ import { wallClock } from '#core/wall.clock'
31
32
  // transport layer (determinism contract). The tracker exposes a neutral
32
33
  // onRecord() sink; the stem bridges records onto the transport.
33
34
 
35
+ // ── Attribution axes ──────────────────────────────────────
36
+ //
37
+ // Typed rather than free strings so a deviation is caught at the call site
38
+ // instead of surfacing as a silently-unmatched routing rule or a cost bucket
39
+ // nobody notices is empty. These live here (not in #llm) because cognition is
40
+ // the lower layer — #llm already imports from this module, and the reverse
41
+ // would be circular.
42
+
43
+ /** Top-level cost bucket for an LLM call. */
44
+ export type LLMCallCategory =
45
+ | 'executive' // the master consciousness and its facets
46
+ | 'summarizer' // rolling memory consolidation
47
+ | 'embedding' // semantic-memory vectorisation
48
+ | 'identity-guard' // creation-time persona review
49
+
50
+ /** The actor/subsystem doing the work. */
51
+ export type LLMCallAttribute =
52
+ | 'master' // the executive itself
53
+ | 'facet' // a spawned focus (conversation, planning, outreach, supervision)
54
+ | 'memory' // consolidation / embedding
55
+ | 'guard' // a safety reviewer
56
+
57
+ /** The specific cognitive function being paid for. */
58
+ export type LLMCallFunction =
59
+ | 'decision' // the master's fused decision call
60
+ | 'ideation' // the deliberate path's propose pass
61
+ | 'deliberation' // action choice under contest
62
+ | 'conversation' // a live reply
63
+ | 'outreach' // an unprompted message
64
+ | 'planning' // plan formation / revision
65
+ | 'supervision' // plan-step supervision
66
+ | 'consolidation' // rolling summary
67
+ | 'recall' // embedding a query
68
+ | 'index' // embedding a write
69
+ | 'identity-coherence' // persona review
70
+
34
71
  /** One attributed ledger record (5-axis attribution + tokens + cost). */
35
72
  export type TokenLedgerRecord = Record<string, unknown>
36
73
  export type TokenRecordListener = ( record: TokenLedgerRecord ) => void
37
74
 
38
- // ── Pricing table (USD per 1M tokens) ─────────────────────
39
-
40
- const MODEL_PRICING: Record<string, { input: number; output: number }> = {
41
- // OpenAI
42
- 'openai/gpt-4o': { input: 2.50, output: 10.00 },
43
- 'openai/gpt-4o-mini': { input: 0.15, output: 0.60 },
44
- 'openai/gpt-4-turbo': { input: 10.00, output: 30.00 },
45
- 'openai/gpt-3.5-turbo': { input: 0.50, output: 1.50 },
46
-
47
- // Anthropic (Claude 4.x family — prices per 1M tokens)
48
- 'anthropic/claude-haiku-4-5': { input: 1.00, output: 5.00 },
49
- 'anthropic/claude-sonnet-4-5': { input: 3.00, output: 15.00 },
50
- 'anthropic/claude-sonnet-4-6': { input: 3.00, output: 15.00 },
51
- 'anthropic/claude-opus-4-7': { input: 5.00, output: 25.00 },
52
- // Legacy aliases kept for backward compat
53
- 'anthropic/claude-haiku-4': { input: 1.00, output: 5.00 },
54
- 'anthropic/claude-opus-4': { input: 5.00, output: 25.00 },
55
-
56
- // Z.ai (GLM-5 family). `glm-5.2[1m]` is the same model asking for its 1M
57
- // context window — same rate, so it gets its own row rather than relying on
58
- // the normalizer (a future long-context tier would price differently).
59
- 'glm/glm-5.2': { input: 1.40, output: 4.40 },
60
- 'glm/glm-5.2[1m]': { input: 1.40, output: 4.40 },
61
-
62
- // Google
63
- 'google/gemini-2.0-flash': { input: 0.10, output: 0.40 },
64
- 'google/gemini-2.0-pro': { input: 1.25, output: 5.00 },
65
-
66
- // Meta (via Groq/Replicate)
67
- 'meta/llama-3.3-70b': { input: 0.59, output: 0.79 },
68
- 'meta/llama-4-maverick': { input: 0.20, output: 0.60 },
69
-
70
- // DeepSeek
71
- 'deepseek/deepseek-v3': { input: 0.27, output: 1.10 },
72
- 'deepseek/deepseek-r1': { input: 0.55, output: 2.19 },
73
-
74
- // Embeddings (input-only — no completion tokens). Priced per 1M input tokens.
75
- 'openai/text-embedding-3-small': { input: 0.02, output: 0 },
76
- 'openai/text-embedding-3-large': { input: 0.13, output: 0 },
77
- 'google/text-embedding-004': { input: 0, output: 0 }, // free tier
78
- 'google/gemini-embedding-001': { input: 0, output: 0 }, // free tier
79
-
80
- // Fallback for unknown models
81
- '__default__': { input: 3.00, output: 15.00 },
82
- }
83
-
84
75
  // ── Prompt-cache pricing (Anthropic) ──────────────────────
85
76
  // `input_tokens` in the API usage already EXCLUDES cached tokens, so the full
86
77
  // input cost is: fresh input ×1 + cache reads ×0.1 + cache writes ×1.25.
@@ -88,40 +79,83 @@ const CACHE_READ_MULT = 0.1
88
79
  const CACHE_WRITE_MULT = 1.25
89
80
 
90
81
  /**
91
- * Normalize a model id to its bare, dateless form so a raw provider model string
92
- * ("claude-sonnet-4-5-20250929", "anthropic/claude-haiku-4-5") resolves to the
93
- * right pricing row. Without this, every non-default id missed the `provider/model`
94
- * keys and silently fell through to __default__ ($3/$15) pricing Haiku ~3× and
95
- * DeepSeek ~11× too high, and breaking the self-hosting margin telemetry entirely.
82
+ * Normalize a model id to its bare form so a raw provider string
83
+ * ("claude-sonnet-5-20260114", "anthropic/claude-haiku-4-5", "glm-5.2[1m]")
84
+ * matches a host price keyed plainly and vice versa. Exact keys are tried
85
+ * first, so a host that prices a long-context variant differently just lists it
86
+ * verbatim and that wins.
96
87
  */
97
88
  function normalizeModelKey( model: string ): string {
98
89
  let m = model.toLowerCase().trim()
99
90
  const slash = m.lastIndexOf('/')
100
- if( slash >= 0 ) m = m.slice( slash + 1 ) // drop "provider/" prefix
101
- return m.replace( /[-@]\d{6,8}$/, '') // drop trailing -YYYYMMDD date stamp
91
+ if( slash >= 0 ) m = m.slice( slash + 1 ) // drop "provider/" prefix
92
+ m = m.replace( /\[[^\]]*\]$/, '') // drop a trailing qualifier, e.g. "[1m]"
93
+ return m.replace( /[-@]\d{6,8}$/, '') // drop trailing -YYYYMMDD date stamp
102
94
  }
95
+ /** USD per 1M tokens for one model. */
96
+ export interface ModelPrice { input: number; output: number }
97
+
98
+ /**
99
+ * Host-supplied prices, keyed by model id. Matching is exact first, then
100
+ * normalized (provider prefix, date stamp and context qualifier stripped), so
101
+ * `claude-sonnet-5` matches `claude-sonnet-5-20260114`.
102
+ *
103
+ * Prices belong to the host: they change on a vendor's schedule, differ per
104
+ * account, and are ~0 for a self-hosted model. The engine ships none.
105
+ */
106
+ export type PriceTable = Record<string, ModelPrice>
107
+
108
+ /** Models already warned about as unpriced — one line each, not one per call. */
109
+ const _unpricedWarned = new Set<string>()
103
110
 
104
- // Pre-index the pricing table by normalized model name for O(1), date-insensitive
105
- // lookup. Built once at module load.
106
- const PRICING_BY_NORM: Record<string, { input: number; output: number }> = ( () => {
107
- const out: Record<string, { input: number; output: number }> = {}
108
- for( const [ key, price ] of Object.entries( MODEL_PRICING ) ){
109
- if( key === '__default__') continue
110
- out[ normalizeModelKey( key ) ] = price
111
+ /**
112
+ * Resolve the price for a model id from the host's table.
113
+ *
114
+ * The engine ships no prices at all. A table baked into a release is wrong the
115
+ * week a vendor changes a rate, differs per account, and is meaningless for a
116
+ * self-hosted model — and a *partial* table is worse than none, because some
117
+ * models then report plausible-but-stale numbers while others honestly report
118
+ * nothing. Prices live with the host, next to the routing policy they inform.
119
+ *
120
+ * `null` does NOT mean free — it means *unknown*, and the caller reports zero
121
+ * cost with `priced: false` so the gap stays visible rather than confidently
122
+ * wrong. (The removed built-in default priced every unrecognised model at
123
+ * Sonnet's rate, overstating a budget model's output by ~54×.)
124
+ */
125
+ export function resolvePricing( model: string, hostPrices?: PriceTable ): ModelPrice | null {
126
+ if( !hostPrices ) return null
127
+
128
+ const exact = hostPrices[ model ]
129
+ if( exact ) return exact
130
+
131
+ // A host table keyed by bare ids still matches a dated / provider-prefixed
132
+ // model id, and vice versa.
133
+ const norm = normalizeModelKey( model )
134
+ if( hostPrices[ norm ] ) return hostPrices[ norm ]
135
+
136
+ for( const [ key, price ] of Object.entries( hostPrices ) ){
137
+ if( normalizeModelKey( key ) === norm ) return price
111
138
  }
112
- return out
113
- } )()
114
-
115
- /** Resolve the pricing row for any model id (exact, normalized, then default). */
116
- export function resolvePricing( model: string ): { input: number; output: number } {
117
- return PRICING_BY_NORM[ normalizeModelKey( model ) ]
118
- ?? MODEL_PRICING[ model ]
119
- ?? MODEL_PRICING['__default__']!
139
+
140
+ return null
120
141
  }
121
142
 
122
143
  export interface TokenUsage {
123
144
  /** Model identifier (e.g., 'openai/gpt-4o') */
124
145
  model: string
146
+ /**
147
+ * The provider that actually served this call.
148
+ *
149
+ * Not derivable from `model`: routing is what makes the same model id
150
+ * reachable from several places — `deepseek-v3` direct, through a gateway, or
151
+ * self-hosted — at prices that differ by orders of magnitude. Without this a
152
+ * host billing across a multi-vendor routing table can attribute spend to a
153
+ * model but never to the vendor it actually paid.
154
+ *
155
+ * Optional because a caller recording usage directly (outside the LLM
156
+ * director) may not know it; absent means unattributed, not "the default".
157
+ */
158
+ provider?: string
125
159
  /** Input/prompt tokens consumed */
126
160
  promptTokens: number
127
161
  /** Output/completion tokens consumed */
@@ -132,16 +166,20 @@ export interface TokenUsage {
132
166
  cacheReadTokens?: number
133
167
  /** Anthropic prompt-cache write tokens (billed at 1.25× input). Optional. */
134
168
  cacheWriteTokens?: number
135
- /** Estimated cost in USD */
169
+ /** Estimated cost in USD. Zero when `priced` is false — unknown, not free. */
136
170
  estimatedCostUsd: number
171
+ /**
172
+ * Whether a price was found for this model. False ⇒ `estimatedCostUsd` is 0
173
+ * because nothing priced it, NOT because the call was free. A consumer
174
+ * summing costs should surface unpriced calls rather than fold them in as
175
+ * zero.
176
+ */
177
+ priced: boolean
137
178
 
138
179
  // ── 5-axis cost attribution ──────────────────────────────
139
- /** Top-level cost bucket: 'executive' | 'summarizer' | 'embedding' | 'identity-guard' | … */
140
- category: string
141
- /** Actor/subsystem doing the work: 'master' | 'facet' | 'memory' | 'guard' | … */
142
- attribute: string
143
- /** Cognitive function: 'decision' | 'ideation' | 'conversation' | 'planning' | 'deliberation' | 'outreach' | 'consolidation' | 'recall' | 'index' | 'identity-coherence' | … */
144
- function: string
180
+ category: LLMCallCategory
181
+ attribute: LLMCallAttribute
182
+ function: LLMCallFunction
145
183
  /** Optional specific id or namespace: facet id, entity id, model name. */
146
184
  scope?: string
147
185
  /** Human-readable label — auto-composed from the axes when the caller omits it. */
@@ -157,15 +195,21 @@ export interface TokenUsage {
157
195
  }
158
196
 
159
197
  /** What callers pass to {@link TokenTracker.recordUsage} — cost and label are derived. */
160
- export type RecordUsageInput = Omit<TokenUsage, 'estimatedCostUsd' | 'label'> & { label?: string }
198
+ export type RecordUsageInput = Omit<TokenUsage, 'estimatedCostUsd' | 'label' | 'priced'> & { label?: string }
161
199
 
162
200
  /** Compose a stable, readable label from the attribution axes. */
163
- function composeLabel( m: { category: string; attribute: string; function: string; scope?: string } ): string {
201
+ function composeLabel( m: { category: LLMCallCategory; attribute: LLMCallAttribute; function: LLMCallFunction; scope?: string } ): string {
164
202
  const base = `${m.category}/${m.attribute}/${m.function}`
165
203
  return m.scope ? `${base}#${m.scope}` : base
166
204
  }
167
205
 
168
206
  export interface TokenTrackerConfig {
207
+ /**
208
+ * Host-supplied model prices (USD per 1M tokens), merged from the per-provider
209
+ * `prices` maps in `WillLLMConfig.providers`. These win over the built-in
210
+ * fallback table. Omitted ⇒ fallback only.
211
+ */
212
+ prices?: PriceTable
169
213
  /** Whether to emit cost events */
170
214
  emitCostEvents?: boolean
171
215
  /** Cost threshold for warning events */
@@ -186,6 +230,7 @@ export class TokenTracker implements SimulationEngine {
186
230
 
187
231
  private _emitCostEvents: boolean
188
232
  private _costWarningThreshold: number
233
+ private _prices: PriceTable | undefined
189
234
 
190
235
  // All recorded usage for the simulation run
191
236
  private _usageLog: TokenUsage[] = []
@@ -200,6 +245,11 @@ export class TokenTracker implements SimulationEngine {
200
245
  private _categoryTokens = new Map<string, { prompt: number; completion: number }>()
201
246
  private _functionCosts = new Map<string, number>()
202
247
  private _functionTokens = new Map<string, { prompt: number; completion: number }>()
248
+ // Per-provider spend. The axis a host actually reconciles against invoices —
249
+ // "which vendor did we pay?" is not answerable from the model id once routing
250
+ // can reach one model through several of them.
251
+ private _providerCosts = new Map<string, number>()
252
+ private _providerTokens = new Map<string, { prompt: number; completion: number }>()
203
253
 
204
254
  // Per-tick costs (for spike detection)
205
255
  private _tickCosts: number[] = []
@@ -219,6 +269,7 @@ export class TokenTracker implements SimulationEngine {
219
269
  constructor( config: TokenTrackerConfig = {} ){
220
270
  this._emitCostEvents = config.emitCostEvents ?? true
221
271
  this._costWarningThreshold = config.costWarningThresholdUsd ?? 0.05
272
+ this._prices = config.prices
222
273
  this._ledgerPath = ( config.writeLedger && config.willId )
223
274
  ? `./data/wills/${config.willId}/debug/token-report.jsonl`
224
275
  : null
@@ -231,18 +282,31 @@ export class TokenTracker implements SimulationEngine {
231
282
  * Called by LLMDirector.call after each completion (src/llm/index.ts).
232
283
  */
233
284
  recordUsage( usage: RecordUsageInput ): void {
234
- const pricing = resolvePricing( usage.model )
285
+ const pricing = resolvePricing( usage.model, this._prices )
235
286
  const cacheRead = usage.cacheReadTokens ?? 0
236
287
  const cacheWrite = usage.cacheWriteTokens ?? 0
237
- const costUsd =
238
- ( usage.promptTokens / 1_000_000 ) * pricing.input +
239
- ( usage.completionTokens / 1_000_000 ) * pricing.output +
240
- ( cacheRead / 1_000_000 ) * pricing.input * CACHE_READ_MULT +
241
- ( cacheWrite / 1_000_000 ) * pricing.input * CACHE_WRITE_MULT
288
+
289
+ // No price cost 0 and `priced: false`. Warn once per model id so an
290
+ // unconfigured provider is visible without flooding the log.
291
+ if( !pricing && !_unpricedWarned.has( usage.model ) ){
292
+ _unpricedWarned.add( usage.model )
293
+ logger.warn(
294
+ `[tokens] no price for "${usage.model}" — reporting cost 0 for it. ` +
295
+ `Supply one via llm.providers.<provider>.prices to get cost telemetry.`
296
+ )
297
+ }
298
+
299
+ const costUsd = pricing
300
+ ? ( usage.promptTokens / 1_000_000 ) * pricing.input +
301
+ ( usage.completionTokens / 1_000_000 ) * pricing.output +
302
+ ( cacheRead / 1_000_000 ) * pricing.input * CACHE_READ_MULT +
303
+ ( cacheWrite / 1_000_000 ) * pricing.input * CACHE_WRITE_MULT
304
+ : 0
242
305
 
243
306
  const full: TokenUsage = {
244
307
  ...usage,
245
308
  label: usage.label ?? composeLabel( usage ),
309
+ priced: pricing !== null,
246
310
  estimatedCostUsd: Math.round( costUsd * 1_000_000 ) / 1_000_000, // round to micro-dollars
247
311
  }
248
312
 
@@ -256,6 +320,9 @@ export class TokenTracker implements SimulationEngine {
256
320
  // Per-axis breakdowns — the repartition surface (category × function).
257
321
  this._accumulate( this._categoryCosts, this._categoryTokens, full.category, full )
258
322
  this._accumulate( this._functionCosts, this._functionTokens, full.function, full )
323
+ // Unattributed rather than guessed: a caller that did not say which
324
+ // provider served the call must not be silently folded into the default.
325
+ this._accumulate( this._providerCosts, this._providerTokens, full.provider ?? 'unattributed', full )
259
326
 
260
327
  // Complete attributed ledger record (every call, all axes + cost): notify
261
328
  // record listeners (the stem forwards them onto the transport) and mirror to
@@ -280,6 +347,7 @@ export class TokenTracker implements SimulationEngine {
280
347
  tick: full.tick,
281
348
  ts: new Date( wallClock() ).toISOString(), // determinism-ok: ledger timestamp is telemetry, never replay state
282
349
  model: full.model,
350
+ provider: full.provider,
283
351
  category: full.category,
284
352
  attribute: full.attribute,
285
353
  function: full.function,
@@ -291,6 +359,10 @@ export class TokenTracker implements SimulationEngine {
291
359
  cacheWriteTok: full.cacheWriteTokens ?? 0,
292
360
  estPromptTok: full.estPromptTokens,
293
361
  costUsd: full.estimatedCostUsd,
362
+ // Whether costUsd came from a real price. False ⇒ 0 because nothing
363
+ // priced this model, NOT because the call was free — a consumer summing
364
+ // spend must not fold unpriced calls in as zero.
365
+ priced: full.priced,
294
366
  latencyMs: full.latencyMs,
295
367
  }
296
368
 
@@ -347,31 +419,34 @@ export class TokenTracker implements SimulationEngine {
347
419
  if( this._tickCosts.length > this._maxTickCostSamples )
348
420
  this._tickCosts.shift()
349
421
 
350
- // Metrics
422
+ // Metrics — TOKENS ONLY (W8c).
423
+ //
424
+ // Token counts are a physical, deterministic fact of a call and belong in
425
+ // state. Dollars are the host's accounting over that fact: prices differ per
426
+ // account, change on a vendor's schedule, and are ~0 self-hosted. Nothing in
427
+ // cognition ever read them (the sole consumer was a console runner), yet
428
+ // while they sat in state a host editing its price table changed state bytes
429
+ // and broke replay-equivalence over a number that influenced nothing.
430
+ //
431
+ // Cost still reaches the host every call on the ledger path
432
+ // (`onRecord` → the stem's transport bridge), which is where it was already
433
+ // being consumed. See `totalCostUsd` / `costBreakdown()` for in-process reads.
351
434
  commands.metrics!.push(
352
435
  [ 'llm.prompt_tokens_total', this._totalPromptTokens ],
353
436
  [ 'llm.completion_tokens_total', this._totalCompletionTokens ],
354
- [ 'llm.cost_total_usd', this._totalCost ],
355
- [ 'llm.cost_this_tick_usd', tickCost ],
356
- [ 'llm.cost_avg_per_tick_usd', this._averageTickCost() ],
357
437
  [ 'llm.total_calls', this._usageLog.length ],
358
438
  )
359
439
 
360
- // Per-axis cost + token breakdown — the transparency surface for
361
- // "how much goes into conversation vs executive vs embedding" (by category)
362
- // and "decision vs ideation vs conversation vs planning…" (by function).
363
- for( const [ cat, cost ] of this._categoryCosts ){
364
- commands.metrics!.push([ `llm.cost.${cat}`, cost ])
365
- }
440
+ // Per-axis TOKEN breakdown — the transparency surface for "how much goes
441
+ // into conversation vs executive vs embedding". The matching cost
442
+ // breakdown is host-side now (W8c); `costBreakdown()` still exposes it
443
+ // in-process for anyone holding the tracker.
366
444
  for( const [ cat, tok ] of this._categoryTokens ){
367
445
  commands.metrics!.push(
368
446
  [ `llm.prompt_tokens.${cat}`, tok.prompt ],
369
447
  [ `llm.completion_tokens.${cat}`, tok.completion ],
370
448
  )
371
449
  }
372
- for( const [ fn, cost ] of this._functionCosts ){
373
- commands.metrics!.push([ `llm.cost.fn.${fn}`, cost ])
374
- }
375
450
 
376
451
  // Cost warning event
377
452
  if( tickCost > this._costWarningThreshold
@@ -427,6 +502,24 @@ export class TokenTracker implements SimulationEngine {
427
502
  return this._functionTokens
428
503
  }
429
504
 
505
+ /**
506
+ * Cost broken down by provider ('anthropic' | 'glm' | 'moonshot' | …), plus
507
+ * an `unattributed` bucket for usage recorded without one.
508
+ *
509
+ * This is the axis a host reconciles against vendor invoices. Calls whose
510
+ * model went unpriced contribute 0 here, so compare against
511
+ * `getUsageLog()`'s `priced` flag before treating a small number as a small
512
+ * bill.
513
+ */
514
+ get providerBreakdown(): ReadonlyMap<string, number> {
515
+ return this._providerCosts
516
+ }
517
+
518
+ /** Token counts (prompt + completion) broken down by provider. */
519
+ get providerTokenBreakdown(): ReadonlyMap<string, { prompt: number; completion: number }> {
520
+ return this._providerTokens
521
+ }
522
+
430
523
  /** Cost per call average */
431
524
  get averageCostPerCall(): number {
432
525
  if( this._usageLog.length === 0 ) return 0
@@ -461,6 +554,8 @@ export class TokenTracker implements SimulationEngine {
461
554
  this._categoryTokens.clear()
462
555
  this._functionCosts.clear()
463
556
  this._functionTokens.clear()
557
+ this._providerCosts.clear()
558
+ this._providerTokens.clear()
464
559
  this._tickCosts = []
465
560
  }
466
561
 
package/src/host/boot.ts CHANGED
@@ -12,9 +12,11 @@
12
12
  // WILL_NAME display name (default "Will")
13
13
  // WILL_IDENTITY persona prompt (default a minimal self)
14
14
  // WILL_TIER basic | standard | full (default standard)
15
- // WILL_LLM mock | anthropic | glm (default: auto — anthropic when
16
- // ANTHROPIC_API_KEY is set, glm when
17
- // ZAI_API_KEY is, else mock)
15
+ // WILL_LLM mock | any provider name (default: auto — whichever
16
+ // provider's own key is set,
17
+ // else the zero-key mock)
18
+ // WILL_LLM_MODEL concrete model id (REQUIRED for a live mind —
19
+ // the engine has no default)
18
20
  // WILL_TICK_MS ms per tick (default 1000)
19
21
  // WILL_SEED deterministic seed (testing) (default unseeded/wall-time)
20
22
  // WILL_PMA_PATH PMA artifact path (default ./.will/<name>.pma.json)
@@ -25,10 +27,10 @@
25
27
  import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
26
28
  import { dirname, resolve } from 'node:path'
27
29
  import { setLogger } from '#core/logger'
28
- import { Will, type CreateWillOptions } from '#sdk/will'
30
+ import { Will, detectProvider, type CreateWillOptions } from '#sdk/will'
29
31
  import type { PMASnapshot } from '#pma/index'
30
32
  import { connectMcpEffectors, type McpToolsSource } from '#root/mcp/effectors'
31
- import { anthropicWireHeaders, defaultBaseFor, defaultModelFor } from '#llm/index'
33
+ import { anthropicWireHeaders, defaultBaseFor, knownWireFor, providerKeyFromEnv, PROVIDER_KEY_ENV, type LLMProvider, type LLMWire } from '#llm/index'
32
34
 
33
35
  /**
34
36
  * Route every engine log line to stderr. For `will mcp`, stdout is the MCP
@@ -47,19 +49,51 @@ function slug( s: string ): string {
47
49
 
48
50
  /** The LLM mode the hosts will boot with: an explicit WILL_LLM, else whichever
49
51
  * provider's key is present, else the zero-key mock. */
50
- export function resolveLlmMode(): 'mock' | 'anthropic' | 'glm' {
51
- const explicit = process.env.WILL_LLM as 'mock' | 'anthropic' | 'glm' | undefined
52
- if( explicit ) return explicit
53
- if( process.env.ANTHROPIC_API_KEY ) return 'anthropic'
54
- if( process.env.ZAI_API_KEY ) return 'glm'
55
- return 'mock'
52
+ export function resolveLlmMode(): 'mock' | LLMProvider {
53
+ const explicit = process.env.WILL_LLM
54
+ if( explicit ) return explicit as 'mock' | LLMProvider
55
+ // The SDK's own detection, not a copy of it. Boot used to know only
56
+ // anthropic/glm, so once the provider set widened, a Will booting live on
57
+ // (say) Kimi had its preflight silently skipped — the one check that exists
58
+ // to stop a mind that boots, perceives, and never speaks.
59
+ return detectProvider()
56
60
  }
57
61
 
58
62
  /** The key for a live mode — the provider-agnostic override first, then the
59
63
  * provider's own env. */
60
- function resolveLlmKey( mode: 'anthropic' | 'glm'): string | undefined {
61
- return process.env.WILL_LLM_API_KEY
62
- ?? ( mode === 'glm' ? process.env.ZAI_API_KEY : process.env.ANTHROPIC_API_KEY )
64
+ function resolveLlmKey( mode: LLMProvider ): string | undefined {
65
+ return process.env.WILL_LLM_API_KEY ?? providerKeyFromEnv( mode )
66
+ }
67
+
68
+ /**
69
+ * The smallest real completion request, in a given wire's dialect.
70
+ *
71
+ * `null` means "this wire has no cheap ping here" — the caller raises the mind
72
+ * unchecked rather than inventing a request shape and reading its rejection as
73
+ * a broken provider.
74
+ */
75
+ export function pingRequest(
76
+ wire: LLMWire, base: string, model: string, key: string, provider: LLMProvider,
77
+ ): { url: string; headers: Record<string, string>; body: unknown } | null {
78
+ const messages = [ { role: 'user', content: 'ping' } ]
79
+ switch( wire ){
80
+ case 'anthropic':
81
+ return {
82
+ url: `${ base }/messages`,
83
+ headers: anthropicWireHeaders( provider, key ),
84
+ body: { model, max_tokens: 1, messages },
85
+ }
86
+ case 'openai':
87
+ return {
88
+ url: `${ base }/chat/completions`,
89
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ key }` },
90
+ body: { model, max_tokens: 1, messages },
91
+ }
92
+ // Gemini authenticates in the query string and nests its payload
93
+ // differently enough that a hand-rolled ping here would drift from the
94
+ // client that actually makes the calls. Better unchecked than wrong.
95
+ case 'google': return null
96
+ }
63
97
  }
64
98
 
65
99
  /**
@@ -83,7 +117,7 @@ async function preflightLLM( anatomy: string ): Promise<void> {
83
117
 
84
118
  const key = resolveLlmKey( mode )
85
119
  if( !key ){
86
- const expected = mode === 'glm' ? 'ZAI_API_KEY' : 'ANTHROPIC_API_KEY'
120
+ const expected = PROVIDER_KEY_ENV[ mode ] ?? 'WILL_LLM_API_KEY'
87
121
  console.error(`[will] WILL_LLM=${ mode } but no ${ expected } / WILL_LLM_API_KEY is set.`)
88
122
  console.error('[will] The Will would boot, perceive, and never speak. Set a key, or run keyless with WILL_LLM=mock.')
89
123
  process.exit( 2 )
@@ -91,16 +125,38 @@ async function preflightLLM( anatomy: string ): Promise<void> {
91
125
 
92
126
  // The ping validates key + balance + reachability, which is the failure class
93
127
  // that strands an operator. It uses the pinned model when there is one, so a
94
- // bad model id is caught too; otherwise the cheapest model stands in. GLM
95
- // speaks the same wire at Z.ai's compat endpoint, so one ping serves both.
96
- const base = process.env.WILL_LLM_BASE_URL ?? defaultBaseFor( mode )
128
+ // bad model id is caught too.
129
+ const base = process.env.WILL_LLM_BASE_URL ?? defaultBaseFor( mode )
130
+ if( !base ){
131
+ console.error(`[will] WILL_LLM=${ mode } has no known base URL — set WILL_LLM_BASE_URL.`)
132
+ process.exit( 2 )
133
+ }
134
+ // The ping needs *a* model id. WILL_LLM_MODEL is the one the Will will
135
+ // actually use, so preferring it means a bad id is caught here rather than at
136
+ // the first tick. Without it there is nothing honest to send: the engine no
137
+ // longer carries a default model, so say so plainly instead of guessing.
97
138
  const model = process.env.WILL_LLM_MODEL
98
- ?? ( mode === 'glm' ? defaultModelFor('glm') : 'claude-haiku-4-5-20251001')
139
+ if( !model ){
140
+ console.error(`[will] WILL_LLM_MODEL is not set. The engine has no default model —`)
141
+ console.error('[will] pick one for your provider (e.g. WILL_LLM_MODEL=claude-sonnet-4-5-20250929).')
142
+ process.exit( 2 )
143
+ }
144
+
145
+ // Ping in the provider's OWN dialect. This used to be hardcoded to the
146
+ // Anthropic wire, which was fine when boot knew only anthropic and glm. With
147
+ // the provider set widened it became a false negative: an OpenAI-wire
148
+ // provider answers 404 to `/messages`, preflight read that as "the LLM
149
+ // refused", and a perfectly working Will never got raised.
150
+ const ping = pingRequest( knownWireFor( mode ) ?? 'openai', base, model, key, mode )
151
+ if( !ping ){
152
+ console.error(`[will] no preflight ping for the ${ mode } wire — raising the mind unchecked.`)
153
+ return
154
+ }
99
155
  try {
100
- const res = await fetch(`${ base }/messages`, {
156
+ const res = await fetch( ping.url, {
101
157
  method: 'POST',
102
- headers: anthropicWireHeaders( mode, key ),
103
- body: JSON.stringify( { model, max_tokens: 1, messages: [ { role: 'user', content: 'ping' } ] } ),
158
+ headers: ping.headers,
159
+ body: JSON.stringify( ping.body ),
104
160
  signal: AbortSignal.timeout( 20_000 ),
105
161
  } )
106
162
  if( res.ok ) return
package/src/index.ts CHANGED
@@ -26,6 +26,41 @@ export {
26
26
 
27
27
  export { assembleMind, type WillConfig } from '#stem/mind'
28
28
 
29
+ // Model routing — which model serves which call.
30
+ //
31
+ // These were reachable only through `WillConfig.llm.router`'s structural type,
32
+ // which meant the reference implementation this ships *for* hosts to use could
33
+ // not be imported by one. A seam nobody can import is not a seam.
34
+ //
35
+ // The engine carries mechanism only: a router sees what kind of work a call is
36
+ // and how much the moment demands, never who is paying or what anything costs.
37
+ // The table itself is the host's.
38
+ export {
39
+ NULL_ROUTER,
40
+ TableRouter,
41
+ chainRouters,
42
+ isNullRouter,
43
+ type ModelRouter,
44
+ type ModelRoute,
45
+ type RoutingRule,
46
+ } from '#llm/routing'
47
+
48
+ // Provider vocabulary — the wire each known provider speaks and where it lives.
49
+ // A provider outside this table works identically once the host declares its
50
+ // `wire` and `baseUrl` on the `llm.providers` entry.
51
+ export {
52
+ KNOWN_PROVIDERS,
53
+ knownWireFor,
54
+ defaultBaseFor,
55
+ BACKGROUND_DEMAND,
56
+ ESCALATION_DEMAND,
57
+ type LLMProvider,
58
+ type KnownProvider,
59
+ type LLMWire,
60
+ type LLMCallMeta,
61
+ type ProviderCredential,
62
+ } from '#llm/index'
63
+
29
64
  // External transport — the unified bidirectional envelope channel between a Will
30
65
  // and its host peer (backend). Exposes the transport interface, every envelope
31
66
  // type, and the three implementations: