@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.
- package/README.md +87 -22
- package/dist/channels/discord.d.ts +1 -1
- package/dist/channels/whatsapp.d.ts +1 -1
- package/dist/cli.js +10823 -10454
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +562 -226
- package/dist/index.js.map +1 -1
- package/dist/mcp/effectors.d.ts +1 -1
- package/dist/{will-DAW0l-lY.d.ts → will-cS6k4uiJ.d.ts} +470 -84
- package/package.json +1 -1
- package/src/cognition/agency/engines/action.selector.ts +2 -1
- package/src/cognition/agency/engines/reafference.engine.ts +12 -2
- package/src/cognition/agency/reconcile.learning.ts +16 -2
- package/src/cognition/agency/schemas/repertoire.ts +12 -5
- package/src/cognition/config.mirror.entities.ts +1 -1
- package/src/cognition/faculties/executive.engine/engine.ts +136 -58
- package/src/cognition/faculties/executive.engine/facet.ts +10 -2
- package/src/cognition/faculties/executive.engine/prompt.factory.ts +2 -1
- package/src/cognition/index.ts +4 -0
- package/src/cognition/memory/vector.embedder.ts +9 -5
- package/src/cognition/utilities/token.tracker.ts +191 -96
- package/src/host/boot.ts +78 -22
- package/src/index.ts +35 -0
- package/src/llm/index.ts +397 -96
- package/src/llm/routing.ts +198 -0
- package/src/llm/summarizer.ts +5 -1
- package/src/runners/thin-shim.runner.ts +18 -6
- package/src/sdk/will.ts +82 -16
- package/src/stem/guards/identity.coherence.ts +17 -6
- package/src/stem/index.ts +3 -3
- package/src/stem/mind.ts +155 -24
- package/src/stem/policy/arbiter.ts +49 -14
- package/src/stem/policy/rule.table.ts +2 -2
- 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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
58
|
+
export type LLMProvider = KnownProvider | ( string & {} )
|
|
27
59
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
136
|
+
* The provider's own key from the environment, if it has a conventional one.
|
|
49
137
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
57
|
-
|
|
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
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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 === '
|
|
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
|
|
128
|
-
category:
|
|
129
|
-
/** The actor/subsystem doing the work
|
|
130
|
-
attribute:
|
|
131
|
-
/** The specific cognitive function
|
|
132
|
-
function:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
model
|
|
347
|
-
|
|
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(
|
|
699
|
+
headers: anthropicWireHeaders( ep.provider, ep.apiKey ),
|
|
393
700
|
body: JSON.stringify({
|
|
394
|
-
model:
|
|
395
|
-
max_tokens:
|
|
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 ${
|
|
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
|
-
() =>
|
|
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(
|
|
525
|
-
case 'anthropic': return this._callAnthropic( systemPrompt, userMessage, temperature )
|
|
526
|
-
case '
|
|
527
|
-
case '
|
|
528
|
-
|
|
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:
|
|
575
|
-
max_tokens:
|
|
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(
|
|
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(`${
|
|
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:
|
|
609
|
-
max_completion_tokens:
|
|
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 ${
|
|
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:
|
|
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/${
|
|
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':
|
|
962
|
+
'x-goog-api-key': ep.apiKey,
|
|
662
963
|
},
|
|
663
964
|
body: JSON.stringify( body ),
|
|
664
965
|
}
|