@stackstackstack/dsh-llm 0.1.5
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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +101 -0
- package/README.zh.md +101 -0
- package/lib/index.js +1407 -0
- package/lib/invariant.js +84 -0
- package/lib/types/adapter-failure.d.ts +14 -0
- package/lib/types/adapter-failure.js +105 -0
- package/lib/types/api-key.d.ts +28 -0
- package/lib/types/api-key.js +34 -0
- package/lib/types/assembler.d.ts +56 -0
- package/lib/types/assembler.js +148 -0
- package/lib/types/attribution.d.ts +47 -0
- package/lib/types/attribution.js +46 -0
- package/lib/types/brand.d.ts +48 -0
- package/lib/types/brand.js +44 -0
- package/lib/types/call-config.d.ts +62 -0
- package/lib/types/call-config.js +86 -0
- package/lib/types/content.d.ts +12 -0
- package/lib/types/content.js +14 -0
- package/lib/types/error.d.ts +73 -0
- package/lib/types/error.js +145 -0
- package/lib/types/index.d.ts +341 -0
- package/lib/types/index.js +730 -0
- package/lib/types/invariant.d.ts +13 -0
- package/lib/types/invariant.js +100 -0
- package/lib/types/message.d.ts +206 -0
- package/lib/types/message.js +100 -0
- package/lib/types/never.d.ts +16 -0
- package/lib/types/never.js +21 -0
- package/lib/types/retry-policy.d.ts +66 -0
- package/lib/types/retry-policy.js +123 -0
- package/lib/types/types.d.ts +349 -0
- package/lib/types/types.js +7 -0
- package/package.json +64 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1407 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { MAX_TIMER_DELAY_MS } from "@stackstackstack/dsh-timeout";
|
|
5
|
+
//#region lib/types/brand.js
|
|
6
|
+
/**
|
|
7
|
+
* dsh-llm's owned branded ids: tool-call correlation and provider request
|
|
8
|
+
* diagnostics.
|
|
9
|
+
*
|
|
10
|
+
* The `Branded<B>` primitive itself lives in `@stackstackstack/dsh-brand` (a
|
|
11
|
+
* zero-dependency type-only package) so every owner of a cross-boundary id can
|
|
12
|
+
* brand it without depending on dsh-llm; see that package's README for the
|
|
13
|
+
* nominal-typing policy.
|
|
14
|
+
*
|
|
15
|
+
* @module @stackstackstack/dsh-llm/brand
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Brand a message identifier.
|
|
19
|
+
* @param id - the opaque message identifier.
|
|
20
|
+
* @returns the same string, branded; no validation is performed.
|
|
21
|
+
*/
|
|
22
|
+
function MessageId(id) {
|
|
23
|
+
return id;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Brand a string as a {@link CallId}.
|
|
27
|
+
* @param id - the provider-issued (or synthesized) call id.
|
|
28
|
+
* @returns the same string, branded; no validation is performed.
|
|
29
|
+
*/
|
|
30
|
+
function CallId(id) {
|
|
31
|
+
return id;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Brand a provider-issued request identifier.
|
|
35
|
+
* @param id - the opaque provider-issued string.
|
|
36
|
+
* @returns the same string, branded; no validation is performed.
|
|
37
|
+
*/
|
|
38
|
+
function ProviderRequestId(id) {
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Brand an adapter-owned reasoning-effort identifier.
|
|
43
|
+
* @param id - the opaque identifier exposed by one model capability.
|
|
44
|
+
* @returns the same string, branded; no validation is performed.
|
|
45
|
+
*/
|
|
46
|
+
function ReasoningEffortId(id) {
|
|
47
|
+
return id;
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region lib/types/call-config.js
|
|
51
|
+
/**
|
|
52
|
+
* Conversation call configuration and freeze utilities. Provider routing,
|
|
53
|
+
* model, reasoning effort, and sampling values are request-header state that
|
|
54
|
+
* can affect cache reuse; request waterfalls replace them and the loop logs
|
|
55
|
+
* changed snapshots instead of allowing silent per-call drift.
|
|
56
|
+
* @module dsh-llm/call-config
|
|
57
|
+
*/
|
|
58
|
+
/** Process-local identities of request objects assembled by dsh-agent-loop. */
|
|
59
|
+
const AGENT_LOOP_REQUESTS = /* @__PURE__ */ new WeakSet();
|
|
60
|
+
/**
|
|
61
|
+
* Field-wise equality over {@link LlmCallConfig} — the comparison a caller
|
|
62
|
+
* runs to decide whether a proposed configuration is a real change (worth a
|
|
63
|
+
* logged header snapshot) or the held one restated.
|
|
64
|
+
* @param a - one configuration.
|
|
65
|
+
* @param b - the other.
|
|
66
|
+
* @returns whether every field (including the `stop` list, element-wise) matches.
|
|
67
|
+
*/
|
|
68
|
+
function callConfigEquals(a, b) {
|
|
69
|
+
if (a.provider !== b.provider || a.model !== b.model || a.reasoningEffort !== b.reasoningEffort || a.temperature !== b.temperature || a.maxTokens !== b.maxTokens) return false;
|
|
70
|
+
if (a.stop === void 0 || b.stop === void 0) return a.stop === b.stop;
|
|
71
|
+
return a.stop.length === b.stop.length && a.stop.every((s, i) => s === b.stop?.[i]);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Mark one exact request object as assembled by dsh-agent-loop.
|
|
75
|
+
* @param request - loop-owned request envelope before LLM dispatch.
|
|
76
|
+
* @returns the same request object marked as created by the process-local agent loop.
|
|
77
|
+
*/
|
|
78
|
+
function markAgentLoopRequest(request) {
|
|
79
|
+
AGENT_LOOP_REQUESTS.add(request);
|
|
80
|
+
return request;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Test whether the exact request object was assembled by dsh-agent-loop.
|
|
84
|
+
* @param request - request envelope observed at the LLM waterfall.
|
|
85
|
+
* @returns whether {@link markAgentLoopRequest} recorded this object.
|
|
86
|
+
*/
|
|
87
|
+
function isAgentLoopRequest(request) {
|
|
88
|
+
return AGENT_LOOP_REQUESTS.has(request);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Deep-freeze a value in place with an iterative traversal, guarding cycles,
|
|
92
|
+
* so later mutation throws without imposing a JavaScript call-stack depth cap.
|
|
93
|
+
* {@link AbortSignal} objects are deliberately skipped because they are the
|
|
94
|
+
* request's live cancellation channel and freezing them breaks abort.
|
|
95
|
+
* @param value - the value to freeze in place.
|
|
96
|
+
* @returns the same value, frozen.
|
|
97
|
+
*/
|
|
98
|
+
function deepFreeze(value) {
|
|
99
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
100
|
+
const pending = [{
|
|
101
|
+
kind: "visit",
|
|
102
|
+
node: value
|
|
103
|
+
}];
|
|
104
|
+
while (pending.length > 0) {
|
|
105
|
+
const task = pending.pop();
|
|
106
|
+
/* v8 ignore next -- the loop condition guarantees one pending task. */
|
|
107
|
+
if (task === void 0) continue;
|
|
108
|
+
if (task.kind === "property") {
|
|
109
|
+
pending.push({
|
|
110
|
+
kind: "visit",
|
|
111
|
+
node: task.source[task.key]
|
|
112
|
+
});
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const node = task.node;
|
|
116
|
+
if (node === null || typeof node !== "object") continue;
|
|
117
|
+
if (node instanceof AbortSignal) continue;
|
|
118
|
+
if (seen.has(node)) continue;
|
|
119
|
+
seen.add(node);
|
|
120
|
+
Object.freeze(node);
|
|
121
|
+
const keys = Object.keys(node);
|
|
122
|
+
for (let index = keys.length - 1; index >= 0; index--) {
|
|
123
|
+
const key = keys[index];
|
|
124
|
+
/* v8 ignore next -- the loop is bounded by the captured key count. */
|
|
125
|
+
if (key === void 0) continue;
|
|
126
|
+
pending.push({
|
|
127
|
+
kind: "property",
|
|
128
|
+
source: node,
|
|
129
|
+
key
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region lib/types/message.js
|
|
137
|
+
/** Message value types, identity, and immutable construction helpers. */
|
|
138
|
+
/**
|
|
139
|
+
* Bound for a `notice` summary. The account rides a collapsed transcript row
|
|
140
|
+
* and is committed to the durable log, while its inputs — task labels, goal
|
|
141
|
+
* objectives, tool arguments — are caller text with no length of their own.
|
|
142
|
+
*/
|
|
143
|
+
const CONTEXT_SUMMARY_MAX_CHARS = 120;
|
|
144
|
+
/**
|
|
145
|
+
* Bound one `notice` summary to {@link CONTEXT_SUMMARY_MAX_CHARS}.
|
|
146
|
+
* @param summary - the producer's one-line account, of any length.
|
|
147
|
+
* @returns the account, ellipsized when it exceeds the bound.
|
|
148
|
+
*/
|
|
149
|
+
function boundContextSummary(summary) {
|
|
150
|
+
return summary.length <= 120 ? summary : `${summary.slice(0, 119)}…`;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Detach and deep-freeze a message whose identity already exists.
|
|
154
|
+
* @param message - complete message, including its stable identity.
|
|
155
|
+
* @returns an immutable snapshot that preserves the identity.
|
|
156
|
+
*/
|
|
157
|
+
function freezeMessage(message) {
|
|
158
|
+
return deepFreeze(structuredClone(message));
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Create one identified message and freeze it before publication.
|
|
162
|
+
* @param input - complete role, content, and source for a new message.
|
|
163
|
+
* @returns an immutable message with a fresh stable identity.
|
|
164
|
+
*/
|
|
165
|
+
function createMessage(input) {
|
|
166
|
+
return freezeMessage({
|
|
167
|
+
...input,
|
|
168
|
+
id: MessageId(crypto.randomUUID())
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Create one identified user-role message and freeze it before publication.
|
|
173
|
+
* @param input - complete content and source for a new user message.
|
|
174
|
+
* @returns an immutable user message with a fresh stable identity.
|
|
175
|
+
*/
|
|
176
|
+
function createUserMessage(input) {
|
|
177
|
+
return createMessage({
|
|
178
|
+
...input,
|
|
179
|
+
role: "user"
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Create one identified model-produced assistant message and freeze it before publication.
|
|
184
|
+
* @param input - complete content plus the provider, model, and optional replay state for a new assistant message.
|
|
185
|
+
* @returns an immutable assistant message with fixed role/source tags and a fresh stable identity.
|
|
186
|
+
*/
|
|
187
|
+
function createAssistantMessage(input) {
|
|
188
|
+
return createMessage({
|
|
189
|
+
role: "assistant",
|
|
190
|
+
content: input.content,
|
|
191
|
+
source: {
|
|
192
|
+
kind: "model",
|
|
193
|
+
...input.source
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Create and freeze one identified tool-result message.
|
|
199
|
+
* @param input - call identity, raw result blocks, and outcome.
|
|
200
|
+
* @returns an immutable user-role tool-result message.
|
|
201
|
+
*/
|
|
202
|
+
function createToolResultMessage(input) {
|
|
203
|
+
return createUserMessage({
|
|
204
|
+
source: {
|
|
205
|
+
kind: "tool",
|
|
206
|
+
callId: input.callId
|
|
207
|
+
},
|
|
208
|
+
content: [{
|
|
209
|
+
type: "tool-result",
|
|
210
|
+
toolCallId: input.callId,
|
|
211
|
+
content: input.content,
|
|
212
|
+
isError: input.isError
|
|
213
|
+
}]
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Whether a stream chunk carries visible model output (the first-token
|
|
218
|
+
* boundary shared by client step timing and the whole-log sessionStats
|
|
219
|
+
* projection). Empty deltas (heartbeats, empty tool-call frames) do not count
|
|
220
|
+
* as a first token.
|
|
221
|
+
* @param chunk - the stream chunk to test.
|
|
222
|
+
* @returns true when the chunk contains a non-empty text/reasoning/tool delta.
|
|
223
|
+
*/
|
|
224
|
+
function isTokenDelta(chunk) {
|
|
225
|
+
switch (chunk.type) {
|
|
226
|
+
case "text-delta":
|
|
227
|
+
case "reasoning-delta": return chunk.text !== "";
|
|
228
|
+
case "tool-call-delta": return chunk.argumentsDelta !== "" || chunk.name !== void 0;
|
|
229
|
+
default: return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region lib/types/error.js
|
|
234
|
+
/**
|
|
235
|
+
* Harness error base with a stable machine-routable code and chained cause.
|
|
236
|
+
* Package errors extend it so tool results and replay can retain failure class.
|
|
237
|
+
* @module @stackstackstack/dsh-llm/error
|
|
238
|
+
*/
|
|
239
|
+
/**
|
|
240
|
+
* Base class for all harness errors. Carries a `code` (stable, programmatic —
|
|
241
|
+
* e.g. `NO_ADAPTER`, `INVALID_ARGS`, `INVARIANT`) distinct from the
|
|
242
|
+
* human-readable `message`, and supports `cause` chaining via the standard
|
|
243
|
+
* `ErrorOptions`. `name` defaults to the subclass constructor name.
|
|
244
|
+
*/
|
|
245
|
+
var HarnessError = class extends Error {
|
|
246
|
+
/** Stable machine-routable failure class (e.g. `RATE_LIMIT`); route on this, never by parsing `message`. */
|
|
247
|
+
code;
|
|
248
|
+
constructor(message, code, options) {
|
|
249
|
+
super(message, options);
|
|
250
|
+
this.code = code;
|
|
251
|
+
this.name = new.target.name;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
/** Canonical provider-neutral code for a model request rejected because its context window was exceeded. */
|
|
255
|
+
const CONTEXT_WINDOW_EXCEEDED_CODE = "CONTEXT_WINDOW_EXCEEDED";
|
|
256
|
+
/** Canonical provider-neutral code for an exhausted account quota or balance. */
|
|
257
|
+
const QUOTA_EXCEEDED_CODE = "QUOTA";
|
|
258
|
+
/**
|
|
259
|
+
* Canonical provider-neutral code for a response that completed normally but
|
|
260
|
+
* carried no content blocks at all. Providers occasionally emit a degenerate
|
|
261
|
+
* completion (a terminal stop with zero output); adapters classify it as this
|
|
262
|
+
* failure instead of yielding an empty assistant message, because an empty
|
|
263
|
+
* message silently ends the turn with nothing for the user or the loop to act
|
|
264
|
+
* on. The attempt produced nothing durable, so retry policy treats it as safe
|
|
265
|
+
* to repeat.
|
|
266
|
+
*/
|
|
267
|
+
const EMPTY_RESPONSE_CODE = "EMPTY_RESPONSE";
|
|
268
|
+
/**
|
|
269
|
+
* Canonical provider-neutral code for a credential that was supplied but
|
|
270
|
+
* cannot be used — malformed rather than absent. Distinct from
|
|
271
|
+
* `MISSING_CREDENTIAL` because the fix differs: correct the stored value
|
|
272
|
+
* rather than supply one. Deliberately outside the default retryable set —
|
|
273
|
+
* a malformed credential fails identically on every attempt.
|
|
274
|
+
*/
|
|
275
|
+
const INVALID_CREDENTIAL_CODE = "INVALID_CREDENTIAL";
|
|
276
|
+
/** Structured codes and plain phrases that explicitly name a context bound being exceeded. */
|
|
277
|
+
const STRUCTURED_CONTEXT_OVERFLOW = new RegExp(String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` + String.raw`(?:exceed(?:ed|s)?|overflow(?:ed)?|limit[\s_-]exceeded)(?:$|[^a-z0-9])`, "i");
|
|
278
|
+
/** Request-size wording that ties "too large" directly to model context capacity. */
|
|
279
|
+
const TOO_LARGE_FOR_CONTEXT = new RegExp(String.raw`\b(?:request|prompt|input|messages?)\s+(?:is\s+|are\s+)?` + String.raw`too\s+(?:large|long)\s+for\s+(?:(?:this|the)\s+)?` + String.raw`(?:model(?:'s)?\s+)?context(?:\s+window)?\b`, "i");
|
|
280
|
+
/** "Exceeds" wording is safe only when its object is explicitly the model context. */
|
|
281
|
+
const EXCEEDS_MODEL_CONTEXT = new RegExp(String.raw`\b(?:input|prompt|request|messages?)\b.{0,40}` + String.raw`\b(?:exceed(?:s|ed)?|overflows?|is\s+larger\s+than)\b.{0,40}` + String.raw`\b(?:the\s+)?(?:model(?:'s)?\s+)?context(?:\s+(?:length|window))?\b`, "i");
|
|
282
|
+
/**
|
|
283
|
+
* Recognize the context-overflow wording used by OpenAI-compatible providers
|
|
284
|
+
* and library adapters. Adapters pass all available provider code, type, and
|
|
285
|
+
* message text so both thrown and in-band delivery styles share one classifier.
|
|
286
|
+
* @param detail - provider error code/type/message text joined into one string.
|
|
287
|
+
* @returns true when the detail identifies a request exceeding the model context window.
|
|
288
|
+
*/
|
|
289
|
+
function isContextWindowExceededError(detail) {
|
|
290
|
+
return STRUCTURED_CONTEXT_OVERFLOW.test(detail) || /\b(?:maximum|max)(?:\s+(?:allowed|supported))?\s+context\s+(?:length|window)\b/i.test(detail) || TOO_LARGE_FOR_CONTEXT.test(detail) || /\b(?:input|prompt|request)\s+(?:is\s+)?too\s+(?:long|large)\s+for\s+(?:this|the)\s+model\b/i.test(detail) || EXCEEDS_MODEL_CONTEXT.test(detail);
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Recognize provider wording that identifies an exhausted account quota rather
|
|
294
|
+
* than a transient request-rate limit.
|
|
295
|
+
* @param detail - provider error code/type/message text joined into one string.
|
|
296
|
+
* @returns true only for terminal quota, balance, credit, budget, or usage-limit wording.
|
|
297
|
+
*/
|
|
298
|
+
function isQuotaExceededError(detail) {
|
|
299
|
+
return /\binsufficient[\s_-]+(?:quota|balance|credits?)\b/i.test(detail) || /\b(?:quota|usage[\s_-]+limit)[\s_-]+(?:exceeded|exhausted|reached)\b/i.test(detail) || /\bexceed(?:ed|s)?[\s_-]+(?:(?:your|the)[\s_-]+)?(?:current[\s_-]+)?quota\b/i.test(detail) || /\b(?:balance|credits?)[\s_-]+(?:exhausted|depleted)\b/i.test(detail) || /\bout[\s_-]+of[\s_-]+(?:credits?|budget)\b/i.test(detail);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Render a thrown value with its full `cause` chain and AggregateError
|
|
303
|
+
* members, so transport wrappers like undici's `TypeError: fetch failed`
|
|
304
|
+
* surface the underlying failure instead of masking it. Plain structured
|
|
305
|
+
* failures render their own data-backed `message`. Diagnostic-surface
|
|
306
|
+
* rendering only (messages, notices, logs) — never parse the result; route on
|
|
307
|
+
* {@link HarnessError.code}.
|
|
308
|
+
* @param value - the caught value (`unknown` in catch clauses).
|
|
309
|
+
* @returns the outermost message first, each cause appended with `: ` (skipped
|
|
310
|
+
* when it repeats the wrapper message verbatim), and AggregateError members
|
|
311
|
+
* bracketed and `; `-joined.
|
|
312
|
+
*/
|
|
313
|
+
function errorChain(value) {
|
|
314
|
+
const path = /* @__PURE__ */ new Set();
|
|
315
|
+
const render = (current) => {
|
|
316
|
+
if (path.has(current)) return "<circular cause>";
|
|
317
|
+
path.add(current);
|
|
318
|
+
try {
|
|
319
|
+
if (!(current instanceof Error)) {
|
|
320
|
+
if (typeof current === "object" && current !== null) {
|
|
321
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, "message");
|
|
322
|
+
if (descriptor !== void 0 && "value" in descriptor && typeof descriptor.value === "string") return descriptor.value;
|
|
323
|
+
}
|
|
324
|
+
return String(current);
|
|
325
|
+
}
|
|
326
|
+
const message = current.message === "" ? current.name : current.message;
|
|
327
|
+
const members = current instanceof AggregateError && current.errors.length > 0 ? ` [${current.errors.map(render).join("; ")}]` : "";
|
|
328
|
+
const causeText = current.cause === void 0 || current.cause === null ? "" : render(current.cause);
|
|
329
|
+
return `${message}${members}${causeText === "" || causeText === message ? "" : `: ${causeText}`}`;
|
|
330
|
+
} catch {
|
|
331
|
+
return "<unrenderable value>";
|
|
332
|
+
} finally {
|
|
333
|
+
path.delete(current);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
return render(value);
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Narrow an arbitrary thrown value to a HarnessError (for `instanceof` at runtime boundaries).
|
|
340
|
+
* @param value - the caught value (`unknown` in catch clauses).
|
|
341
|
+
* @returns true only for real instances; duck-typed or cross-realm errors do not narrow.
|
|
342
|
+
*/
|
|
343
|
+
function isHarnessError(value) {
|
|
344
|
+
return value instanceof HarnessError;
|
|
345
|
+
}
|
|
346
|
+
//#endregion
|
|
347
|
+
//#region lib/types/retry-policy.js
|
|
348
|
+
/**
|
|
349
|
+
* Provider-owned request-retry policy configuration and resolution.
|
|
350
|
+
*
|
|
351
|
+
* Adapters expose one resolved policy per registered provider route; the
|
|
352
|
+
* optional dsh-llm-retry plugin executes it on the agent's failed-step extension point.
|
|
353
|
+
*
|
|
354
|
+
* @module @stackstackstack/dsh-llm/retry-policy
|
|
355
|
+
*/
|
|
356
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
357
|
+
const DEFAULT_INITIAL_DELAY_MS = 500;
|
|
358
|
+
const DEFAULT_MAX_DELAY_MS = 1e4;
|
|
359
|
+
const DEFAULT_JITTER_RATIO = .1;
|
|
360
|
+
const DEFAULT_RETRYABLE_CODES = Object.freeze([
|
|
361
|
+
EMPTY_RESPONSE_CODE,
|
|
362
|
+
"RATE_LIMIT",
|
|
363
|
+
"SERVER",
|
|
364
|
+
"TIMEOUT",
|
|
365
|
+
"TRANSPORT"
|
|
366
|
+
]);
|
|
367
|
+
const backoffSchema = z.object({
|
|
368
|
+
initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
|
|
369
|
+
maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
|
|
370
|
+
jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO)
|
|
371
|
+
});
|
|
372
|
+
const normalPolicySchema = z.object({
|
|
373
|
+
mode: z.const("normal").required(),
|
|
374
|
+
maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES),
|
|
375
|
+
retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]),
|
|
376
|
+
backoff: backoffSchema
|
|
377
|
+
});
|
|
378
|
+
const alwaysPolicySchema = z.object({
|
|
379
|
+
mode: z.const("always").required(),
|
|
380
|
+
backoff: backoffSchema
|
|
381
|
+
});
|
|
382
|
+
/** Cordis schema embedded by each concrete provider configuration. */
|
|
383
|
+
const RetryPolicySchema = z.union([normalPolicySchema, alwaysPolicySchema]);
|
|
384
|
+
const NORMAL_POLICY_KEYS = new Set([
|
|
385
|
+
"mode",
|
|
386
|
+
"maxRetries",
|
|
387
|
+
"retryableCodes",
|
|
388
|
+
"backoff"
|
|
389
|
+
]);
|
|
390
|
+
const ALWAYS_POLICY_KEYS = new Set(["mode", "backoff"]);
|
|
391
|
+
const BACKOFF_KEYS = new Set([
|
|
392
|
+
"initialDelayMs",
|
|
393
|
+
"maxDelayMs",
|
|
394
|
+
"jitterRatio"
|
|
395
|
+
]);
|
|
396
|
+
function validateKeys(value, allowed, path) {
|
|
397
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`${path}: unknown key "${key}"`);
|
|
398
|
+
}
|
|
399
|
+
function resolveBackoff(config, path) {
|
|
400
|
+
if (config !== void 0) validateKeys(config, BACKOFF_KEYS, path);
|
|
401
|
+
const initialDelayMs = config?.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;
|
|
402
|
+
const maxDelayMs = config?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
403
|
+
const jitterRatio = config?.jitterRatio ?? DEFAULT_JITTER_RATIO;
|
|
404
|
+
if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) throw new Error(`${path}.initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
405
|
+
if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) throw new Error(`${path}.maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
406
|
+
if (initialDelayMs > maxDelayMs) throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`);
|
|
407
|
+
if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) throw new Error(`${path}.jitterRatio must be between 0 and 1`);
|
|
408
|
+
return Object.freeze({
|
|
409
|
+
initialDelayMs,
|
|
410
|
+
maxDelayMs,
|
|
411
|
+
jitterRatio
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Validate, default, and detach one provider-owned retry policy.
|
|
416
|
+
* @param config - optional provider configuration; omission selects normal defaults.
|
|
417
|
+
* @param path - diagnostic path naming the provider config that owns the value.
|
|
418
|
+
* @returns an immutable policy safe to capture in provider registration state.
|
|
419
|
+
*/
|
|
420
|
+
function resolveRetryPolicy(config, path) {
|
|
421
|
+
if (config === void 0) return Object.freeze({
|
|
422
|
+
mode: "normal",
|
|
423
|
+
maxRetries: DEFAULT_MAX_RETRIES,
|
|
424
|
+
retryableCodes: DEFAULT_RETRYABLE_CODES,
|
|
425
|
+
...resolveBackoff(void 0, `${path}.backoff`)
|
|
426
|
+
});
|
|
427
|
+
switch (config.mode) {
|
|
428
|
+
case "normal": {
|
|
429
|
+
validateKeys(config, NORMAL_POLICY_KEYS, path);
|
|
430
|
+
const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
431
|
+
const retryableCodes = config.retryableCodes ?? [...DEFAULT_RETRYABLE_CODES];
|
|
432
|
+
if (!Number.isSafeInteger(maxRetries) || maxRetries < 0) throw new Error(`${path}.maxRetries must be a non-negative safe integer`);
|
|
433
|
+
if (retryableCodes.length === 0) throw new Error(`${path}.retryableCodes must not be empty`);
|
|
434
|
+
if (retryableCodes.some((code) => typeof code !== "string" || code.length === 0)) throw new Error(`${path}.retryableCodes must contain only non-empty strings`);
|
|
435
|
+
if (new Set(retryableCodes).size !== retryableCodes.length) throw new Error(`${path}.retryableCodes must not contain duplicates`);
|
|
436
|
+
return Object.freeze({
|
|
437
|
+
mode: "normal",
|
|
438
|
+
maxRetries,
|
|
439
|
+
retryableCodes: Object.freeze([...retryableCodes]),
|
|
440
|
+
...resolveBackoff(config.backoff, `${path}.backoff`)
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
case "always":
|
|
444
|
+
validateKeys(config, ALWAYS_POLICY_KEYS, path);
|
|
445
|
+
return Object.freeze({
|
|
446
|
+
mode: "always",
|
|
447
|
+
...resolveBackoff(config.backoff, `${path}.backoff`)
|
|
448
|
+
});
|
|
449
|
+
default: throw new Error(`${path}.mode must be "normal" or "always"`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
//#endregion
|
|
453
|
+
//#region lib/types/adapter-failure.js
|
|
454
|
+
/**
|
|
455
|
+
* Normalization for values thrown by a final LLM adapter boundary.
|
|
456
|
+
*
|
|
457
|
+
* @module @stackstackstack/dsh-llm/adapter-failure
|
|
458
|
+
*/
|
|
459
|
+
/**
|
|
460
|
+
* Detach serializable provider facts from a value thrown by an adapter.
|
|
461
|
+
* @param value - arbitrary value thrown during adapter dispatch or iteration.
|
|
462
|
+
* @returns immutable provider-neutral facts suitable for a terminal finish chunk.
|
|
463
|
+
* @internal
|
|
464
|
+
*/
|
|
465
|
+
function normalizeLlmFailure(value) {
|
|
466
|
+
const error = value instanceof Error ? value : new HarnessError(thrownMessage(value), "UNKNOWN", { cause: value });
|
|
467
|
+
const carried = ownFailureSnapshot(error);
|
|
468
|
+
if (carried !== void 0 && carried.code === ownErrorCode(error)) return carried;
|
|
469
|
+
return Object.freeze({
|
|
470
|
+
message: errorMessage(error),
|
|
471
|
+
code: harnessErrorCode(error)
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
/** Render a non-Error throw without letting hostile coercion escape normalization. */
|
|
475
|
+
function thrownMessage(value) {
|
|
476
|
+
try {
|
|
477
|
+
const message = String(value);
|
|
478
|
+
return message.length > 0 ? message : "LLM adapter failed";
|
|
479
|
+
} catch (_hostileThrownValue) {
|
|
480
|
+
return "LLM adapter failed";
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/** Read a foreign error's own data-backed `code` without invoking accessors. */
|
|
484
|
+
function ownErrorCode(error) {
|
|
485
|
+
try {
|
|
486
|
+
const descriptor = Object.getOwnPropertyDescriptor(error, "code");
|
|
487
|
+
return descriptor !== void 0 && "value" in descriptor ? descriptor.value : void 0;
|
|
488
|
+
} catch (_sdkPropertyTrap) {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/** Snapshot an own data property without invoking an SDK-defined accessor. */
|
|
493
|
+
function ownFailureSnapshot(error) {
|
|
494
|
+
try {
|
|
495
|
+
const descriptor = Object.getOwnPropertyDescriptor(error, "failure");
|
|
496
|
+
return descriptor !== void 0 && "value" in descriptor ? failureSnapshot(descriptor.value) : void 0;
|
|
497
|
+
} catch (_sdkPropertyTrap) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/** Validate and detach an arbitrary serializable failure payload. */
|
|
502
|
+
function failureSnapshot(value) {
|
|
503
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
504
|
+
try {
|
|
505
|
+
const candidate = value;
|
|
506
|
+
const message = candidate.message;
|
|
507
|
+
const code = candidate.code;
|
|
508
|
+
const status = candidate.status;
|
|
509
|
+
const providerRetryAfterMs = candidate.providerRetryAfterMs;
|
|
510
|
+
const requestId = candidate.requestId;
|
|
511
|
+
if (typeof message !== "string" || message.length === 0 || typeof code !== "string" || code.length === 0 || status !== void 0 && (!Number.isInteger(status) || status < 100 || status > 599) || providerRetryAfterMs !== void 0 && (!Number.isFinite(providerRetryAfterMs) || providerRetryAfterMs <= 0) || requestId !== void 0 && (typeof requestId !== "string" || requestId.length === 0)) return void 0;
|
|
512
|
+
return Object.freeze({
|
|
513
|
+
message,
|
|
514
|
+
code,
|
|
515
|
+
...status === void 0 ? {} : { status },
|
|
516
|
+
...providerRetryAfterMs === void 0 ? {} : { providerRetryAfterMs },
|
|
517
|
+
...requestId === void 0 ? {} : { requestId }
|
|
518
|
+
});
|
|
519
|
+
} catch (_sdkFailureGetter) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/** Read an SDK error message without letting an accessor replace the primary failure. */
|
|
524
|
+
function errorMessage(error) {
|
|
525
|
+
try {
|
|
526
|
+
const message = error.message;
|
|
527
|
+
if (typeof message === "string" && message.length > 0) return message;
|
|
528
|
+
} catch (_sdkMessageGetter) {}
|
|
529
|
+
return "LLM adapter failed";
|
|
530
|
+
}
|
|
531
|
+
/** Trust only Harness-owned codes; third-party SDK codes are not our taxonomy. */
|
|
532
|
+
function harnessErrorCode(error) {
|
|
533
|
+
return error instanceof HarnessError ? error.code : "UNKNOWN";
|
|
534
|
+
}
|
|
535
|
+
//#endregion
|
|
536
|
+
//#region lib/types/api-key.js
|
|
537
|
+
/**
|
|
538
|
+
* The one definition of a well-formed provider API key, shared by every
|
|
539
|
+
* adapter that puts one in an HTTP header.
|
|
540
|
+
* @module @stackstackstack/dsh-llm/api-key
|
|
541
|
+
*/
|
|
542
|
+
/**
|
|
543
|
+
* Characters an HTTP header value carries verbatim and every known provider
|
|
544
|
+
* key uses: printable ASCII, space excluded. A key outside this set cannot
|
|
545
|
+
* reach any provider — `fetch` refuses to build the header — so this is a
|
|
546
|
+
* transport invariant rather than one provider's policy. Latin-1 is excluded
|
|
547
|
+
* deliberately: a header could carry it, but no provider issues it, and
|
|
548
|
+
* admitting it trades a local explained refusal for an opaque 401.
|
|
549
|
+
*/
|
|
550
|
+
const LEGAL_API_KEY = /^[\x21-\x7E]+$/;
|
|
551
|
+
/**
|
|
552
|
+
* Judge one *supplied* API key, trimming surrounding whitespace first.
|
|
553
|
+
*
|
|
554
|
+
* Trimming is silent because a padded key has one unambiguous reading; every
|
|
555
|
+
* other defect is reported. Absence is a configuration state this function
|
|
556
|
+
* never sees — a profile naming no credential authenticates through the
|
|
557
|
+
* provider's own ambient discovery or OAuth — so callers decide whether a
|
|
558
|
+
* value was supplied before asking.
|
|
559
|
+
* @param raw - the key exactly as configured, stored, or typed.
|
|
560
|
+
* @returns the trimmed key, or why it cannot be used.
|
|
561
|
+
*/
|
|
562
|
+
function normalizeApiKey(raw) {
|
|
563
|
+
const value = raw.trim();
|
|
564
|
+
if (value.length === 0) return {
|
|
565
|
+
ok: false,
|
|
566
|
+
reason: "empty"
|
|
567
|
+
};
|
|
568
|
+
if (!LEGAL_API_KEY.test(value)) return {
|
|
569
|
+
ok: false,
|
|
570
|
+
reason: "illegalCharacters"
|
|
571
|
+
};
|
|
572
|
+
return {
|
|
573
|
+
ok: true,
|
|
574
|
+
value
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
//#endregion
|
|
578
|
+
//#region lib/types/attribution.js
|
|
579
|
+
/**
|
|
580
|
+
* Centralize the non-secret product identity every provider request sends as `User-Agent`, keeping
|
|
581
|
+
* adapters from drifting. See
|
|
582
|
+
* `.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md`.
|
|
583
|
+
*
|
|
584
|
+
* App-attribution vocabulary for provider requests.
|
|
585
|
+
* @module @stackstackstack/dsh-llm/attribution
|
|
586
|
+
*/
|
|
587
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
588
|
+
/**
|
|
589
|
+
* The harness's own identity: the default every adapter sends. Deployments
|
|
590
|
+
* that need a white-label identity pass their own {@link AppIdentity} to
|
|
591
|
+
* {@link attributionHeaders} — omission falls back to this default; nothing
|
|
592
|
+
* can suppress attribution entirely.
|
|
593
|
+
*/
|
|
594
|
+
const APP_IDENTITY = {
|
|
595
|
+
product: "deepseek-harness",
|
|
596
|
+
version,
|
|
597
|
+
url: "https://github.com/deepseek-ai/deepseek-harness"
|
|
598
|
+
};
|
|
599
|
+
/**
|
|
600
|
+
* The standard `User-Agent` value: `product/version (+url)`. The
|
|
601
|
+
* parenthesized `+url` comment is the conventional self-identification form
|
|
602
|
+
* (RFC 9110 §10.1.5 product + comment syntax).
|
|
603
|
+
* @param identity - the identity to render; defaults to {@link APP_IDENTITY}.
|
|
604
|
+
* @returns the ready-to-send header value.
|
|
605
|
+
*/
|
|
606
|
+
function userAgent(identity = APP_IDENTITY) {
|
|
607
|
+
return `${identity.product}/${identity.version} (+${identity.url})`;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Build the attribution headers an adapter must send on every provider
|
|
611
|
+
* request. Header names are lowercase (HTTP field names are case-insensitive
|
|
612
|
+
* on the wire).
|
|
613
|
+
* @param identity - the identity to send; defaults to {@link APP_IDENTITY} — omission cannot suppress attribution.
|
|
614
|
+
* @returns headers to merge into the provider request (currently just `user-agent`).
|
|
615
|
+
*/
|
|
616
|
+
function attributionHeaders(identity = APP_IDENTITY) {
|
|
617
|
+
return { "user-agent": userAgent(identity) };
|
|
618
|
+
}
|
|
619
|
+
//#endregion
|
|
620
|
+
//#region lib/types/never.js
|
|
621
|
+
/**
|
|
622
|
+
* Exhaustiveness helper for closed core unions. Use {@link assertNever} at the default branch so a
|
|
623
|
+
* new variant fails compilation at every required handler. Do not use it for declaration-merged
|
|
624
|
+
* unions such as session events or content blocks: handle known variants and explicitly fall
|
|
625
|
+
* through because plugins may add valid unknown cases.
|
|
626
|
+
* @module @stackstackstack/dsh-llm/never
|
|
627
|
+
*/
|
|
628
|
+
/**
|
|
629
|
+
* Mark an unreachable closed-union branch. A newly unhandled typed variant fails at the call site;
|
|
630
|
+
* a value that escaped its type throws with diagnostics at runtime.
|
|
631
|
+
* @param value - the impossible value; typed `never` so an unhandled variant fails compilation at the call site.
|
|
632
|
+
* @param context - optional label (e.g. the switch site) prefixed into the throw message.
|
|
633
|
+
* @returns never — it always throws, with the offending value JSON-rendered in the message.
|
|
634
|
+
*/
|
|
635
|
+
function assertNever(value, context) {
|
|
636
|
+
const rendered = JSON.stringify(value) ?? String(value);
|
|
637
|
+
throw new Error(`unreachable variant${context ? ` in ${context}` : ""}: ${rendered}`);
|
|
638
|
+
}
|
|
639
|
+
//#endregion
|
|
640
|
+
//#region lib/types/content.js
|
|
641
|
+
/** Content-block structure helpers. @module @stackstackstack/dsh-llm/content */
|
|
642
|
+
/**
|
|
643
|
+
* True when typed model content contains an image block, walking nested
|
|
644
|
+
* tool-result content. This is the one recursive image walk shared by every
|
|
645
|
+
* image policy (capability gating, text-only serialization, compaction
|
|
646
|
+
* survey), so a consumer cannot silently diverge on nesting depth.
|
|
647
|
+
* @param content - typed model content blocks.
|
|
648
|
+
* @returns whether any nested block is an image.
|
|
649
|
+
*/
|
|
650
|
+
function contentHasImage(content) {
|
|
651
|
+
return content.some((block) => block.type === "image" || block.type === "tool-result" && contentHasImage(block.content));
|
|
652
|
+
}
|
|
653
|
+
//#endregion
|
|
654
|
+
//#region lib/types/assembler.js
|
|
655
|
+
/**
|
|
656
|
+
* Incremental chunk-to-message assembler. This is the single canonical assembly
|
|
657
|
+
* algorithm used by the agent loop to build an assistant message from a chunk
|
|
658
|
+
* stream while logging the raw chunks for replay fidelity.
|
|
659
|
+
*
|
|
660
|
+
* @module @stackstackstack/dsh-llm/assembler
|
|
661
|
+
*/
|
|
662
|
+
/**
|
|
663
|
+
* Incrementally assembles raw {@link StreamChunk}s into complete
|
|
664
|
+
* {@link ContentBlock}s and a final assistant {@link Message}.
|
|
665
|
+
*
|
|
666
|
+
* The agent loop feeds it while logging raw chunks for replay fidelity, then
|
|
667
|
+
* reads `blocks()` / `message()` / `usage` / `finish` once the stream ends.
|
|
668
|
+
*
|
|
669
|
+
* Tolerant of delta-only protocols (no block-start/end); deltas arriving for
|
|
670
|
+
* an index already closed by `block-end` are ignored (malformed stream) so a
|
|
671
|
+
* misbehaving adapter cannot grow memory or corrupt a completed block.
|
|
672
|
+
*/
|
|
673
|
+
var BlockAssembler = class {
|
|
674
|
+
partials = /* @__PURE__ */ new Map();
|
|
675
|
+
order = [];
|
|
676
|
+
_usage;
|
|
677
|
+
_finish;
|
|
678
|
+
_replayState = void 0;
|
|
679
|
+
/**
|
|
680
|
+
* Feed one chunk into the assembly state.
|
|
681
|
+
* @param chunk - the next raw chunk, in stream order.
|
|
682
|
+
*/
|
|
683
|
+
push(chunk) {
|
|
684
|
+
switch (chunk.type) {
|
|
685
|
+
case "block-start":
|
|
686
|
+
if (!this.partials.has(chunk.index)) {
|
|
687
|
+
this.order.push(chunk.index);
|
|
688
|
+
this.partials.set(chunk.index, {
|
|
689
|
+
blockType: chunk.blockType,
|
|
690
|
+
text: "",
|
|
691
|
+
toolCallArguments: ""
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
return;
|
|
695
|
+
case "text-delta":
|
|
696
|
+
case "reasoning-delta": {
|
|
697
|
+
const partial = this.ensure(chunk.index, chunk.type === "text-delta" ? "text" : "reasoning");
|
|
698
|
+
if (partial.block) return;
|
|
699
|
+
partial.text += chunk.text;
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
case "tool-call-delta": {
|
|
703
|
+
const partial = this.ensure(chunk.index, "tool-call");
|
|
704
|
+
if (partial.block) return;
|
|
705
|
+
partial.toolCallId = chunk.id;
|
|
706
|
+
if (chunk.name) partial.toolCallName = chunk.name;
|
|
707
|
+
partial.toolCallArguments += chunk.argumentsDelta;
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
case "block-end": {
|
|
711
|
+
const partial = this.ensure(chunk.index, chunk.block.type);
|
|
712
|
+
if (partial.block) return;
|
|
713
|
+
partial.block = chunk.block;
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
case "usage":
|
|
717
|
+
this._usage = chunk.usage;
|
|
718
|
+
return;
|
|
719
|
+
case "finish":
|
|
720
|
+
this._finish = chunk.reason;
|
|
721
|
+
this._replayState = chunk.replayState;
|
|
722
|
+
return;
|
|
723
|
+
default: return assertNever(chunk, "BlockAssembler.push");
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
ensure(index, blockType) {
|
|
727
|
+
let partial = this.partials.get(index);
|
|
728
|
+
if (!partial) {
|
|
729
|
+
partial = {
|
|
730
|
+
blockType,
|
|
731
|
+
text: "",
|
|
732
|
+
toolCallArguments: ""
|
|
733
|
+
};
|
|
734
|
+
this.partials.set(index, partial);
|
|
735
|
+
this.order.push(index);
|
|
736
|
+
}
|
|
737
|
+
return partial;
|
|
738
|
+
}
|
|
739
|
+
assemble(partial, index) {
|
|
740
|
+
if (partial.block) return partial.block;
|
|
741
|
+
switch (partial.blockType) {
|
|
742
|
+
case "text": return {
|
|
743
|
+
type: "text",
|
|
744
|
+
text: partial.text
|
|
745
|
+
};
|
|
746
|
+
case "reasoning": return {
|
|
747
|
+
type: "reasoning",
|
|
748
|
+
text: partial.text
|
|
749
|
+
};
|
|
750
|
+
case "tool-call": return {
|
|
751
|
+
type: "tool-call",
|
|
752
|
+
id: partial.toolCallId ?? CallId(`call-${index}`),
|
|
753
|
+
name: partial.toolCallName ?? "",
|
|
754
|
+
arguments: partial.toolCallArguments
|
|
755
|
+
};
|
|
756
|
+
default: throw new Error(`cannot assemble incomplete block of type "${partial.blockType}"`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/** Invariant accessor: every index in `order` has a partial. */
|
|
760
|
+
mustGet(index) {
|
|
761
|
+
const partial = this.partials.get(index);
|
|
762
|
+
if (!partial) throw new Error(`BlockAssembler invariant violated: no partial for index ${index}`);
|
|
763
|
+
return partial;
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Assemble all blocks seen so far, in stream order.
|
|
767
|
+
* @returns one block per seen index, except that max-token truncation drops
|
|
768
|
+
* tool calls that cannot be executed safely; an open block assembles from
|
|
769
|
+
* its accumulated deltas (an unknown block type never closed by `block-end` throws).
|
|
770
|
+
*/
|
|
771
|
+
blocks() {
|
|
772
|
+
const blocks = this.order.map((index) => this.assemble(this.mustGet(index), index));
|
|
773
|
+
return this.finish.kind === "max-tokens" ? blocks.filter((block) => block.type !== "tool-call") : blocks;
|
|
774
|
+
}
|
|
775
|
+
/** Usage from the `usage` chunk; undefined until one arrives. */
|
|
776
|
+
get usage() {
|
|
777
|
+
return this._usage;
|
|
778
|
+
}
|
|
779
|
+
/** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */
|
|
780
|
+
get finish() {
|
|
781
|
+
return this._finish ?? { kind: "stop" };
|
|
782
|
+
}
|
|
783
|
+
/** Adapter-private replay state from the terminal finish chunk, if any. */
|
|
784
|
+
get replayState() {
|
|
785
|
+
return this._replayState;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* The assembled assistant message.
|
|
789
|
+
* @param source - producer attribution for the assembled message.
|
|
790
|
+
* @returns a frozen assistant-role message over `blocks()` (same open-block assembly rules).
|
|
791
|
+
*/
|
|
792
|
+
message(source = {
|
|
793
|
+
kind: "plugin",
|
|
794
|
+
plugin: "dsh-llm/assembler"
|
|
795
|
+
}) {
|
|
796
|
+
return createMessage({
|
|
797
|
+
role: "assistant",
|
|
798
|
+
content: this.blocks(),
|
|
799
|
+
source
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
//#endregion
|
|
804
|
+
//#region lib/types/index.js
|
|
805
|
+
/**
|
|
806
|
+
* LLM service: adapter registry with a waterfall-interceptable streaming call
|
|
807
|
+
* API. Exports the `LlmRuntime` default, the abstract `LlmAdapter` for
|
|
808
|
+
* provider backends, and `BlockAssembler` for chunk assembly.
|
|
809
|
+
*
|
|
810
|
+
* @module @stackstackstack/dsh-llm
|
|
811
|
+
*/
|
|
812
|
+
/**
|
|
813
|
+
* Typed error for LLM-related failures. Extends {@link HarnessError}, so the
|
|
814
|
+
* `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy.
|
|
815
|
+
*/
|
|
816
|
+
var LlmError = class extends HarnessError {
|
|
817
|
+
/** Serializable facts retained beside this live Error. */
|
|
818
|
+
failure;
|
|
819
|
+
/**
|
|
820
|
+
* @param message - non-empty human-readable failure summary.
|
|
821
|
+
* @param code - non-empty stable provider-neutral machine code.
|
|
822
|
+
* @param options - optional cause and validated serializable provider facts.
|
|
823
|
+
*/
|
|
824
|
+
constructor(message, code, options) {
|
|
825
|
+
if (typeof message !== "string" || message.length === 0) throw new Error("LlmError message must be a non-empty string");
|
|
826
|
+
if (typeof code !== "string" || code.length === 0) throw new Error("LlmError code must be a non-empty string");
|
|
827
|
+
if (options?.status !== void 0 && (!Number.isInteger(options.status) || options.status < 100 || options.status > 599)) throw new Error("LlmError status must be an integer from 100 through 599");
|
|
828
|
+
if (options?.providerRetryAfterMs !== void 0 && (!Number.isFinite(options.providerRetryAfterMs) || options.providerRetryAfterMs <= 0)) throw new Error("LlmError providerRetryAfterMs must be a positive finite number");
|
|
829
|
+
if (options?.requestId !== void 0 && (typeof options.requestId !== "string" || options.requestId.length === 0)) throw new Error("LlmError requestId must be a non-empty string");
|
|
830
|
+
super(message, code, options);
|
|
831
|
+
this.name = "LlmError";
|
|
832
|
+
this.failure = Object.freeze({
|
|
833
|
+
message,
|
|
834
|
+
code,
|
|
835
|
+
...options?.status === void 0 ? {} : { status: options.status },
|
|
836
|
+
...options?.providerRetryAfterMs === void 0 ? {} : { providerRetryAfterMs: options.providerRetryAfterMs },
|
|
837
|
+
...options?.requestId === void 0 ? {} : { requestId: options.requestId }
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
/**
|
|
842
|
+
* Accept one supplied credential, or refuse it as unusable.
|
|
843
|
+
*
|
|
844
|
+
* A stored key arrives from the credentials seam, a `.env` line, or a shell
|
|
845
|
+
* export, all of which pick up surrounding whitespace, so trimming is silent.
|
|
846
|
+
* Anything else fails here rather than inside `fetch`, whose ByteString
|
|
847
|
+
* refusal names a UTF-16 code point instead of the setting to change. The key
|
|
848
|
+
* never enters the message: `ref` names where to fix it, and echoing any part
|
|
849
|
+
* of a secret into a log or a UI is the failure this diagnosis avoids.
|
|
850
|
+
*
|
|
851
|
+
* Lives beside {@link LlmError} rather than in `./api-key.ts` so the predicate
|
|
852
|
+
* module stays dependency-free; both adapters share this one diagnosis instead
|
|
853
|
+
* of keeping near-identical local copies.
|
|
854
|
+
* @param raw - the credential exactly as supplied.
|
|
855
|
+
* @param pkg - the refusing package name, prefixed to the diagnostic.
|
|
856
|
+
* @param ref - the credential reference the value resolved through.
|
|
857
|
+
* @returns the trimmed, usable key.
|
|
858
|
+
*/
|
|
859
|
+
function assertUsableApiKey(raw, pkg, ref) {
|
|
860
|
+
const checked = normalizeApiKey(raw);
|
|
861
|
+
if (checked.ok) return checked.value;
|
|
862
|
+
throw new LlmError(checked.reason === "empty" ? `${pkg}: the API key resolved from ${ref} is blank; set ${ref} to the raw key (the web Models page writes it) or export it in the launching environment` : `${pkg}: the API key resolved from ${ref} contains characters no HTTP header can carry; set ${ref} to the raw key alone (the web Models page writes it)`, INVALID_CREDENTIAL_CODE);
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Provider-wire adapter for the harness message and stream vocabulary. Register implementations
|
|
866
|
+
* with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include
|
|
867
|
+
* `attributionHeaders()`; prove the headers are added in the wire request or library header hook. The direct-fetch
|
|
868
|
+
* DeepSeek and library-backed pi-ai adapters meet this contract through different internals.
|
|
869
|
+
*/
|
|
870
|
+
var LlmAdapter = class {
|
|
871
|
+
/**
|
|
872
|
+
* Describe one provider route owned by this adapter.
|
|
873
|
+
* @param provider - a route passed to `registerAdapter()` for this instance.
|
|
874
|
+
* @returns detached display metadata whose id must equal `provider`.
|
|
875
|
+
*/
|
|
876
|
+
providerInfo(provider) {
|
|
877
|
+
return {
|
|
878
|
+
id: provider,
|
|
879
|
+
name: provider
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Return the provider-owned retry policy captured with this route.
|
|
884
|
+
* @param _provider - a route passed to `registerAdapter()` for this instance.
|
|
885
|
+
* @returns a resolved policy, or `undefined` to use the normal defaults.
|
|
886
|
+
*/
|
|
887
|
+
providerRetryPolicy(_provider) {}
|
|
888
|
+
/**
|
|
889
|
+
* List models this adapter can currently advertise for one owned provider.
|
|
890
|
+
* The result is advisory: an adapter may accept unlisted model ids, and
|
|
891
|
+
* consumers must not turn absence into request rejection.
|
|
892
|
+
* @param _provider - one provider route owned by this adapter.
|
|
893
|
+
* @returns discoverable models in adapter-preferred order.
|
|
894
|
+
*/
|
|
895
|
+
listModels(_provider) {
|
|
896
|
+
return Promise.resolve([]);
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Resolve all metadata available for one exact model. This query is
|
|
900
|
+
* independent of the advisory catalog and does not validate request routing.
|
|
901
|
+
* @param provider - one provider route owned by this adapter.
|
|
902
|
+
* @param model - exact model id passed to {@link GenerateOptions.model}.
|
|
903
|
+
* @param _signal - cancellation for this exact-model lookup; asynchronous
|
|
904
|
+
* implementations must settle promptly after it aborts.
|
|
905
|
+
* @returns provider/model identity plus any context, call-default, and reasoning metadata.
|
|
906
|
+
*/
|
|
907
|
+
resolveModel(provider, model, _signal) {
|
|
908
|
+
return Promise.resolve({
|
|
909
|
+
provider,
|
|
910
|
+
id: model,
|
|
911
|
+
name: model
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
/**
|
|
916
|
+
* The abstract `llm` service: an adapter registry plus a streaming model-call
|
|
917
|
+
* API, interceptable via the `llm/stream` waterfall.
|
|
918
|
+
*/
|
|
919
|
+
var LlmRuntime = class extends Service {
|
|
920
|
+
adapters = /* @__PURE__ */ new Map();
|
|
921
|
+
directory = /* @__PURE__ */ new Map();
|
|
922
|
+
discoveries = /* @__PURE__ */ new Map();
|
|
923
|
+
constructor(ctx) {
|
|
924
|
+
super(ctx, "llm");
|
|
925
|
+
}
|
|
926
|
+
/** Notify topology observers without letting one broken listener veto the commit. */
|
|
927
|
+
emitAdaptersUpdated() {
|
|
928
|
+
let invariantFailure;
|
|
929
|
+
for (const listener of this.ctx.events.dispatch("emit", ["llm/adapters-updated"])) try {
|
|
930
|
+
const returned = listener();
|
|
931
|
+
if (returned != null && typeof returned.then === "function") Promise.resolve(returned).then(void 0, (error) => {
|
|
932
|
+
this.warnAdaptersListenerFailure(error);
|
|
933
|
+
});
|
|
934
|
+
} catch (error) {
|
|
935
|
+
if (error?.code === "INVARIANT") {
|
|
936
|
+
invariantFailure ??= error;
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
this.warnAdaptersListenerFailure(error);
|
|
940
|
+
}
|
|
941
|
+
if (invariantFailure !== void 0) throw invariantFailure;
|
|
942
|
+
}
|
|
943
|
+
/** Contained-listener diagnostic shared by the sync and async failure paths. */
|
|
944
|
+
warnAdaptersListenerFailure(error) {
|
|
945
|
+
this.ctx.logger.warn("llm: an llm/adapters-updated listener failed");
|
|
946
|
+
this.ctx.logger.warn(error);
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Register an adapter for the given provider routes. Throws `LlmError` with code
|
|
950
|
+
* `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).
|
|
951
|
+
* Disposed with the fiber.
|
|
952
|
+
* @param providers - every provider route this adapter should serve.
|
|
953
|
+
* @param adapter - the adapter that streams calls for those providers.
|
|
954
|
+
* @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.
|
|
955
|
+
*/
|
|
956
|
+
registerAdapter(providers, adapter) {
|
|
957
|
+
const owned = /* @__PURE__ */ new Set();
|
|
958
|
+
let released = false;
|
|
959
|
+
const dispose = this.ctx.effect(function* () {
|
|
960
|
+
if (providers.length === 0) throw new LlmError("an adapter must register at least one provider", "INVALID_ADAPTER");
|
|
961
|
+
this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned));
|
|
962
|
+
yield () => {
|
|
963
|
+
released = true;
|
|
964
|
+
for (const provider of owned) this.adapters.delete(provider);
|
|
965
|
+
owned.clear();
|
|
966
|
+
this.emitAdaptersUpdated();
|
|
967
|
+
};
|
|
968
|
+
}.bind(this), "llm.registerAdapter()");
|
|
969
|
+
const handle = (() => void dispose());
|
|
970
|
+
handle.replace = (next) => {
|
|
971
|
+
if (released) throw new LlmError("a disposed adapter registration cannot replace its routes", "REGISTRATION_DISPOSED");
|
|
972
|
+
this.commitRoutes(owned, this.prepareRoutes(next, adapter, owned));
|
|
973
|
+
};
|
|
974
|
+
return handle;
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Validate one candidate route set for `adapter`, treating routes this
|
|
978
|
+
* registration already holds as available. Nothing is mutated: a rejected
|
|
979
|
+
* candidate leaves the registry exactly as it was.
|
|
980
|
+
*/
|
|
981
|
+
prepareRoutes(providers, adapter, owned) {
|
|
982
|
+
const unique = /* @__PURE__ */ new Set();
|
|
983
|
+
const registrations = [];
|
|
984
|
+
for (const provider of providers) {
|
|
985
|
+
if (provider.length === 0) throw new LlmError("adapter provider names must be non-empty", "INVALID_ADAPTER");
|
|
986
|
+
if (unique.has(provider) || this.adapters.has(provider) && !owned.has(provider)) throw new LlmError(`an adapter for provider "${provider}" is already registered`, "DUPLICATE_ADAPTER");
|
|
987
|
+
const info = adapter.providerInfo(provider);
|
|
988
|
+
if (typeof info.id !== "string" || info.id !== provider || typeof info.name !== "string" || info.name.length === 0) throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, "INVALID_ADAPTER");
|
|
989
|
+
unique.add(provider);
|
|
990
|
+
const retryPolicy = adapter.providerRetryPolicy(provider) ?? resolveRetryPolicy(void 0, `llm: provider "${provider}" retryPolicy`);
|
|
991
|
+
registrations.push({
|
|
992
|
+
adapter,
|
|
993
|
+
provider: {
|
|
994
|
+
id: info.id,
|
|
995
|
+
name: info.name
|
|
996
|
+
},
|
|
997
|
+
retryPolicy
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
return registrations;
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Swap this registration's routes for the prepared ones in one synchronous
|
|
1004
|
+
* section, so no observer can see the registry between the release and the
|
|
1005
|
+
* re-registration. The route set's one mutation point is also where
|
|
1006
|
+
* `llm/adapters-updated` is published, so a `replace` announces itself
|
|
1007
|
+
* exactly like a first registration.
|
|
1008
|
+
*/
|
|
1009
|
+
commitRoutes(owned, registrations) {
|
|
1010
|
+
for (const provider of owned) this.adapters.delete(provider);
|
|
1011
|
+
owned.clear();
|
|
1012
|
+
for (const registration of registrations) {
|
|
1013
|
+
this.adapters.set(registration.provider.id, registration);
|
|
1014
|
+
owned.add(registration.provider.id);
|
|
1015
|
+
}
|
|
1016
|
+
this.emitAdaptersUpdated();
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Describe provider routes with a registered adapter.
|
|
1020
|
+
* @returns detached provider metadata in registration order.
|
|
1021
|
+
*/
|
|
1022
|
+
listProviders() {
|
|
1023
|
+
return [...this.adapters.values()].map(({ provider }) => ({ ...provider }));
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Declare provider routes an adapter plugin can activate through
|
|
1027
|
+
* configuration. Registration is all-or-nothing: an empty list, invalid
|
|
1028
|
+
* entry, or a provider already declared by any registration throws
|
|
1029
|
+
* `LlmError` without registering the rest. Disposed with the fiber.
|
|
1030
|
+
* @param entries - every configurable provider this plugin owns.
|
|
1031
|
+
* @returns a handle that withdraws all of them, and can atomically replace them.
|
|
1032
|
+
*/
|
|
1033
|
+
registerConfigurableProviders(entries) {
|
|
1034
|
+
let held = [];
|
|
1035
|
+
let disposed = false;
|
|
1036
|
+
/**
|
|
1037
|
+
* Validate a candidate set in full against everything this registration
|
|
1038
|
+
* does not already hold, then publish it. Nothing is written until the
|
|
1039
|
+
* whole set passes, so a refused candidate leaves the current entries in
|
|
1040
|
+
* place — the property that makes `replace` a swap rather than a
|
|
1041
|
+
* delete-then-add that can strand the directory empty.
|
|
1042
|
+
*/
|
|
1043
|
+
const commit = (candidates) => {
|
|
1044
|
+
const detached = [];
|
|
1045
|
+
const own = new Set(held.map((entry) => entry.provider));
|
|
1046
|
+
for (const entry of candidates) {
|
|
1047
|
+
if (entry.provider.length === 0 || entry.displayName.length === 0 || entry.settingsNs.length === 0) throw new LlmError("configurable providers need a non-empty provider, displayName, and settingsNs", "INVALID_DIRECTORY");
|
|
1048
|
+
if (entry.settingsPath.some((segment) => segment.length === 0)) throw new LlmError(`configurable provider "${entry.provider}" has an empty settingsPath segment`, "INVALID_DIRECTORY");
|
|
1049
|
+
if (this.directory.has(entry.provider) && !own.has(entry.provider) || detached.some((seen) => seen.provider === entry.provider)) throw new LlmError(`configurable provider "${entry.provider}" is already declared`, "DUPLICATE_DIRECTORY");
|
|
1050
|
+
detached.push({
|
|
1051
|
+
...entry,
|
|
1052
|
+
settingsPath: [...entry.settingsPath]
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
for (const entry of held) this.directory.delete(entry.provider);
|
|
1056
|
+
for (const entry of detached) this.directory.set(entry.provider, entry);
|
|
1057
|
+
held = detached;
|
|
1058
|
+
this.emitAdaptersUpdated();
|
|
1059
|
+
};
|
|
1060
|
+
const dispose = this.ctx.effect(function* () {
|
|
1061
|
+
if (entries.length === 0) throw new LlmError("a configurable-provider registration must declare at least one provider", "INVALID_DIRECTORY");
|
|
1062
|
+
commit(entries);
|
|
1063
|
+
yield () => {
|
|
1064
|
+
disposed = true;
|
|
1065
|
+
for (const entry of held) this.directory.delete(entry.provider);
|
|
1066
|
+
held = [];
|
|
1067
|
+
this.emitAdaptersUpdated();
|
|
1068
|
+
};
|
|
1069
|
+
}.bind(this), "llm.registerConfigurableProviders()");
|
|
1070
|
+
const handle = (() => void dispose());
|
|
1071
|
+
handle.replace = (next) => {
|
|
1072
|
+
if (disposed) throw new LlmError("this configurable-provider registration was disposed", "REGISTRATION_DISPOSED");
|
|
1073
|
+
commit(next);
|
|
1074
|
+
};
|
|
1075
|
+
return handle;
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* List every declared configurable provider, registered or dormant.
|
|
1079
|
+
* @returns detached directory entries in declaration order.
|
|
1080
|
+
*/
|
|
1081
|
+
listConfigurableProviders() {
|
|
1082
|
+
return [...this.directory.values()].map((entry) => ({
|
|
1083
|
+
...entry,
|
|
1084
|
+
settingsPath: [...entry.settingsPath]
|
|
1085
|
+
}));
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Offer to interrogate provider endpoints on behalf of the settings
|
|
1089
|
+
* namespace this plugin owns. The namespace is the key because that is what
|
|
1090
|
+
* a configuration surface already holds from the configurable-provider
|
|
1091
|
+
* directory, and because a provider being *added* has no route to name yet.
|
|
1092
|
+
* Disposed with the fiber.
|
|
1093
|
+
* @param settingsNs - the namespace whose profiles this discovery serves.
|
|
1094
|
+
* @param discover - interrogates one endpoint; must honor `request.signal`.
|
|
1095
|
+
* @returns the disposer that withdraws the offer.
|
|
1096
|
+
*/
|
|
1097
|
+
registerModelDiscovery(settingsNs, discover) {
|
|
1098
|
+
const dispose = this.ctx.effect(function* () {
|
|
1099
|
+
if (settingsNs.length === 0) throw new LlmError("model discovery needs a non-empty settings namespace", "INVALID_DISCOVERY");
|
|
1100
|
+
if (this.discoveries.has(settingsNs)) throw new LlmError(`model discovery for "${settingsNs}" is already registered`, "DUPLICATE_DISCOVERY");
|
|
1101
|
+
this.discoveries.set(settingsNs, discover);
|
|
1102
|
+
yield () => {
|
|
1103
|
+
this.discoveries.delete(settingsNs);
|
|
1104
|
+
};
|
|
1105
|
+
}.bind(this), "llm.registerModelDiscovery()");
|
|
1106
|
+
return () => void dispose();
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Interrogate one provider endpoint for the models it advertises. The
|
|
1110
|
+
* request describes a draft, not a stored route, so nothing here reads or
|
|
1111
|
+
* writes settings or credentials — the caller owns both, and the reply is
|
|
1112
|
+
* candidate metadata a surface may offer for adoption.
|
|
1113
|
+
* @param settingsNs - namespace whose registered discovery serves this draft.
|
|
1114
|
+
* @param request - the endpoint, protocol, and one-shot credential to use.
|
|
1115
|
+
* @returns the advertised models, deduplicated in endpoint order.
|
|
1116
|
+
*/
|
|
1117
|
+
async discoverModels(settingsNs, request) {
|
|
1118
|
+
const discover = this.discoveries.get(settingsNs);
|
|
1119
|
+
if (discover === void 0) throw new LlmError(`no model discovery is registered for "${settingsNs}"`, "NO_DISCOVERY");
|
|
1120
|
+
if ((request.provider ?? "").length === 0 && (request.baseURL ?? "").length === 0) throw new LlmError("model discovery needs a provider route or a baseURL", "INVALID_DISCOVERY");
|
|
1121
|
+
const discovered = await discover(request);
|
|
1122
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1123
|
+
const models = [];
|
|
1124
|
+
for (const model of discovered) {
|
|
1125
|
+
if (typeof model.id !== "string" || model.id.length === 0 || seen.has(model.id)) continue;
|
|
1126
|
+
seen.add(model.id);
|
|
1127
|
+
models.push({
|
|
1128
|
+
id: model.id,
|
|
1129
|
+
...model.name === void 0 ? {} : { name: model.name },
|
|
1130
|
+
...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow },
|
|
1131
|
+
...model.maxTokens === void 0 ? {} : { maxTokens: model.maxTokens }
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
return models;
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Resolve the retry policy captured when one provider route was registered.
|
|
1138
|
+
* @param provider - registered provider route to inspect.
|
|
1139
|
+
* @returns the provider-owned policy, with normal defaults already resolved.
|
|
1140
|
+
*/
|
|
1141
|
+
providerRetryPolicy(provider) {
|
|
1142
|
+
return this.registration(provider).retryPolicy;
|
|
1143
|
+
}
|
|
1144
|
+
/** Detach typed adapter-owned modality metadata. */
|
|
1145
|
+
detachedModalities(modalities) {
|
|
1146
|
+
return modalities === void 0 ? void 0 : [...modalities];
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Discover models advertised by one registered provider. Catalog membership
|
|
1150
|
+
* is advisory and never changes routing or request validation.
|
|
1151
|
+
* @param provider - registered provider route to inspect.
|
|
1152
|
+
* @returns detached model metadata in adapter-preferred order.
|
|
1153
|
+
*/
|
|
1154
|
+
async listModels(provider) {
|
|
1155
|
+
const models = await this.registration(provider).adapter.listModels(provider);
|
|
1156
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1157
|
+
return models.map((model) => {
|
|
1158
|
+
if (typeof model.provider !== "string" || model.provider !== provider || typeof model.id !== "string" || model.id.length === 0 || typeof model.name !== "string" || model.name.length === 0 || model.description !== void 0 && typeof model.description !== "string" || seen.has(model.id)) throw new LlmError(`adapter returned invalid or duplicate model metadata for provider "${provider}"`, "INVALID_CATALOG");
|
|
1159
|
+
seen.add(model.id);
|
|
1160
|
+
const inputModalities = this.detachedModalities(model.inputModalities);
|
|
1161
|
+
return {
|
|
1162
|
+
provider: model.provider,
|
|
1163
|
+
id: model.id,
|
|
1164
|
+
name: model.name,
|
|
1165
|
+
...model.description === void 0 ? {} : { description: model.description },
|
|
1166
|
+
...inputModalities === void 0 ? {} : { inputModalities }
|
|
1167
|
+
};
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Resolve and validate all metadata from the adapter that owns one exact
|
|
1172
|
+
* route. The result is detached from adapter-owned objects; catalog
|
|
1173
|
+
* membership remains advisory and does not control request routing.
|
|
1174
|
+
* @param provider - registered provider route to inspect.
|
|
1175
|
+
* @param model - exact model id passed to the adapter.
|
|
1176
|
+
* @param signal - optional cancellation for adapter-owned asynchronous lookup.
|
|
1177
|
+
* @returns exact model identity plus available context and reasoning metadata.
|
|
1178
|
+
*/
|
|
1179
|
+
async resolveModelInfo(provider, model, signal) {
|
|
1180
|
+
return this.resolveModelInfoFor(this.registration(provider), model, signal);
|
|
1181
|
+
}
|
|
1182
|
+
async resolveModelInfoFor(registration, model, signal) {
|
|
1183
|
+
const provider = registration.provider.id;
|
|
1184
|
+
const resolved = await registration.adapter.resolveModel(provider, model, signal);
|
|
1185
|
+
if (typeof resolved.provider !== "string" || resolved.provider !== provider || typeof resolved.id !== "string" || resolved.id !== model || typeof resolved.name !== "string" || resolved.name.length === 0 || resolved.description !== void 0 && typeof resolved.description !== "string") throw new LlmError(`adapter returned invalid exact model metadata for provider "${provider}" model "${model}"`, "INVALID_MODEL_INFO");
|
|
1186
|
+
const context = resolved.context;
|
|
1187
|
+
if (context !== void 0 && (!Number.isInteger(context.contextWindow) || context.contextWindow <= 0)) throw new LlmError(`adapter returned invalid context metadata for provider "${provider}" model "${model}"`, "INVALID_MODEL_CONTEXT");
|
|
1188
|
+
const inputModalities = this.detachedModalities(resolved.inputModalities);
|
|
1189
|
+
const defaultMaxTokens = resolved.defaultMaxTokens;
|
|
1190
|
+
if (defaultMaxTokens !== void 0 && (!Number.isSafeInteger(defaultMaxTokens) || defaultMaxTokens <= 0)) throw new LlmError(`adapter returned invalid default maxTokens for provider "${provider}" model "${model}"`, "INVALID_MODEL_MAX_TOKENS");
|
|
1191
|
+
const info = {
|
|
1192
|
+
provider,
|
|
1193
|
+
id: model,
|
|
1194
|
+
name: resolved.name,
|
|
1195
|
+
...resolved.description === void 0 ? {} : { description: resolved.description },
|
|
1196
|
+
...inputModalities === void 0 ? {} : { inputModalities },
|
|
1197
|
+
...context === void 0 ? {} : { context: { contextWindow: context.contextWindow } },
|
|
1198
|
+
...defaultMaxTokens === void 0 ? {} : { defaultMaxTokens }
|
|
1199
|
+
};
|
|
1200
|
+
const reasoning = resolved.reasoning;
|
|
1201
|
+
if (reasoning === void 0) return info;
|
|
1202
|
+
if (reasoning.efforts.length === 0) throw new LlmError(`adapter returned invalid reasoning metadata for provider "${provider}" model "${model}"`, "INVALID_MODEL_REASONING");
|
|
1203
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1204
|
+
const efforts = reasoning.efforts.map((effort) => {
|
|
1205
|
+
if (typeof effort.id !== "string" || effort.id.length === 0 || typeof effort.name !== "string" || effort.name.length === 0 || effort.description !== void 0 && typeof effort.description !== "string" || seen.has(effort.id)) throw new LlmError(`adapter returned invalid or duplicate reasoning effort metadata for provider "${provider}" model "${model}"`, "INVALID_MODEL_REASONING");
|
|
1206
|
+
seen.add(effort.id);
|
|
1207
|
+
return {
|
|
1208
|
+
id: effort.id,
|
|
1209
|
+
name: effort.name,
|
|
1210
|
+
...effort.description === void 0 ? {} : { description: effort.description }
|
|
1211
|
+
};
|
|
1212
|
+
});
|
|
1213
|
+
if (reasoning.defaultEffort !== void 0 && !seen.has(reasoning.defaultEffort)) throw new LlmError(`adapter returned an unknown default reasoning effort for provider "${provider}" model "${model}"`, "INVALID_MODEL_REASONING");
|
|
1214
|
+
return {
|
|
1215
|
+
...info,
|
|
1216
|
+
reasoning: {
|
|
1217
|
+
efforts,
|
|
1218
|
+
...reasoning.defaultEffort === void 0 ? {} : { defaultEffort: reasoning.defaultEffort }
|
|
1219
|
+
}
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Validate a conversation call config against its exact model capability and
|
|
1224
|
+
* materialize adapter-configured defaults. Unsupported explicit efforts
|
|
1225
|
+
* reject before provider I/O; no clamping or aliasing is performed. This
|
|
1226
|
+
* standalone query does not bind a later dispatch; use {@link prepareCall}
|
|
1227
|
+
* when logging and streaming must share one adapter registration.
|
|
1228
|
+
* @param config - provider/model route and optional request controls.
|
|
1229
|
+
* @param signal - optional cancellation for adapter-owned capability lookup.
|
|
1230
|
+
* @returns a detached config only when a default must be materialized.
|
|
1231
|
+
*/
|
|
1232
|
+
async resolveCallConfig(config, signal) {
|
|
1233
|
+
return (await this.resolveCallFor(this.registration(config.provider), config, signal)).config;
|
|
1234
|
+
}
|
|
1235
|
+
async resolveCallFor(registration, config, signal) {
|
|
1236
|
+
const info = await this.resolveModelInfoFor(registration, config.model, signal);
|
|
1237
|
+
const defaulted = config.maxTokens === void 0 && info.defaultMaxTokens !== void 0 ? {
|
|
1238
|
+
...config,
|
|
1239
|
+
maxTokens: info.defaultMaxTokens
|
|
1240
|
+
} : config;
|
|
1241
|
+
const reasoning = info.reasoning;
|
|
1242
|
+
const requested = defaulted.reasoningEffort;
|
|
1243
|
+
let resolvedConfig = defaulted;
|
|
1244
|
+
if (reasoning === void 0) {
|
|
1245
|
+
if (requested !== void 0) throw new LlmError(`provider "${config.provider}" model "${config.model}" does not support reasoning effort "${requested}"`, "UNSUPPORTED_REASONING_EFFORT");
|
|
1246
|
+
} else {
|
|
1247
|
+
const effective = requested ?? reasoning.defaultEffort;
|
|
1248
|
+
if (effective !== void 0) {
|
|
1249
|
+
if (!reasoning.efforts.some((effort) => effort.id === effective)) throw new LlmError(`provider "${config.provider}" model "${config.model}" does not support reasoning effort "${effective}"`, "UNSUPPORTED_REASONING_EFFORT");
|
|
1250
|
+
if (requested !== effective) resolvedConfig = {
|
|
1251
|
+
...defaulted,
|
|
1252
|
+
reasoningEffort: effective
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return {
|
|
1257
|
+
config: resolvedConfig,
|
|
1258
|
+
...info.context === void 0 ? {} : { context: info.context }
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Resolve one call under its current adapter registration. The returned
|
|
1263
|
+
* one-shot handle keeps that registration across header logging and dispatch,
|
|
1264
|
+
* so HMR cannot combine one adapter's capability result with another adapter.
|
|
1265
|
+
* @param config - provider/model route and optional request controls.
|
|
1266
|
+
* @param signal - optional cancellation for adapter-owned capability lookup.
|
|
1267
|
+
* @returns a prepared config and its registration-bound stream entry point.
|
|
1268
|
+
*/
|
|
1269
|
+
async prepareCall(config, signal) {
|
|
1270
|
+
const registration = this.registration(config.provider);
|
|
1271
|
+
const resolved = await this.resolveCallFor(registration, config, signal);
|
|
1272
|
+
const resolvedConfig = deepFreeze(structuredClone(resolved.config));
|
|
1273
|
+
const context = resolved.context === void 0 ? void 0 : deepFreeze(structuredClone(resolved.context));
|
|
1274
|
+
const adapterDefaults = deepFreeze({
|
|
1275
|
+
...config.reasoningEffort === void 0 && resolvedConfig.reasoningEffort !== void 0 ? { reasoningEffort: true } : {},
|
|
1276
|
+
...config.maxTokens === void 0 && resolvedConfig.maxTokens !== void 0 ? { maxTokens: true } : {}
|
|
1277
|
+
});
|
|
1278
|
+
let dispatched = false;
|
|
1279
|
+
return Object.freeze({
|
|
1280
|
+
config: resolvedConfig,
|
|
1281
|
+
retryPolicy: registration.retryPolicy,
|
|
1282
|
+
adapterDefaults,
|
|
1283
|
+
...context === void 0 ? {} : { context },
|
|
1284
|
+
stream: (options) => {
|
|
1285
|
+
if (dispatched) throw new LlmError("a prepared LLM call can only be dispatched once", "INVALID_PREPARED_CALL");
|
|
1286
|
+
if (!callConfigEquals(options, resolvedConfig)) throw new LlmError("prepared LLM call config changed before adapter dispatch", "INVALID_PREPARED_CALL");
|
|
1287
|
+
dispatched = true;
|
|
1288
|
+
return this.streamWithRegistration(options, {
|
|
1289
|
+
registration,
|
|
1290
|
+
config: resolvedConfig
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
registration(provider) {
|
|
1296
|
+
const registration = this.adapters.get(provider);
|
|
1297
|
+
if (!registration) throw new LlmError(`no adapter registered for provider "${provider}"`, "NO_ADAPTER");
|
|
1298
|
+
return registration;
|
|
1299
|
+
}
|
|
1300
|
+
/** Remove replay state whose historical route is owned by another adapter. */
|
|
1301
|
+
forAdapter(options, adapter) {
|
|
1302
|
+
const messages = options.messages.map((message) => {
|
|
1303
|
+
const source = message.source;
|
|
1304
|
+
if (message.role !== "assistant" || source.kind !== "model" || source.replayState === void 0) return message;
|
|
1305
|
+
if (this.adapters.get(source.provider)?.adapter === adapter) return message;
|
|
1306
|
+
return freezeMessage({
|
|
1307
|
+
...message,
|
|
1308
|
+
source: {
|
|
1309
|
+
kind: "model",
|
|
1310
|
+
provider: source.provider,
|
|
1311
|
+
model: source.model
|
|
1312
|
+
}
|
|
1313
|
+
});
|
|
1314
|
+
});
|
|
1315
|
+
if (messages.every((message, index) => message === options.messages[index])) return options;
|
|
1316
|
+
const filtered = {
|
|
1317
|
+
...options,
|
|
1318
|
+
messages
|
|
1319
|
+
};
|
|
1320
|
+
return Object.isFrozen(options) ? deepFreeze(filtered) : filtered;
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Final adapter boundary. Adapter selection, dispatch, iterator construction,
|
|
1324
|
+
* and iteration failures become one terminal failure chunk. Middleware and
|
|
1325
|
+
* downstream consumer failures remain thrown plugin or consumer errors.
|
|
1326
|
+
*/
|
|
1327
|
+
async *adapterStream(options, prepared) {
|
|
1328
|
+
let iterator;
|
|
1329
|
+
try {
|
|
1330
|
+
const registration = prepared?.registration ?? this.registration(options.provider);
|
|
1331
|
+
const resolvedConfig = prepared === void 0 ? (await this.resolveCallFor(registration, options, options.signal)).config : prepared.config;
|
|
1332
|
+
if (prepared !== void 0 && !callConfigEquals(options, resolvedConfig)) throw new LlmError("prepared LLM call config changed before adapter dispatch", "INVALID_PREPARED_CALL");
|
|
1333
|
+
const resolvedOptions = callConfigEquals(options, resolvedConfig) ? options : Object.isFrozen(options) ? deepFreeze({
|
|
1334
|
+
...options,
|
|
1335
|
+
...resolvedConfig
|
|
1336
|
+
}) : {
|
|
1337
|
+
...options,
|
|
1338
|
+
...resolvedConfig
|
|
1339
|
+
};
|
|
1340
|
+
const adapter = registration.adapter;
|
|
1341
|
+
iterator = adapter.stream(this.forAdapter(resolvedOptions, adapter))[Symbol.asyncIterator]();
|
|
1342
|
+
} catch (error) {
|
|
1343
|
+
yield adapterFailureChunk(error, options.signal);
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
let completed = false;
|
|
1347
|
+
try {
|
|
1348
|
+
while (true) {
|
|
1349
|
+
let item;
|
|
1350
|
+
try {
|
|
1351
|
+
const next = await iterator.next();
|
|
1352
|
+
item = next.done ? { done: true } : {
|
|
1353
|
+
done: false,
|
|
1354
|
+
value: next.value
|
|
1355
|
+
};
|
|
1356
|
+
} catch (error) {
|
|
1357
|
+
completed = true;
|
|
1358
|
+
yield adapterFailureChunk(error, options.signal);
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
if (item.done) {
|
|
1362
|
+
completed = true;
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
yield item.value;
|
|
1366
|
+
}
|
|
1367
|
+
} finally {
|
|
1368
|
+
if (!completed) {
|
|
1369
|
+
const close = iterator.return?.bind(iterator);
|
|
1370
|
+
if (close) await close();
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Stream one model call as raw chunks (token-level deltas). Replay state is
|
|
1376
|
+
* retained only when the same adapter instance owns its historical provider
|
|
1377
|
+
* and the target provider. Final adapter selection remains fixed through
|
|
1378
|
+
* asynchronous exact-model resolution and dispatch. Adapter selection,
|
|
1379
|
+
* dispatch, and iteration failures become terminal `error` or `aborted`
|
|
1380
|
+
* finish chunks; middleware, nested-call, cleanup, and consumer failures
|
|
1381
|
+
* remain thrown.
|
|
1382
|
+
* @param options - the full request; `options.provider` selects the adapter.
|
|
1383
|
+
* @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
|
|
1384
|
+
*/
|
|
1385
|
+
stream(options) {
|
|
1386
|
+
return this.streamWithRegistration(options);
|
|
1387
|
+
}
|
|
1388
|
+
streamWithRegistration(options, prepared) {
|
|
1389
|
+
return this.ctx.waterfall(this, "llm/stream", options, () => this.adapterStream(options, prepared));
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
/** Convert one adapter throw into the stream protocol's terminal outcome. */
|
|
1393
|
+
function adapterFailureChunk(error, signal) {
|
|
1394
|
+
const failure = normalizeLlmFailure(error);
|
|
1395
|
+
return {
|
|
1396
|
+
type: "finish",
|
|
1397
|
+
reason: signal?.aborted || failure.code === "ABORTED" ? {
|
|
1398
|
+
kind: "aborted",
|
|
1399
|
+
failure
|
|
1400
|
+
} : {
|
|
1401
|
+
kind: "error",
|
|
1402
|
+
failure
|
|
1403
|
+
}
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
//#endregion
|
|
1407
|
+
export { APP_IDENTITY, BlockAssembler, CONTEXT_SUMMARY_MAX_CHARS, CONTEXT_WINDOW_EXCEEDED_CODE, CallId, EMPTY_RESPONSE_CODE, HarnessError, INVALID_CREDENTIAL_CODE, LlmAdapter, LlmError, LlmRuntime, LlmRuntime as default, MessageId, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId, RetryPolicySchema, assertNever, assertUsableApiKey, attributionHeaders, boundContextSummary, callConfigEquals, contentHasImage, createAssistantMessage, createMessage, createToolResultMessage, createUserMessage, deepFreeze, errorChain, freezeMessage, isAgentLoopRequest, isContextWindowExceededError, isHarnessError, isQuotaExceededError, isTokenDelta, markAgentLoopRequest, normalizeApiKey, resolveRetryPolicy, userAgent };
|