@stackstackstack/dsh-llm-pi-ai 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 +204 -0
- package/README.zh.md +205 -0
- package/lib/index.js +1868 -0
- package/lib/invariant.js +23 -0
- package/lib/types/adapter.d.ts +69 -0
- package/lib/types/catalog.d.ts +183 -0
- package/lib/types/config.d.ts +176 -0
- package/lib/types/context.d.ts +24 -0
- package/lib/types/discovery.d.ts +38 -0
- package/lib/types/index.d.ts +68 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/provider.d.ts +59 -0
- package/lib/types/replay.d.ts +48 -0
- package/lib/types/stream.d.ts +38 -0
- package/package.json +59 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1868 @@
|
|
|
1
|
+
import { launchEnvironmentOf } from "@stackstackstack/dsh-launch-environment";
|
|
2
|
+
import { CONTEXT_WINDOW_EXCEEDED_CODE, CallId, EMPTY_RESPONSE_CODE, INVALID_CREDENTIAL_CODE, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, ReasoningEffortId, RetryPolicySchema, assertUsableApiKey, attributionHeaders, contentHasImage, isContextWindowExceededError, isQuotaExceededError, normalizeApiKey, resolveRetryPolicy } from "@stackstackstack/dsh-llm";
|
|
3
|
+
import { deepEqualJson, installSettingsSection, settingsNamespace } from "@stackstackstack/dsh-settings";
|
|
4
|
+
import { createModels, createProvider, getSupportedThinkingLevels, isContextOverflow } from "@earendil-works/pi-ai";
|
|
5
|
+
import { MAX_TIMER_DELAY_MS, idleWatchdog, timeoutOf } from "@stackstackstack/dsh-timeout";
|
|
6
|
+
import { builtinProviders, getBuiltinModels, getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
|
|
7
|
+
import z from "@deepseek-ai/schemastery";
|
|
8
|
+
import { credentialRef } from "@stackstackstack/dsh-credentials";
|
|
9
|
+
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
|
|
10
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
11
|
+
import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
|
|
12
|
+
//#region lib/types/replay.js
|
|
13
|
+
/**
|
|
14
|
+
* Durable pi-ai replay metadata and assistant-history reconstruction.
|
|
15
|
+
*
|
|
16
|
+
* Harness content remains the durable source for text and tool calls. This
|
|
17
|
+
* module stores only the provider-native metadata needed to reconstruct a
|
|
18
|
+
* pi-ai assistant message on a later request.
|
|
19
|
+
*
|
|
20
|
+
* @module dsh-llm-pi-ai/replay
|
|
21
|
+
*/
|
|
22
|
+
/** Parse tool-call argument JSON; tolerate model malformations with {}. */
|
|
23
|
+
function parseArguments(raw) {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
|
|
27
|
+
} catch {}
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
/** Construct the zero usage value required by historical pi-ai messages. */
|
|
31
|
+
function emptyPiUsage() {
|
|
32
|
+
return {
|
|
33
|
+
input: 0,
|
|
34
|
+
output: 0,
|
|
35
|
+
cacheRead: 0,
|
|
36
|
+
cacheWrite: 0,
|
|
37
|
+
totalTokens: 0,
|
|
38
|
+
cost: {
|
|
39
|
+
input: 0,
|
|
40
|
+
output: 0,
|
|
41
|
+
cacheRead: 0,
|
|
42
|
+
cacheWrite: 0,
|
|
43
|
+
total: 0
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Project a successful pi-ai response into the minimal durable replay state.
|
|
49
|
+
* @param message - completed native pi-ai assistant response.
|
|
50
|
+
* @returns the versioned lossless-JSON replay projection.
|
|
51
|
+
*/
|
|
52
|
+
function toPiReplayState(message) {
|
|
53
|
+
return {
|
|
54
|
+
kind: "pi-ai",
|
|
55
|
+
version: 1,
|
|
56
|
+
api: message.api,
|
|
57
|
+
provider: message.provider,
|
|
58
|
+
model: message.model,
|
|
59
|
+
...message.responseModel === void 0 ? {} : { responseModel: message.responseModel },
|
|
60
|
+
...message.responseId === void 0 ? {} : { responseId: message.responseId },
|
|
61
|
+
stopReason: message.stopReason,
|
|
62
|
+
blocks: message.content.map((block) => {
|
|
63
|
+
switch (block.type) {
|
|
64
|
+
case "text": return {
|
|
65
|
+
type: "text",
|
|
66
|
+
...block.textSignature === void 0 ? {} : { textSignature: block.textSignature }
|
|
67
|
+
};
|
|
68
|
+
case "thinking": return {
|
|
69
|
+
type: "reasoning",
|
|
70
|
+
...block.thinkingSignature === void 0 ? {} : { thinkingSignature: block.thinkingSignature },
|
|
71
|
+
...block.redacted === void 0 ? {} : { redacted: block.redacted }
|
|
72
|
+
};
|
|
73
|
+
case "toolCall": return {
|
|
74
|
+
type: "tool-call",
|
|
75
|
+
...block.thoughtSignature === void 0 ? {} : { thoughtSignature: block.thoughtSignature }
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function invalidReplay(message) {
|
|
82
|
+
throw new LlmError(`invalid pi-ai replay state: ${message}`, "INVALID_REPLAY_STATE");
|
|
83
|
+
}
|
|
84
|
+
/** Validate the adapter-private state before it reaches pi-ai. */
|
|
85
|
+
function readReplayState(value) {
|
|
86
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return invalidReplay("expected an object");
|
|
87
|
+
const state = value;
|
|
88
|
+
if (state["kind"] !== "pi-ai") return invalidReplay("unknown state kind");
|
|
89
|
+
if (state["version"] !== 1) return invalidReplay(`unsupported version ${String(state["version"])}`);
|
|
90
|
+
for (const key of [
|
|
91
|
+
"api",
|
|
92
|
+
"provider",
|
|
93
|
+
"model"
|
|
94
|
+
]) if (typeof state[key] !== "string" || state[key].length === 0) return invalidReplay(`${key} must be a non-empty string`);
|
|
95
|
+
if (![
|
|
96
|
+
"stop",
|
|
97
|
+
"length",
|
|
98
|
+
"toolUse",
|
|
99
|
+
"error",
|
|
100
|
+
"aborted"
|
|
101
|
+
].includes(String(state["stopReason"]))) return invalidReplay("unknown stopReason");
|
|
102
|
+
if (state["responseModel"] !== void 0 && typeof state["responseModel"] !== "string") return invalidReplay("responseModel must be a string");
|
|
103
|
+
if (state["responseId"] !== void 0 && typeof state["responseId"] !== "string") return invalidReplay("responseId must be a string");
|
|
104
|
+
if (!Array.isArray(state["blocks"])) return invalidReplay("blocks must be an array");
|
|
105
|
+
for (const [index, value] of state["blocks"].entries()) {
|
|
106
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return invalidReplay(`block ${index} must be an object`);
|
|
107
|
+
const block = value;
|
|
108
|
+
if (![
|
|
109
|
+
"text",
|
|
110
|
+
"reasoning",
|
|
111
|
+
"tool-call"
|
|
112
|
+
].includes(String(block["type"]))) return invalidReplay(`block ${index} has an unknown type`);
|
|
113
|
+
for (const signature of [
|
|
114
|
+
"textSignature",
|
|
115
|
+
"thinkingSignature",
|
|
116
|
+
"thoughtSignature"
|
|
117
|
+
]) if (block[signature] !== void 0 && typeof block[signature] !== "string") return invalidReplay(`block ${index} ${signature} must be a string`);
|
|
118
|
+
if (block["redacted"] !== void 0 && typeof block["redacted"] !== "boolean") return invalidReplay(`block ${index} redacted must be boolean`);
|
|
119
|
+
}
|
|
120
|
+
return state;
|
|
121
|
+
}
|
|
122
|
+
/** Convert provider-neutral blocks without trusting them as same-model replay. */
|
|
123
|
+
function foreignAssistant(message) {
|
|
124
|
+
const source = message.source.kind === "model" ? message.source : void 0;
|
|
125
|
+
const content = [];
|
|
126
|
+
for (const block of message.content) switch (block.type) {
|
|
127
|
+
case "text":
|
|
128
|
+
content.push({
|
|
129
|
+
type: "text",
|
|
130
|
+
text: block.text
|
|
131
|
+
});
|
|
132
|
+
break;
|
|
133
|
+
case "reasoning":
|
|
134
|
+
content.push({
|
|
135
|
+
type: "thinking",
|
|
136
|
+
thinking: block.text
|
|
137
|
+
});
|
|
138
|
+
break;
|
|
139
|
+
case "tool-call":
|
|
140
|
+
content.push({
|
|
141
|
+
type: "toolCall",
|
|
142
|
+
id: block.id,
|
|
143
|
+
name: block.name,
|
|
144
|
+
arguments: parseArguments(block.arguments)
|
|
145
|
+
});
|
|
146
|
+
break;
|
|
147
|
+
case "image": throw new LlmError("pi-ai chat history cannot represent structured assistant image output", "UNSUPPORTED_CONTENT");
|
|
148
|
+
default: break;
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
role: "assistant",
|
|
152
|
+
content,
|
|
153
|
+
api: "dsh-foreign",
|
|
154
|
+
provider: source?.provider ?? "dsh-foreign",
|
|
155
|
+
model: source?.model ?? "dsh-foreign",
|
|
156
|
+
usage: emptyPiUsage(),
|
|
157
|
+
stopReason: content.some((piece) => piece.type === "toolCall") ? "toolUse" : "stop",
|
|
158
|
+
timestamp: 0
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** Recombine durable Harness content with validated pi-ai replay metadata. */
|
|
162
|
+
function replayedAssistant(message, source, rawState) {
|
|
163
|
+
const state = readReplayState(rawState);
|
|
164
|
+
if (state.provider !== source.provider) return invalidReplay("provider does not match assistant source");
|
|
165
|
+
if (state.model !== source.model) return invalidReplay("model does not match assistant source");
|
|
166
|
+
if (state.blocks.length !== message.content.length) return invalidReplay("block count does not match assistant content");
|
|
167
|
+
return {
|
|
168
|
+
role: "assistant",
|
|
169
|
+
content: message.content.map((block, index) => {
|
|
170
|
+
const replay = state.blocks[index];
|
|
171
|
+
if (replay === void 0 || replay.type !== block.type) return invalidReplay(`block ${index} does not match assistant content`);
|
|
172
|
+
switch (block.type) {
|
|
173
|
+
case "text": return {
|
|
174
|
+
type: "text",
|
|
175
|
+
text: block.text,
|
|
176
|
+
...replay.type === "text" && replay.textSignature !== void 0 ? { textSignature: replay.textSignature } : {}
|
|
177
|
+
};
|
|
178
|
+
case "reasoning": return {
|
|
179
|
+
type: "thinking",
|
|
180
|
+
thinking: block.text,
|
|
181
|
+
...replay.type === "reasoning" && replay.thinkingSignature !== void 0 ? { thinkingSignature: replay.thinkingSignature } : {},
|
|
182
|
+
...replay.type === "reasoning" && replay.redacted !== void 0 ? { redacted: replay.redacted } : {}
|
|
183
|
+
};
|
|
184
|
+
case "tool-call": return {
|
|
185
|
+
type: "toolCall",
|
|
186
|
+
id: block.id,
|
|
187
|
+
name: block.name,
|
|
188
|
+
arguments: parseArguments(block.arguments),
|
|
189
|
+
...replay.type === "tool-call" && replay.thoughtSignature !== void 0 ? { thoughtSignature: replay.thoughtSignature } : {}
|
|
190
|
+
};
|
|
191
|
+
/* v8 ignore next -- readReplayState rejects unknown replay tags, so an equal plugin-added Harness tag cannot reach this switch */
|
|
192
|
+
default: return invalidReplay(`block ${index} has an unsupported Harness type`);
|
|
193
|
+
}
|
|
194
|
+
}),
|
|
195
|
+
api: state.api,
|
|
196
|
+
provider: state.provider,
|
|
197
|
+
model: state.model,
|
|
198
|
+
...state.responseModel === void 0 ? {} : { responseModel: state.responseModel },
|
|
199
|
+
...state.responseId === void 0 ? {} : { responseId: state.responseId },
|
|
200
|
+
usage: emptyPiUsage(),
|
|
201
|
+
stopReason: state.stopReason,
|
|
202
|
+
timestamp: 0
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Convert one durable Harness assistant message into pi-ai history.
|
|
207
|
+
* @param message - assistant content with required source and optional adapter-owned replay metadata.
|
|
208
|
+
* @returns a native pi-ai assistant message reconstructed from durable content.
|
|
209
|
+
*/
|
|
210
|
+
function toPiAssistant(message) {
|
|
211
|
+
const source = message.source;
|
|
212
|
+
return source.kind !== "model" || source.replayState === void 0 ? foreignAssistant(message) : replayedAssistant(message, source, source.replayState);
|
|
213
|
+
}
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region lib/types/context.js
|
|
216
|
+
/**
|
|
217
|
+
* Harness request-history conversion into pi-ai's Context vocabulary.
|
|
218
|
+
*
|
|
219
|
+
* @module dsh-llm-pi-ai/context
|
|
220
|
+
*/
|
|
221
|
+
/** Join the text blocks of a harness message. */
|
|
222
|
+
function flattenText(message) {
|
|
223
|
+
return message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
224
|
+
}
|
|
225
|
+
/** Flatten text recursively inside one tool result. */
|
|
226
|
+
function toolResultText(blocks) {
|
|
227
|
+
return blocks.map((block) => block.type === "text" ? block.text : block.type === "tool-result" ? toolResultText(block.content) : "").join("");
|
|
228
|
+
}
|
|
229
|
+
async function userContent(blocks, attachments) {
|
|
230
|
+
const content = [];
|
|
231
|
+
for (const block of blocks) switch (block.type) {
|
|
232
|
+
case "text":
|
|
233
|
+
if (block.text.length > 0) content.push({
|
|
234
|
+
type: "text",
|
|
235
|
+
text: block.text
|
|
236
|
+
});
|
|
237
|
+
break;
|
|
238
|
+
case "image": {
|
|
239
|
+
const stored = await attachments.readImage(block.attachment);
|
|
240
|
+
content.push({
|
|
241
|
+
type: "image",
|
|
242
|
+
data: Buffer.from(stored.data).toString("base64"),
|
|
243
|
+
mimeType: stored.ref.mediaType
|
|
244
|
+
});
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
case "tool-result":
|
|
248
|
+
{
|
|
249
|
+
const nested = await userContent(block.content, attachments);
|
|
250
|
+
if (typeof nested === "string") {
|
|
251
|
+
if (nested.length > 0) content.push({
|
|
252
|
+
type: "text",
|
|
253
|
+
text: nested
|
|
254
|
+
});
|
|
255
|
+
} else content.push(...nested);
|
|
256
|
+
}
|
|
257
|
+
break;
|
|
258
|
+
default: break;
|
|
259
|
+
}
|
|
260
|
+
if (content.every((block) => block.type === "text")) return content.map((block) => block.text).join("");
|
|
261
|
+
return content;
|
|
262
|
+
}
|
|
263
|
+
function toolsOf(options) {
|
|
264
|
+
return options.tools?.map((tool) => ({
|
|
265
|
+
name: tool.name,
|
|
266
|
+
description: tool.description,
|
|
267
|
+
parameters: tool.parameters
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
/** Assemble the request-level pi-ai context envelope shared by both conversion paths. */
|
|
271
|
+
function piContext(options, messages) {
|
|
272
|
+
const tools = toolsOf(options);
|
|
273
|
+
return {
|
|
274
|
+
...options.system !== void 0 ? { systemPrompt: options.system } : {},
|
|
275
|
+
messages,
|
|
276
|
+
...tools !== void 0 && tools.length > 0 ? { tools } : {}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
function textOnlyContext(options) {
|
|
280
|
+
const toolNames = /* @__PURE__ */ new Map();
|
|
281
|
+
const messages = [];
|
|
282
|
+
for (const message of options.messages) {
|
|
283
|
+
if (contentHasImage(message.content)) throw new LlmError("pi-ai image conversion requires the durable attachment service", "UNSUPPORTED_CONTENT");
|
|
284
|
+
if (message.role === "system") {
|
|
285
|
+
messages.push({
|
|
286
|
+
role: "user",
|
|
287
|
+
content: flattenText(message),
|
|
288
|
+
timestamp: 0
|
|
289
|
+
});
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (message.role === "assistant") {
|
|
293
|
+
const assistant = toPiAssistant(message);
|
|
294
|
+
for (const block of assistant.content) if (block.type === "toolCall") toolNames.set(CallId(block.id), block.name);
|
|
295
|
+
messages.push(assistant);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const text = flattenText(message);
|
|
299
|
+
const results = message.content.filter((block) => block.type === "tool-result");
|
|
300
|
+
if (text.length > 0 || results.length === 0) messages.push({
|
|
301
|
+
role: "user",
|
|
302
|
+
content: text,
|
|
303
|
+
timestamp: 0
|
|
304
|
+
});
|
|
305
|
+
for (const result of results) messages.push({
|
|
306
|
+
role: "toolResult",
|
|
307
|
+
toolCallId: result.toolCallId,
|
|
308
|
+
toolName: toolNames.get(result.toolCallId) ?? "unknown",
|
|
309
|
+
content: [{
|
|
310
|
+
type: "text",
|
|
311
|
+
text: toolResultText(result.content) || "(no output)"
|
|
312
|
+
}],
|
|
313
|
+
isError: result.isError ?? false,
|
|
314
|
+
timestamp: 0
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
return piContext(options, messages);
|
|
318
|
+
}
|
|
319
|
+
function toPiContext(options, attachments) {
|
|
320
|
+
return attachments === void 0 ? textOnlyContext(options) : toPiContextWithImages(options, attachments);
|
|
321
|
+
}
|
|
322
|
+
async function toPiContextWithImages(options, attachments) {
|
|
323
|
+
const toolNames = /* @__PURE__ */ new Map();
|
|
324
|
+
const messages = [];
|
|
325
|
+
for (const message of options.messages) {
|
|
326
|
+
if (message.role === "system") {
|
|
327
|
+
if (contentHasImage(message.content)) throw new LlmError("pi-ai cannot represent an image in an in-history system message", "UNSUPPORTED_CONTENT");
|
|
328
|
+
messages.push({
|
|
329
|
+
role: "user",
|
|
330
|
+
content: flattenText(message),
|
|
331
|
+
timestamp: 0
|
|
332
|
+
});
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (message.role === "assistant") {
|
|
336
|
+
const assistant = toPiAssistant(message);
|
|
337
|
+
for (const block of assistant.content) if (block.type === "toolCall") toolNames.set(CallId(block.id), block.name);
|
|
338
|
+
messages.push(assistant);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const content = await userContent(message.content.filter((block) => block.type !== "tool-result"), attachments);
|
|
342
|
+
const results = message.content.filter((block) => block.type === "tool-result");
|
|
343
|
+
if (content.length > 0 || results.length === 0) messages.push({
|
|
344
|
+
role: "user",
|
|
345
|
+
content,
|
|
346
|
+
timestamp: 0
|
|
347
|
+
});
|
|
348
|
+
for (const result of results) {
|
|
349
|
+
const resultContent = await userContent(result.content, attachments);
|
|
350
|
+
messages.push({
|
|
351
|
+
role: "toolResult",
|
|
352
|
+
toolCallId: result.toolCallId,
|
|
353
|
+
toolName: toolNames.get(result.toolCallId) ?? "unknown",
|
|
354
|
+
content: typeof resultContent === "string" ? [{
|
|
355
|
+
type: "text",
|
|
356
|
+
text: resultContent || "(no output)"
|
|
357
|
+
}] : resultContent,
|
|
358
|
+
isError: result.isError ?? false,
|
|
359
|
+
timestamp: 0
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return piContext(options, messages);
|
|
364
|
+
}
|
|
365
|
+
//#endregion
|
|
366
|
+
//#region lib/types/stream.js
|
|
367
|
+
/**
|
|
368
|
+
* pi-ai assistant event translation into the Harness streaming protocol.
|
|
369
|
+
*
|
|
370
|
+
* pi-ai tool-call arguments are parsed objects while the Harness keeps their
|
|
371
|
+
* raw JSON representation. pi-ai also reports failures as terminal stream
|
|
372
|
+
* events, which this module maps into Harness finish chunks.
|
|
373
|
+
*
|
|
374
|
+
* @module dsh-llm-pi-ai/stream
|
|
375
|
+
*/
|
|
376
|
+
/**
|
|
377
|
+
* Map pi-ai usage (reasoning folded into output by pi-ai).
|
|
378
|
+
* @param usage - cumulative usage from the terminal pi-ai event.
|
|
379
|
+
* @returns harness counts; cache fields appear only when non-zero (pi-ai reports zeros, not absence).
|
|
380
|
+
*/
|
|
381
|
+
function mapUsage(usage) {
|
|
382
|
+
return {
|
|
383
|
+
inputTokens: usage.input,
|
|
384
|
+
outputTokens: usage.output,
|
|
385
|
+
...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
|
|
386
|
+
...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {}
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
function classifyPiAiError(message) {
|
|
390
|
+
if (/\b(?:401|403)\b/.test(message)) return "AUTH";
|
|
391
|
+
if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE;
|
|
392
|
+
if (/\b429\b|rate.?limit/i.test(message)) return "RATE_LIMIT";
|
|
393
|
+
if (/\b400\b|invalid.?request/i.test(message)) return "INVALID_REQUEST";
|
|
394
|
+
if (/\b5\d\d\b/.test(message)) return "SERVER";
|
|
395
|
+
if (/\btime(?:d)?\s*out\b|timeout/i.test(message)) return "TIMEOUT";
|
|
396
|
+
if (/stream ended (?:before|without)\b/i.test(message)) return "TRANSPORT";
|
|
397
|
+
if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message) || /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message) || /\bterminated\b|premature close/i.test(message)) return "TRANSPORT";
|
|
398
|
+
return "PI_AI_ERROR";
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Map a terminal pi-ai event to the harness finish reason.
|
|
402
|
+
* @param message - the assistant message carried by the `done` or `error` event.
|
|
403
|
+
* @param contextWindow - resolved catalog capacity for usage-based overflow detection.
|
|
404
|
+
* @returns the mapped harness reason. Recognized error text, `stop` usage above
|
|
405
|
+
* `contextWindow`, and zero-output `length` usage that fills the window map
|
|
406
|
+
* to `CONTEXT_WINDOW_EXCEEDED`; a `stop` with no content blocks maps to an
|
|
407
|
+
* `EMPTY_RESPONSE` error.
|
|
408
|
+
*/
|
|
409
|
+
function mapStopReason(message, contextWindow) {
|
|
410
|
+
const piAiOverflow = isContextOverflow(message, contextWindow);
|
|
411
|
+
const harnessOverflow = message.stopReason === "error" && message.errorMessage !== void 0 && isContextWindowExceededError(message.errorMessage);
|
|
412
|
+
if (piAiOverflow || harnessOverflow) return {
|
|
413
|
+
kind: "error",
|
|
414
|
+
failure: {
|
|
415
|
+
message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`,
|
|
416
|
+
code: CONTEXT_WINDOW_EXCEEDED_CODE
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
switch (message.stopReason) {
|
|
420
|
+
case "stop":
|
|
421
|
+
if (message.content.length === 0) return {
|
|
422
|
+
kind: "error",
|
|
423
|
+
failure: {
|
|
424
|
+
message: `model "${message.model}" returned a completed response with no content`,
|
|
425
|
+
code: EMPTY_RESPONSE_CODE
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
return { kind: "stop" };
|
|
429
|
+
case "length": return { kind: "max-tokens" };
|
|
430
|
+
case "toolUse": return { kind: "tool-calls" };
|
|
431
|
+
case "aborted": return {
|
|
432
|
+
kind: "aborted",
|
|
433
|
+
failure: {
|
|
434
|
+
message: message.errorMessage ?? "pi-ai stream aborted",
|
|
435
|
+
code: "ABORTED"
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
case "error": {
|
|
439
|
+
const text = message.errorMessage ?? "pi-ai stream error";
|
|
440
|
+
return {
|
|
441
|
+
kind: "error",
|
|
442
|
+
failure: {
|
|
443
|
+
message: text,
|
|
444
|
+
code: classifyPiAiError(text)
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Translate the pi-ai event stream into StreamChunks. pi-ai never throws
|
|
452
|
+
* mid-stream — failures arrive as `error` events, which become error/aborted
|
|
453
|
+
* `finish` chunks (the harness protocol's other error-delivery style).
|
|
454
|
+
* @param events - one assistant turn's pi-ai event stream.
|
|
455
|
+
* @param contextWindow - resolved catalog capacity for usage-based overflow detection.
|
|
456
|
+
* @returns the harness chunks, ending with `usage` then `finish`; throws
|
|
457
|
+
* `LlmError` (`STREAM_CLOSED`) if the source ends without a terminal event.
|
|
458
|
+
*/
|
|
459
|
+
async function* toStreamChunks(events, contextWindow) {
|
|
460
|
+
const toolIds = /* @__PURE__ */ new Map();
|
|
461
|
+
for await (const event of events) switch (event.type) {
|
|
462
|
+
case "start": break;
|
|
463
|
+
case "text_start":
|
|
464
|
+
yield {
|
|
465
|
+
type: "block-start",
|
|
466
|
+
index: event.contentIndex,
|
|
467
|
+
blockType: "text"
|
|
468
|
+
};
|
|
469
|
+
break;
|
|
470
|
+
case "text_delta":
|
|
471
|
+
yield {
|
|
472
|
+
type: "text-delta",
|
|
473
|
+
index: event.contentIndex,
|
|
474
|
+
text: event.delta
|
|
475
|
+
};
|
|
476
|
+
break;
|
|
477
|
+
case "text_end":
|
|
478
|
+
yield {
|
|
479
|
+
type: "block-end",
|
|
480
|
+
index: event.contentIndex,
|
|
481
|
+
block: {
|
|
482
|
+
type: "text",
|
|
483
|
+
text: event.content
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
break;
|
|
487
|
+
case "thinking_start":
|
|
488
|
+
yield {
|
|
489
|
+
type: "block-start",
|
|
490
|
+
index: event.contentIndex,
|
|
491
|
+
blockType: "reasoning"
|
|
492
|
+
};
|
|
493
|
+
break;
|
|
494
|
+
case "thinking_delta":
|
|
495
|
+
yield {
|
|
496
|
+
type: "reasoning-delta",
|
|
497
|
+
index: event.contentIndex,
|
|
498
|
+
text: event.delta
|
|
499
|
+
};
|
|
500
|
+
break;
|
|
501
|
+
case "thinking_end":
|
|
502
|
+
yield {
|
|
503
|
+
type: "block-end",
|
|
504
|
+
index: event.contentIndex,
|
|
505
|
+
block: {
|
|
506
|
+
type: "reasoning",
|
|
507
|
+
text: event.content
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
break;
|
|
511
|
+
case "toolcall_start": {
|
|
512
|
+
const partial = event.partial.content[event.contentIndex];
|
|
513
|
+
const id = partial?.type === "toolCall" ? partial.id : "";
|
|
514
|
+
const name = partial?.type === "toolCall" ? partial.name : "";
|
|
515
|
+
toolIds.set(event.contentIndex, {
|
|
516
|
+
id,
|
|
517
|
+
name
|
|
518
|
+
});
|
|
519
|
+
yield {
|
|
520
|
+
type: "block-start",
|
|
521
|
+
index: event.contentIndex,
|
|
522
|
+
blockType: "tool-call"
|
|
523
|
+
};
|
|
524
|
+
break;
|
|
525
|
+
}
|
|
526
|
+
case "toolcall_delta": {
|
|
527
|
+
const known = toolIds.get(event.contentIndex);
|
|
528
|
+
yield {
|
|
529
|
+
type: "tool-call-delta",
|
|
530
|
+
index: event.contentIndex,
|
|
531
|
+
id: CallId(known?.id ?? ""),
|
|
532
|
+
...known?.name !== void 0 && known.name.length > 0 ? { name: known.name } : {},
|
|
533
|
+
argumentsDelta: event.delta
|
|
534
|
+
};
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
case "toolcall_end":
|
|
538
|
+
yield {
|
|
539
|
+
type: "block-end",
|
|
540
|
+
index: event.contentIndex,
|
|
541
|
+
block: {
|
|
542
|
+
type: "tool-call",
|
|
543
|
+
id: CallId(event.toolCall.id),
|
|
544
|
+
name: event.toolCall.name,
|
|
545
|
+
arguments: JSON.stringify(event.toolCall.arguments)
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
break;
|
|
549
|
+
case "done":
|
|
550
|
+
yield {
|
|
551
|
+
type: "usage",
|
|
552
|
+
usage: mapUsage(event.message.usage)
|
|
553
|
+
};
|
|
554
|
+
yield {
|
|
555
|
+
type: "finish",
|
|
556
|
+
reason: mapStopReason(event.message, contextWindow),
|
|
557
|
+
replayState: toPiReplayState(event.message)
|
|
558
|
+
};
|
|
559
|
+
return;
|
|
560
|
+
case "error":
|
|
561
|
+
yield {
|
|
562
|
+
type: "usage",
|
|
563
|
+
usage: mapUsage(event.error.usage)
|
|
564
|
+
};
|
|
565
|
+
yield {
|
|
566
|
+
type: "finish",
|
|
567
|
+
reason: mapStopReason(event.error, contextWindow)
|
|
568
|
+
};
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
throw new LlmError("pi-ai event stream ended without done/error", "STREAM_CLOSED");
|
|
572
|
+
}
|
|
573
|
+
//#endregion
|
|
574
|
+
//#region lib/types/adapter.js
|
|
575
|
+
/**
|
|
576
|
+
* Generic pi-ai-backed implementation of the Harness LLM seam.
|
|
577
|
+
*
|
|
578
|
+
* Each resolution produces one **immutable** snapshot — the profiles plus a
|
|
579
|
+
* `Models` collection holding the `Provider` each route built — and an
|
|
580
|
+
* operation captures a whole snapshot before its first `await`. A
|
|
581
|
+
* configuration change builds a *new* collection rather than mutating the one
|
|
582
|
+
* in use, because `Models.streamSimple()` is lazy: it resolves the provider
|
|
583
|
+
* when the stream is first consumed, which is after the credential await, so a
|
|
584
|
+
* mutated collection would let a request that started under one configuration
|
|
585
|
+
* finish under another — or fail with a provider that no longer exists. This is
|
|
586
|
+
* what makes the seam's per-step call freeze (`llm.prepareCall()`) hold all the
|
|
587
|
+
* way down: switching models mid-reply takes effect on the next step, never
|
|
588
|
+
* inside the one in flight.
|
|
589
|
+
*
|
|
590
|
+
* Credentials stay outside that collection. The harness resolves a route's key
|
|
591
|
+
* through its own seam and passes it as the request's `apiKey` option, which
|
|
592
|
+
* pi-ai treats as the highest-priority auth override — so `Models` never holds
|
|
593
|
+
* a credential store and the harness keeps its fail-loud reference semantics.
|
|
594
|
+
*
|
|
595
|
+
* @module dsh-llm-pi-ai/adapter
|
|
596
|
+
*/
|
|
597
|
+
var __addDisposableResource = function(env, value, async) {
|
|
598
|
+
if (value !== null && value !== void 0) {
|
|
599
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
600
|
+
var dispose, inner;
|
|
601
|
+
if (async) {
|
|
602
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
603
|
+
dispose = value[Symbol.asyncDispose];
|
|
604
|
+
}
|
|
605
|
+
if (dispose === void 0) {
|
|
606
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
607
|
+
dispose = value[Symbol.dispose];
|
|
608
|
+
if (async) inner = dispose;
|
|
609
|
+
}
|
|
610
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
611
|
+
if (inner) dispose = function() {
|
|
612
|
+
try {
|
|
613
|
+
inner.call(this);
|
|
614
|
+
} catch (e) {
|
|
615
|
+
return Promise.reject(e);
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
env.stack.push({
|
|
619
|
+
value,
|
|
620
|
+
dispose,
|
|
621
|
+
async
|
|
622
|
+
});
|
|
623
|
+
} else if (async) env.stack.push({ async: true });
|
|
624
|
+
return value;
|
|
625
|
+
};
|
|
626
|
+
var __disposeResources = (function(SuppressedError) {
|
|
627
|
+
return function(env) {
|
|
628
|
+
function fail(e) {
|
|
629
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
630
|
+
env.hasError = true;
|
|
631
|
+
}
|
|
632
|
+
var r, s = 0;
|
|
633
|
+
function next() {
|
|
634
|
+
while (r = env.stack.pop()) try {
|
|
635
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
636
|
+
if (r.dispose) {
|
|
637
|
+
var result = r.dispose.call(r.value);
|
|
638
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
|
|
639
|
+
fail(e);
|
|
640
|
+
return next();
|
|
641
|
+
});
|
|
642
|
+
} else s |= 1;
|
|
643
|
+
} catch (e) {
|
|
644
|
+
fail(e);
|
|
645
|
+
}
|
|
646
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
647
|
+
if (env.hasError) throw env.error;
|
|
648
|
+
}
|
|
649
|
+
return next();
|
|
650
|
+
};
|
|
651
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
|
|
652
|
+
var e = new Error(message);
|
|
653
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
654
|
+
});
|
|
655
|
+
/** Copy profile stream knobs into pi-ai's common option vocabulary. */
|
|
656
|
+
function profileOptions(profile, reasoning, apiKey) {
|
|
657
|
+
const enabledReasoning = reasoning === "off" ? void 0 : reasoning;
|
|
658
|
+
return {
|
|
659
|
+
...apiKey === void 0 ? {} : { apiKey },
|
|
660
|
+
...enabledReasoning === void 0 ? {} : { reasoning: enabledReasoning },
|
|
661
|
+
...profile.thinkingBudgets === void 0 ? {} : { thinkingBudgets: profile.thinkingBudgets },
|
|
662
|
+
...profile.cacheRetention === void 0 ? {} : { cacheRetention: profile.cacheRetention },
|
|
663
|
+
...profile.transport === void 0 ? {} : { transport: profile.transport },
|
|
664
|
+
...profile.timeoutMs === void 0 ? {} : { timeoutMs: profile.timeoutMs },
|
|
665
|
+
...profile.websocketConnectTimeoutMs === void 0 ? {} : { websocketConnectTimeoutMs: profile.websocketConnectTimeoutMs },
|
|
666
|
+
maxRetries: 0
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* The profile default this exact model can actually take, for DESCRIBING it.
|
|
671
|
+
* A configured level the model does not support yields none rather than
|
|
672
|
+
* throwing: `resolveModel` builds the model catalog, and a catalog that fails
|
|
673
|
+
* takes its whole provider out of every picker — so one mis-set profile field
|
|
674
|
+
* would hide every model on the route, including the ones that support the
|
|
675
|
+
* level. The request path still refuses, which is where a bad configuration
|
|
676
|
+
* belongs: describing what a model can do must not fail because a deployment
|
|
677
|
+
* asked it for something it cannot.
|
|
678
|
+
* @param model - the resolved model descriptor.
|
|
679
|
+
* @param effort - the profile's configured level, if any.
|
|
680
|
+
* @returns the level when this model supports it, otherwise undefined.
|
|
681
|
+
*/
|
|
682
|
+
function describableReasoningLevel(model, effort) {
|
|
683
|
+
if (effort === void 0) return void 0;
|
|
684
|
+
return getSupportedThinkingLevels(model).some((level) => level === effort) ? effort : void 0;
|
|
685
|
+
}
|
|
686
|
+
/** Validate an explicit Harness/profile effort without invoking pi-ai's clamp. */
|
|
687
|
+
function resolveReasoningLevel(model, effort) {
|
|
688
|
+
if (effort === void 0) return void 0;
|
|
689
|
+
if (getSupportedThinkingLevels(model).some((level) => level === effort)) return effort;
|
|
690
|
+
throw new LlmError(`pi-ai provider "${model.provider}" model "${model.id}" does not support reasoning effort "${effort}"`, "UNSUPPORTED_REASONING_EFFORT");
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Selectable reasoning efforts for one model, or nothing at all.
|
|
694
|
+
*
|
|
695
|
+
* A model that carries no reasoning metadata — every hand-declared one, and
|
|
696
|
+
* every catalog model pi-ai marks as non-reasoning — is reported by pi-ai as
|
|
697
|
+
* supporting the single level `off`. Passing that through would offer a control
|
|
698
|
+
* that cannot do what it says: `off` is translated to *omitting* the reasoning
|
|
699
|
+
* option, which for such a model is byte-for-byte the same request as naming no
|
|
700
|
+
* effort — so a provider whose own default is to think would keep thinking with
|
|
701
|
+
* `off` selected. Omitting `reasoning` entirely is the seam's way of saying the
|
|
702
|
+
* capability is unavailable, which leaves the surface offering only the
|
|
703
|
+
* provider's default.
|
|
704
|
+
* @param model - the resolved model descriptor.
|
|
705
|
+
* @param defaultLevel - the profile's configured effort, already validated.
|
|
706
|
+
* @returns the `reasoning` field, or an empty object when none can be offered.
|
|
707
|
+
*/
|
|
708
|
+
function reasoningInfo(model, defaultLevel) {
|
|
709
|
+
if (!model.reasoning) return {};
|
|
710
|
+
return { reasoning: {
|
|
711
|
+
efforts: getSupportedThinkingLevels(model).map((level) => ({
|
|
712
|
+
id: ReasoningEffortId(level),
|
|
713
|
+
name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`
|
|
714
|
+
})),
|
|
715
|
+
...defaultLevel === void 0 ? {} : { defaultEffort: ReasoningEffortId(defaultLevel) }
|
|
716
|
+
} };
|
|
717
|
+
}
|
|
718
|
+
/** Merge deployment headers while removing case-insensitive attribution collisions. */
|
|
719
|
+
function requestHeaders(headers) {
|
|
720
|
+
const attribution = attributionHeaders();
|
|
721
|
+
const reserved = new Set(Object.keys(attribution).map((name) => name.toLowerCase()));
|
|
722
|
+
return {
|
|
723
|
+
...Object.fromEntries(Object.entries(headers ?? {}).filter(([name]) => !reserved.has(name.toLowerCase()))),
|
|
724
|
+
...attribution
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* pi-ai-backed multi-provider adapter. Each operation reads the current
|
|
729
|
+
* profiles, so a configuration change reaches the next request without a
|
|
730
|
+
* restart; model descriptors come from the collection those profiles built.
|
|
731
|
+
*/
|
|
732
|
+
var PiAiAdapter = class extends LlmAdapter {
|
|
733
|
+
config;
|
|
734
|
+
snapshot;
|
|
735
|
+
constructor(config) {
|
|
736
|
+
super();
|
|
737
|
+
this.config = config;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* The snapshot for the current profiles. Resolution memoizes its result, so
|
|
741
|
+
* an unchanged configuration is recognized by identity; a changed one gets a
|
|
742
|
+
* brand-new collection, leaving any snapshot an operation already captured
|
|
743
|
+
* untouched for as long as that operation holds it.
|
|
744
|
+
*/
|
|
745
|
+
current() {
|
|
746
|
+
const profiles = this.config.profiles();
|
|
747
|
+
if (this.snapshot?.profiles === profiles) return this.snapshot;
|
|
748
|
+
const models = createModels();
|
|
749
|
+
for (const profile of profiles.values()) models.setProvider(profile.piProvider);
|
|
750
|
+
this.snapshot = {
|
|
751
|
+
profiles,
|
|
752
|
+
models
|
|
753
|
+
};
|
|
754
|
+
return this.snapshot;
|
|
755
|
+
}
|
|
756
|
+
/** The profile for one route within one snapshot, or the not-owned failure. */
|
|
757
|
+
profileOf(snapshot, provider) {
|
|
758
|
+
const profile = snapshot.profiles.get(provider);
|
|
759
|
+
if (profile === void 0) throw new LlmError(`pi-ai adapter does not own provider "${provider}"`, "NO_ADAPTER");
|
|
760
|
+
return profile;
|
|
761
|
+
}
|
|
762
|
+
/** The configured descriptor for one exact route/model pair within one snapshot. */
|
|
763
|
+
modelOf(snapshot, provider, model) {
|
|
764
|
+
this.profileOf(snapshot, provider);
|
|
765
|
+
const resolved = snapshot.models.getModel(provider, model);
|
|
766
|
+
if (resolved === void 0) throw new LlmError(`pi-ai provider "${provider}" has no configured model "${model}"`, "UNKNOWN_MODEL");
|
|
767
|
+
return resolved;
|
|
768
|
+
}
|
|
769
|
+
providerInfo(provider) {
|
|
770
|
+
return {
|
|
771
|
+
id: provider,
|
|
772
|
+
name: this.current().profiles.get(provider)?.displayName ?? provider
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
providerRetryPolicy(provider) {
|
|
776
|
+
return this.current().profiles.get(provider)?.retryPolicy;
|
|
777
|
+
}
|
|
778
|
+
listModels(provider) {
|
|
779
|
+
return Promise.resolve().then(() => {
|
|
780
|
+
const snapshot = this.current();
|
|
781
|
+
this.profileOf(snapshot, provider);
|
|
782
|
+
return snapshot.models.getModels(provider).map((model) => ({
|
|
783
|
+
provider,
|
|
784
|
+
id: model.id,
|
|
785
|
+
name: model.name,
|
|
786
|
+
inputModalities: [...model.input]
|
|
787
|
+
}));
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
resolveModel(provider, model, _signal) {
|
|
791
|
+
return Promise.resolve().then(() => {
|
|
792
|
+
const snapshot = this.current();
|
|
793
|
+
const profile = this.profileOf(snapshot, provider);
|
|
794
|
+
const resolvedModel = this.modelOf(snapshot, provider, model);
|
|
795
|
+
const defaultLevel = describableReasoningLevel(resolvedModel, profile.reasoning);
|
|
796
|
+
const configuredMaxTokens = profile.configuredMaxTokens.get(model);
|
|
797
|
+
return {
|
|
798
|
+
provider,
|
|
799
|
+
id: model,
|
|
800
|
+
name: resolvedModel.name,
|
|
801
|
+
inputModalities: [...resolvedModel.input],
|
|
802
|
+
context: { contextWindow: resolvedModel.contextWindow },
|
|
803
|
+
...configuredMaxTokens === void 0 ? {} : { defaultMaxTokens: configuredMaxTokens },
|
|
804
|
+
...reasoningInfo(resolvedModel, defaultLevel)
|
|
805
|
+
};
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
async *stream(options) {
|
|
809
|
+
const env_1 = {
|
|
810
|
+
stack: [],
|
|
811
|
+
error: void 0,
|
|
812
|
+
hasError: false
|
|
813
|
+
};
|
|
814
|
+
try {
|
|
815
|
+
if (options.stop !== void 0) throw new LlmError("llm-pi-ai does not support GenerateOptions.stop", "UNSUPPORTED_OPTION");
|
|
816
|
+
const snapshot = this.current();
|
|
817
|
+
const profile = this.profileOf(snapshot, options.provider);
|
|
818
|
+
const model = this.modelOf(snapshot, options.provider, options.model);
|
|
819
|
+
const reasoning = resolveReasoningLevel(model, options.reasoningEffort ?? profile.reasoning);
|
|
820
|
+
const apiKey = await this.config.resolveApiKey(options.provider, profile);
|
|
821
|
+
const consumer = new AbortController();
|
|
822
|
+
const upstream = options.signal === void 0 ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]);
|
|
823
|
+
const streamIdleTimeoutMs = profile.streamIdleTimeoutMs;
|
|
824
|
+
const watchdog = __addDisposableResource(env_1, idleWatchdog(upstream, streamIdleTimeoutMs, "LLM_STREAM_IDLE_TIMEOUT"), false);
|
|
825
|
+
try {
|
|
826
|
+
const containsImage = options.messages.some((message) => contentHasImage(message.content));
|
|
827
|
+
if (containsImage && !model.input.includes("image")) throw new LlmError(`pi-ai model "${model.id}" does not support image input`, "UNSUPPORTED_CONTENT");
|
|
828
|
+
const attachments = containsImage ? this.config.resolveAttachments?.() : void 0;
|
|
829
|
+
if (containsImage && attachments === void 0) throw new LlmError("pi-ai image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
|
|
830
|
+
const context = attachments === void 0 ? toPiContext(options) : await toPiContext(options, attachments);
|
|
831
|
+
const iterator = toStreamChunks(snapshot.models.streamSimple(model, context, {
|
|
832
|
+
...profileOptions(profile, reasoning, apiKey),
|
|
833
|
+
...options.temperature === void 0 ? {} : { temperature: options.temperature },
|
|
834
|
+
...options.maxTokens === void 0 ? {} : { maxTokens: options.maxTokens },
|
|
835
|
+
...options.sessionId === void 0 ? {} : { sessionId: String(options.sessionId) },
|
|
836
|
+
signal: watchdog.signal,
|
|
837
|
+
headers: requestHeaders(profile.headers)
|
|
838
|
+
}), model.contextWindow)[Symbol.asyncIterator]();
|
|
839
|
+
let exhausted = false;
|
|
840
|
+
try {
|
|
841
|
+
while (true) {
|
|
842
|
+
const result = await watchdog.next(iterator);
|
|
843
|
+
const timeout = timeoutOf(watchdog.signal, "LLM_STREAM_IDLE_TIMEOUT");
|
|
844
|
+
if (timeout !== void 0) throw timeout;
|
|
845
|
+
if (result.done) {
|
|
846
|
+
exhausted = true;
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
yield result.value;
|
|
850
|
+
}
|
|
851
|
+
} finally {
|
|
852
|
+
if (!exhausted) {
|
|
853
|
+
consumer.abort("pi-ai stream consumer stopped");
|
|
854
|
+
try {
|
|
855
|
+
await iterator.return(void 0);
|
|
856
|
+
} catch (_abortedSdkTeardown) {}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
} catch (error) {
|
|
860
|
+
if (timeoutOf(watchdog.signal, "LLM_STREAM_IDLE_TIMEOUT") !== void 0) throw new LlmError(`pi-ai stream idle timeout after ${streamIdleTimeoutMs}ms`, "TIMEOUT", { cause: error });
|
|
861
|
+
if (options.signal?.aborted) throw new LlmError("pi-ai request aborted by caller", "ABORTED", { cause: error });
|
|
862
|
+
throw error;
|
|
863
|
+
} finally {
|
|
864
|
+
consumer.abort("pi-ai stream consumer stopped");
|
|
865
|
+
}
|
|
866
|
+
} catch (e_1) {
|
|
867
|
+
env_1.error = e_1;
|
|
868
|
+
env_1.hasError = true;
|
|
869
|
+
} finally {
|
|
870
|
+
__disposeResources(env_1);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
//#endregion
|
|
875
|
+
//#region lib/types/catalog.js
|
|
876
|
+
/**
|
|
877
|
+
* Materialization of one provider route's model catalog. The installed pi-ai
|
|
878
|
+
* catalog supplies defaults keyed by model id, and a profile's own model
|
|
879
|
+
* entries override them field by field, so a route naming a catalog provider
|
|
880
|
+
* stays configuration-free while a route pi-ai has never heard of is fully
|
|
881
|
+
* describable from `settings.yaml`.
|
|
882
|
+
*
|
|
883
|
+
* Every pi-ai `Model` field the harness cannot default is required here rather
|
|
884
|
+
* than at request time: an unserviceable route fails while its configuration is
|
|
885
|
+
* being resolved, which is the earliest point that can name the offending key.
|
|
886
|
+
*
|
|
887
|
+
* @module dsh-llm-pi-ai/catalog
|
|
888
|
+
*/
|
|
889
|
+
/**
|
|
890
|
+
* Pricing for a model the installed catalog does not describe. The harness
|
|
891
|
+
* never reads pi-ai's cost metadata — `replay.ts` zeroes it and no consumer
|
|
892
|
+
* reports spend — so this is the absence of a fact, not a configurable rate.
|
|
893
|
+
*/
|
|
894
|
+
const NO_COST = {
|
|
895
|
+
input: 0,
|
|
896
|
+
output: 0,
|
|
897
|
+
cacheRead: 0,
|
|
898
|
+
cacheWrite: 0
|
|
899
|
+
};
|
|
900
|
+
/** Every request modality a profile may declare. */
|
|
901
|
+
const MODALITIES = Object.keys({
|
|
902
|
+
text: true,
|
|
903
|
+
image: true
|
|
904
|
+
});
|
|
905
|
+
/**
|
|
906
|
+
* One entry's modality list, or `undefined` when it states no answer. Absent
|
|
907
|
+
* and empty mean the same thing — `[]` describes a model that accepts nothing
|
|
908
|
+
* and could serve no request — which is what makes an entry naming a catalog
|
|
909
|
+
* model without declaring modalities keep the catalog's, since the config
|
|
910
|
+
* schema materializes `[]` for an absent array.
|
|
911
|
+
* @param configured - the list a `models` or `modelOverrides` entry supplied.
|
|
912
|
+
* @returns the declared modalities, or `undefined` to ask the next level.
|
|
913
|
+
*/
|
|
914
|
+
function declaredInput(configured) {
|
|
915
|
+
return configured === void 0 || configured.length === 0 ? void 0 : [...configured];
|
|
916
|
+
}
|
|
917
|
+
/** Every pi-ai thinking level a profile may declare, in escalation order. */
|
|
918
|
+
const THINKING_LEVELS = Object.keys({
|
|
919
|
+
off: true,
|
|
920
|
+
minimal: true,
|
|
921
|
+
low: true,
|
|
922
|
+
medium: true,
|
|
923
|
+
high: true,
|
|
924
|
+
xhigh: true,
|
|
925
|
+
max: true
|
|
926
|
+
});
|
|
927
|
+
/** Reasoning-dispatch wire formats a profile may name, most-reached first. */
|
|
928
|
+
const SUPPORTED_THINKING_FORMATS = Object.keys({
|
|
929
|
+
"openai": true,
|
|
930
|
+
"deepseek": true,
|
|
931
|
+
"openrouter": true,
|
|
932
|
+
"together": true,
|
|
933
|
+
"zai": true,
|
|
934
|
+
"qwen": true,
|
|
935
|
+
"string-thinking": true,
|
|
936
|
+
"ant-ling": true
|
|
937
|
+
});
|
|
938
|
+
let providerIndex;
|
|
939
|
+
/**
|
|
940
|
+
* Installed catalog providers by id, constructed once. Each entry owns the API
|
|
941
|
+
* implementations for its own models, which is why a catalog route reuses this
|
|
942
|
+
* provider instead of being rebuilt from parts.
|
|
943
|
+
* @returns the catalog provider index.
|
|
944
|
+
*/
|
|
945
|
+
function catalogProviders() {
|
|
946
|
+
providerIndex ??= new Map(builtinProviders().map((provider) => [provider.id, provider]));
|
|
947
|
+
return providerIndex;
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* The installed catalog provider for one route, when pi-ai ships one.
|
|
951
|
+
* @param provider - provider route key.
|
|
952
|
+
* @returns the catalog provider, or `undefined` for a route pi-ai does not ship.
|
|
953
|
+
*/
|
|
954
|
+
function catalogProvider(provider) {
|
|
955
|
+
return catalogProviders().get(provider);
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Every provider route the installed pi-ai catalog ships.
|
|
959
|
+
* @returns the catalog provider ids.
|
|
960
|
+
*/
|
|
961
|
+
function catalogProviderIds() {
|
|
962
|
+
return getBuiltinProviders();
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Whether the installed catalog provider for one route declares an api-key
|
|
966
|
+
* method — the only authentication this adapter obtains on its own.
|
|
967
|
+
*
|
|
968
|
+
* A key is what the harness resolves through its own credential seam and hands
|
|
969
|
+
* pi-ai per request. pi-ai's other method, OAuth, resolves from a *stored*
|
|
970
|
+
* OAuth credential alone: `resolveProviderAuth` has no ambient path for it,
|
|
971
|
+
* this adapter builds its `Models` collection with no credential store, and
|
|
972
|
+
* nothing here runs a login flow. So a provider offering OAuth by itself
|
|
973
|
+
* leaves nothing for this adapter to authenticate with, and the posture such a
|
|
974
|
+
* provider invites — no key configured, credentials discovered by the provider
|
|
975
|
+
* — fails every request with `Provider is not configured`.
|
|
976
|
+
* @param provider - provider route key.
|
|
977
|
+
* @returns whether the catalog provider takes an api key; false for a route
|
|
978
|
+
* pi-ai does not ship, which the caller answers for separately.
|
|
979
|
+
*/
|
|
980
|
+
function catalogProviderTakesApiKey(provider) {
|
|
981
|
+
return catalogProvider(provider)?.auth.apiKey !== void 0;
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* The installed catalog models for one route, indexed by model id.
|
|
985
|
+
* @param provider - provider route key.
|
|
986
|
+
* @returns catalog models by id; empty for a route pi-ai does not ship.
|
|
987
|
+
*/
|
|
988
|
+
function catalogModels(provider) {
|
|
989
|
+
if (!catalogProviders().has(provider)) return /* @__PURE__ */ new Map();
|
|
990
|
+
const models = getBuiltinModels(provider);
|
|
991
|
+
return new Map(models.map((model) => [model.id, model]));
|
|
992
|
+
}
|
|
993
|
+
/** Report a route the deployment cannot serve, naming the settings key at fault. */
|
|
994
|
+
function invalid(provider, detail) {
|
|
995
|
+
throw new Error(`llm-pi-ai: provider "${provider}" ${detail}`);
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* The one wire protocol a catalog route's shipped models agree on. This is what
|
|
999
|
+
* lets a deployment add a model the installed catalog has not caught up with —
|
|
1000
|
+
* a provider's newest release — without restating the protocol its siblings
|
|
1001
|
+
* already use. A route whose shipped models disagree (an OpenAI-style catalog
|
|
1002
|
+
* spanning Responses and Chat Completions) has no such answer, so a model it
|
|
1003
|
+
* does not describe must name its protocol at the route.
|
|
1004
|
+
*/
|
|
1005
|
+
function sharedCatalogApi(defaults) {
|
|
1006
|
+
const apis = /* @__PURE__ */ new Set();
|
|
1007
|
+
for (const model of defaults.values()) apis.add(model.api);
|
|
1008
|
+
return apis.size === 1 ? [...apis][0] : void 0;
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Resolve one model's reasoning capability from its declared efforts.
|
|
1012
|
+
*
|
|
1013
|
+
* A declared dict translates to pi-ai's `thinkingLevelMap` with every level
|
|
1014
|
+
* decided explicitly: declared levels carry their wire spelling, undeclared
|
|
1015
|
+
* levels are pinned to `null` (unsupported). Pinning matters because pi-ai's
|
|
1016
|
+
* own defaulting is asymmetric — an absent key means "supported" for the five
|
|
1017
|
+
* base levels but "unsupported" for `xhigh`/`max` — and a profile author
|
|
1018
|
+
* should not need to know that. A declared `off` with no value is the one
|
|
1019
|
+
* exception: it stays absent from the map, which pi-ai reads as "supported,
|
|
1020
|
+
* send nothing" — the correct dispatch where not thinking is the parameter's
|
|
1021
|
+
* absence — while `off` with a value sends that value.
|
|
1022
|
+
* @param provider - provider route key, for diagnostics.
|
|
1023
|
+
* @param entry - the configured model entry.
|
|
1024
|
+
* @param base - the installed catalog entry of the same id, when one exists.
|
|
1025
|
+
* @returns the reasoning fields the materialized model carries.
|
|
1026
|
+
*/
|
|
1027
|
+
function resolveModelReasoning(provider, entry, base) {
|
|
1028
|
+
const efforts = entry.reasoningEfforts;
|
|
1029
|
+
if (efforts === void 0) return { reasoning: base?.reasoning ?? false };
|
|
1030
|
+
if (efforts === false) return { reasoning: false };
|
|
1031
|
+
if (efforts === null || Object.keys(efforts).length === 0) invalid(provider, `model "${entry.id}" has an empty reasoningEfforts; declare the offered levels, set false for a non-reasoning model, or omit the field to keep the installed catalog's capability`);
|
|
1032
|
+
const declared = THINKING_LEVELS.flatMap((level) => {
|
|
1033
|
+
const wire = efforts[level];
|
|
1034
|
+
return wire === void 0 ? [] : [[level, wire]];
|
|
1035
|
+
});
|
|
1036
|
+
for (const [level, wire] of declared) if (wire === null) {
|
|
1037
|
+
if (level !== "off") invalid(provider, `model "${entry.id}" reasoningEfforts.${level} needs the wire value dispatch should send; only "off" may leave it empty`);
|
|
1038
|
+
} else if (wire.length === 0) invalid(provider, `model "${entry.id}" reasoningEfforts.${level} must not be an empty string`);
|
|
1039
|
+
if (!declared.some(([level]) => level !== "off")) invalid(provider, `model "${entry.id}" reasoningEfforts offers no level beyond "off"; declare a thinking level, or set reasoningEfforts to false for a non-reasoning model`);
|
|
1040
|
+
const map = {};
|
|
1041
|
+
for (const level of THINKING_LEVELS) {
|
|
1042
|
+
const wire = efforts[level];
|
|
1043
|
+
if (wire === void 0) map[level] = null;
|
|
1044
|
+
else if (wire !== null) map[level] = wire;
|
|
1045
|
+
}
|
|
1046
|
+
return {
|
|
1047
|
+
reasoning: true,
|
|
1048
|
+
thinkingLevelMap: map
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Resolve one model's compat block from the profile's reasoning switches.
|
|
1053
|
+
*
|
|
1054
|
+
* A model switch wins over the route switch; whatever neither sets keeps the
|
|
1055
|
+
* installed entry's value, and a field no layer decides falls through to
|
|
1056
|
+
* pi-ai's baseURL-derived detection. Only an `openai-completions` model takes
|
|
1057
|
+
* the switches at all: a model-level switch on any other protocol fails
|
|
1058
|
+
* resolution, while a route-level default skips past such models — the same
|
|
1059
|
+
* posture as the route-level `reasoning` default, which also must not fail
|
|
1060
|
+
* models it does not fit.
|
|
1061
|
+
* @param provider - provider route key, for diagnostics.
|
|
1062
|
+
* @param entry - the configured model entry.
|
|
1063
|
+
* @param route - the route-level switches, when any.
|
|
1064
|
+
* @param base - the installed catalog entry of the same id, when one exists.
|
|
1065
|
+
* @param api - the model's resolved wire protocol.
|
|
1066
|
+
* @returns a `compat` field to spread into the model, or nothing.
|
|
1067
|
+
*/
|
|
1068
|
+
function resolveModelCompat(provider, entry, route, base, api) {
|
|
1069
|
+
const thinkingFormat = entry.compat?.thinkingFormat ?? route?.thinkingFormat;
|
|
1070
|
+
const supportsReasoningEffort = entry.compat?.supportsReasoningEffort ?? route?.supportsReasoningEffort;
|
|
1071
|
+
if (thinkingFormat === void 0 && supportsReasoningEffort === void 0) return {};
|
|
1072
|
+
if (api !== "openai-completions") {
|
|
1073
|
+
if (entry.compat?.thinkingFormat !== void 0 || entry.compat?.supportsReasoningEffort !== void 0) invalid(provider, `model "${entry.id}" sets compat reasoning switches, but its api is "${api}"; thinkingFormat and supportsReasoningEffort exist only on openai-completions`);
|
|
1074
|
+
return {};
|
|
1075
|
+
}
|
|
1076
|
+
return { compat: {
|
|
1077
|
+
...base?.api === api ? base.compat : void 0,
|
|
1078
|
+
...thinkingFormat === void 0 ? {} : { thinkingFormat },
|
|
1079
|
+
...supportsReasoningEffort === void 0 ? {} : { supportsReasoningEffort }
|
|
1080
|
+
} };
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Materialize one route's catalog by merging the installed catalog defaults
|
|
1084
|
+
* under the configured entries. A route with no configured `models` serves the
|
|
1085
|
+
* installed catalog unchanged, which is what keeps an existing
|
|
1086
|
+
* `providers: { deepseek: { apiKeyEnv: … } }` profile working untouched.
|
|
1087
|
+
* @param request - the route-level catalog facts.
|
|
1088
|
+
* @returns the materialized models and the explicitly configured request caps.
|
|
1089
|
+
*/
|
|
1090
|
+
function resolveRouteModels(request) {
|
|
1091
|
+
const { provider } = request;
|
|
1092
|
+
const defaults = catalogModels(provider);
|
|
1093
|
+
const providerBaseUrl = catalogProvider(provider)?.baseUrl;
|
|
1094
|
+
const configured = request.models ?? [];
|
|
1095
|
+
const overrides = request.modelOverrides ?? {};
|
|
1096
|
+
for (const [id, override] of Object.entries(overrides)) {
|
|
1097
|
+
if (id.length === 0) invalid(provider, "has a modelOverrides entry with an empty model id");
|
|
1098
|
+
if (defaults.size === 0) invalid(provider, `sets modelOverrides for "${id}", but the installed catalog does not describe this route; a declared route spells every model out in its models list`);
|
|
1099
|
+
if (configured.length > 0) invalid(provider, `sets modelOverrides for "${id}" beside a models list; models already replaces the served catalog, so declare the fields on its entries`);
|
|
1100
|
+
if (!defaults.has(id)) invalid(provider, `modelOverrides names "${id}", which the installed catalog does not describe`);
|
|
1101
|
+
if ("id" in override) invalid(provider, `modelOverrides entry "${id}" sets "id", which is the dict key`);
|
|
1102
|
+
}
|
|
1103
|
+
const entries = configured.length > 0 ? configured : [...defaults.values()].map((model) => ({
|
|
1104
|
+
id: model.id,
|
|
1105
|
+
...overrides[model.id]
|
|
1106
|
+
}));
|
|
1107
|
+
if (entries.length === 0) invalid(provider, "resolves no models; the installed catalog does not describe this route, so its models must be listed in configuration");
|
|
1108
|
+
const routeApi = sharedCatalogApi(defaults);
|
|
1109
|
+
const routeCompatDefined = request.compat?.thinkingFormat !== void 0 || request.compat?.supportsReasoningEffort !== void 0;
|
|
1110
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1111
|
+
const configuredMaxTokens = /* @__PURE__ */ new Map();
|
|
1112
|
+
const models = entries.map((entry) => {
|
|
1113
|
+
if (entry.id.length === 0) invalid(provider, "has a model with an empty id");
|
|
1114
|
+
if (seen.has(entry.id)) invalid(provider, `lists model "${entry.id}" more than once`);
|
|
1115
|
+
seen.add(entry.id);
|
|
1116
|
+
const base = defaults.get(entry.id);
|
|
1117
|
+
const api = request.api ?? base?.api ?? routeApi;
|
|
1118
|
+
if (api === void 0) invalid(provider, `model "${entry.id}" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks`);
|
|
1119
|
+
const baseUrl = request.baseURL ?? base?.baseUrl ?? providerBaseUrl;
|
|
1120
|
+
if (baseUrl === void 0) invalid(provider, `model "${entry.id}" needs a baseURL; the installed catalog does not describe this route`);
|
|
1121
|
+
const contextWindow = entry.contextWindow ?? base?.contextWindow ?? request.defaultContextWindow;
|
|
1122
|
+
if (!Number.isInteger(contextWindow) || contextWindow <= 0) invalid(provider, `model "${entry.id}" contextWindow must be a positive integer`);
|
|
1123
|
+
const maxTokens = entry.maxTokens ?? base?.maxTokens ?? request.defaultMaxTokens;
|
|
1124
|
+
if (!Number.isInteger(maxTokens) || maxTokens <= 0) invalid(provider, `model "${entry.id}" maxTokens must be a positive integer`);
|
|
1125
|
+
if (entry.maxTokens !== void 0) configuredMaxTokens.set(entry.id, entry.maxTokens);
|
|
1126
|
+
return {
|
|
1127
|
+
...base,
|
|
1128
|
+
id: entry.id,
|
|
1129
|
+
name: entry.name ?? base?.name ?? entry.id,
|
|
1130
|
+
api,
|
|
1131
|
+
provider,
|
|
1132
|
+
baseUrl,
|
|
1133
|
+
input: declaredInput(entry.input) ?? base?.input ?? [...request.defaultInput],
|
|
1134
|
+
cost: base?.cost ?? NO_COST,
|
|
1135
|
+
contextWindow,
|
|
1136
|
+
maxTokens,
|
|
1137
|
+
...resolveModelReasoning(provider, entry, base),
|
|
1138
|
+
...resolveModelCompat(provider, entry, request.compat, base, api)
|
|
1139
|
+
};
|
|
1140
|
+
});
|
|
1141
|
+
if (routeCompatDefined && !models.some((model) => model.api === "openai-completions")) invalid(provider, "sets compat reasoning switches, but no model on the route speaks openai-completions; thinkingFormat and supportsReasoningEffort exist only on that protocol");
|
|
1142
|
+
return {
|
|
1143
|
+
models,
|
|
1144
|
+
configuredMaxTokens
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
//#endregion
|
|
1148
|
+
//#region lib/types/provider.js
|
|
1149
|
+
/**
|
|
1150
|
+
* Construction of the pi-ai `Provider` that one configured route registers into
|
|
1151
|
+
* the adapter's `Models` collection.
|
|
1152
|
+
*
|
|
1153
|
+
* Two constructions, one decision: a route the installed catalog ships, whose
|
|
1154
|
+
* profile does not override the wire protocol, **reuses that catalog provider**
|
|
1155
|
+
* with its models replaced — the catalog provider owns API implementations this
|
|
1156
|
+
* package cannot reconstruct (Bedrock loads its Smithy module through a
|
|
1157
|
+
* separate entry point), so rebuilding it from parts would silently narrow
|
|
1158
|
+
* which providers work. Every other route — one pi-ai has never heard of, or a
|
|
1159
|
+
* catalog route pointed at a different protocol — is built by `createProvider`
|
|
1160
|
+
* over the protocol table below.
|
|
1161
|
+
*
|
|
1162
|
+
* Credentials never reach this module's storage: the harness resolves a route's
|
|
1163
|
+
* key through `ctx.credentials` before the request enters pi-ai and hands it
|
|
1164
|
+
* over as a stream option, which `Models` presents to `resolve()` as the
|
|
1165
|
+
* credential key.
|
|
1166
|
+
*
|
|
1167
|
+
* @module dsh-llm-pi-ai/provider
|
|
1168
|
+
*/
|
|
1169
|
+
/**
|
|
1170
|
+
* Wire protocols a configured route may name, mapped to pi-ai's lazily loaded
|
|
1171
|
+
* implementations. Each entry is the factory that pi-ai's matching provider
|
|
1172
|
+
* factory uses, so a hand-declared route reaches exactly the implementation a
|
|
1173
|
+
* catalog route would.
|
|
1174
|
+
*
|
|
1175
|
+
* The table is deliberately narrow: the protocols a hand-declared route
|
|
1176
|
+
* actually reaches for today, each completely describable with a key, an
|
|
1177
|
+
* endpoint, and headers. Bedrock signs with SigV4 over AWS credentials and a
|
|
1178
|
+
* region, Vertex needs a project, a location, and application-default
|
|
1179
|
+
* credentials, Azure needs provider environment plus an api-version, and Codex
|
|
1180
|
+
* authenticates through OAuth — none of which this configuration shape can
|
|
1181
|
+
* express, so offering them would hand back a provider that cannot
|
|
1182
|
+
* authenticate. The remainder are absent for want of a consumer rather than a
|
|
1183
|
+
* blocker: each is one line here once a deployment needs it. Catalog routes
|
|
1184
|
+
* still reach every protocol through their own provider; only an explicit
|
|
1185
|
+
* override is refused.
|
|
1186
|
+
*/
|
|
1187
|
+
const PROTOCOLS = {
|
|
1188
|
+
"openai-completions": openAICompletionsApi,
|
|
1189
|
+
"openai-responses": openAIResponsesApi,
|
|
1190
|
+
"anthropic-messages": anthropicMessagesApi
|
|
1191
|
+
};
|
|
1192
|
+
/**
|
|
1193
|
+
* Every wire protocol a configured route may name, most-reached first. The
|
|
1194
|
+
* order is the table's and therefore stable; a configuration surface offering
|
|
1195
|
+
* a choice presents the first as its default, which is why the protocol a
|
|
1196
|
+
* hand-declared gateway most often speaks — and the one endpoint interrogation
|
|
1197
|
+
* can read — leads.
|
|
1198
|
+
* @returns the supported protocol identifiers.
|
|
1199
|
+
*/
|
|
1200
|
+
function supportedProtocols() {
|
|
1201
|
+
return Object.keys(PROTOCOLS);
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Api-key auth for a route the harness authenticates itself. `Models` calls
|
|
1205
|
+
* this after the adapter has already resolved the route's credential, so a
|
|
1206
|
+
* missing key here is not this layer's failure: a named-but-unresolvable
|
|
1207
|
+
* reference has already failed the request with `MISSING_CREDENTIAL`, and a
|
|
1208
|
+
* route naming no credential at all is deliberately unauthenticated. Reporting
|
|
1209
|
+
* it as configured hands the decision to the protocol, which is where the
|
|
1210
|
+
* requirement actually lives — pi-ai's OpenAI-compatible implementation, for
|
|
1211
|
+
* one, still insists on a key or an `Authorization` header of its own.
|
|
1212
|
+
* @param name - display name used as the resolution's status label.
|
|
1213
|
+
* @returns the api-key auth for a harness-authenticated route.
|
|
1214
|
+
*/
|
|
1215
|
+
function harnessApiKeyAuth(name) {
|
|
1216
|
+
return {
|
|
1217
|
+
name,
|
|
1218
|
+
resolve: ({ credential }) => Promise.resolve({
|
|
1219
|
+
auth: credential?.key === void 0 ? {} : { apiKey: credential.key },
|
|
1220
|
+
source: name
|
|
1221
|
+
})
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
/**
|
|
1225
|
+
* The auth one route resolves its credential through.
|
|
1226
|
+
*
|
|
1227
|
+
* A catalog route keeps the installed provider's own auth, which is what
|
|
1228
|
+
* preserves provider-native ambient discovery for a profile naming no
|
|
1229
|
+
* credential. That holds even when the profile repoints the protocol: which
|
|
1230
|
+
* environment a provider reads is a property of the provider, not of the wire
|
|
1231
|
+
* format its models speak.
|
|
1232
|
+
*
|
|
1233
|
+
* The single addition covers a catalog provider that offers no api-key method
|
|
1234
|
+
* at all. pi-ai resolves a request's `apiKey` override only when the provider
|
|
1235
|
+
* declares one (`resolveProviderAuth` checks `provider.auth.apiKey` before
|
|
1236
|
+
* honouring the override), so an OAuth-only provider — `openai-codex` is the
|
|
1237
|
+
* one the installed catalog ships — would refuse a profile's explicit key with
|
|
1238
|
+
* `Provider is not configured` before any request went out. Adding the harness
|
|
1239
|
+
* method beside the provider's own restores that route. A keyless profile adds
|
|
1240
|
+
* nothing and still reports the honest refusal, because this adapter resolves
|
|
1241
|
+
* credentials through its own seam and holds no OAuth store to fall back on.
|
|
1242
|
+
* @param spec - the resolved route facts.
|
|
1243
|
+
* @param catalog - the installed catalog provider, when pi-ai ships one.
|
|
1244
|
+
* @returns the auth to construct this route's provider with.
|
|
1245
|
+
*/
|
|
1246
|
+
function routeAuth(spec, catalog) {
|
|
1247
|
+
if (catalog === void 0) return { apiKey: harnessApiKeyAuth(spec.displayName) };
|
|
1248
|
+
if (catalog.auth.apiKey !== void 0 || !spec.namesCredential) return catalog.auth;
|
|
1249
|
+
return {
|
|
1250
|
+
...catalog.auth,
|
|
1251
|
+
apiKey: harnessApiKeyAuth(spec.displayName)
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Reuse an installed catalog provider with this route's models and identity.
|
|
1256
|
+
* Model dispatch stays with the catalog provider, so its API implementations,
|
|
1257
|
+
* compatibility quirks, and ambient credential discovery are preserved exactly.
|
|
1258
|
+
* Catalog-owned dynamic refresh is dropped: this route's catalog is the
|
|
1259
|
+
* settings document, and a background refresh would contradict it.
|
|
1260
|
+
*/
|
|
1261
|
+
function reuseCatalogProvider(base, spec) {
|
|
1262
|
+
const baseUrl = spec.baseURL ?? base.baseUrl;
|
|
1263
|
+
return {
|
|
1264
|
+
id: spec.provider,
|
|
1265
|
+
name: spec.displayName,
|
|
1266
|
+
...baseUrl === void 0 ? {} : { baseUrl },
|
|
1267
|
+
auth: routeAuth(spec, base),
|
|
1268
|
+
getModels: () => spec.models,
|
|
1269
|
+
stream: (model, context, options) => base.stream(model, context, options),
|
|
1270
|
+
streamSimple: (model, context, options) => base.streamSimple(model, context, options)
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Build the pi-ai provider for one resolved route.
|
|
1275
|
+
* @param spec - the resolved route facts.
|
|
1276
|
+
* @returns the provider to register in the adapter's `Models` collection.
|
|
1277
|
+
* @throws Error when the route names a wire protocol this build cannot serve.
|
|
1278
|
+
*/
|
|
1279
|
+
function buildProvider(spec) {
|
|
1280
|
+
const catalog = catalogProvider(spec.provider);
|
|
1281
|
+
if (catalog !== void 0 && spec.api === void 0) return reuseCatalogProvider(catalog, spec);
|
|
1282
|
+
const factory = spec.api === void 0 ? void 0 : PROTOCOLS[spec.api];
|
|
1283
|
+
if (factory === void 0) throw new Error(`llm-pi-ai: provider "${spec.provider}" names api "${spec.api}", which this build cannot serve; supported protocols are ${supportedProtocols().join(", ")}`);
|
|
1284
|
+
return createProvider({
|
|
1285
|
+
id: spec.provider,
|
|
1286
|
+
name: spec.displayName,
|
|
1287
|
+
...spec.baseURL === void 0 ? {} : { baseUrl: spec.baseURL },
|
|
1288
|
+
auth: routeAuth(spec, catalog),
|
|
1289
|
+
models: spec.models,
|
|
1290
|
+
api: factory()
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
//#endregion
|
|
1294
|
+
//#region lib/types/config.js
|
|
1295
|
+
/**
|
|
1296
|
+
* Configuration schema and provider-profile validation for the pi-ai adapter.
|
|
1297
|
+
* Profiles are a dict keyed by provider route, so the composition base and a
|
|
1298
|
+
* user-settings layer merge per provider and the route set is structural.
|
|
1299
|
+
*
|
|
1300
|
+
* A route key is not required to name an installed pi-ai provider. When it does,
|
|
1301
|
+
* that provider's endpoint, protocol, display name, and model catalog are the
|
|
1302
|
+
* profile's defaults and the profile overrides them field by field; when it does
|
|
1303
|
+
* not, the profile is the whole provider declaration. Resolution therefore ends
|
|
1304
|
+
* in a built pi-ai `Provider` per route: everything a request needs is decided
|
|
1305
|
+
* once, while the configuration key that made a route unserviceable can still be
|
|
1306
|
+
* named in the failure.
|
|
1307
|
+
*
|
|
1308
|
+
* @module dsh-llm-pi-ai/config
|
|
1309
|
+
*/
|
|
1310
|
+
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
|
1311
|
+
const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e5;
|
|
1312
|
+
/** Context capacity assumed for a model neither configuration nor the catalog sizes. */
|
|
1313
|
+
const DEFAULT_CONTEXT_WINDOW = 262144;
|
|
1314
|
+
/** Output capability assumed for a model neither configuration nor the catalog sizes. */
|
|
1315
|
+
const DEFAULT_MAX_TOKENS = 32768;
|
|
1316
|
+
/**
|
|
1317
|
+
* Modalities assumed for a model neither configuration nor the catalog
|
|
1318
|
+
* declares. Text is the floor every supported protocol certainly carries, so
|
|
1319
|
+
* this is the absence of a declaration rather than a guess at the endpoint:
|
|
1320
|
+
* nothing can interrogate a gateway for its modalities, and the two wrong
|
|
1321
|
+
* answers do not cost the same. Under-claiming refuses the image before it is
|
|
1322
|
+
* attached, naming the model. Over-claiming admits one the provider then
|
|
1323
|
+
* rejects mid-turn, after the message is durable, leaving the session
|
|
1324
|
+
* repeating a request that cannot succeed.
|
|
1325
|
+
*/
|
|
1326
|
+
const DEFAULT_INPUT = ["text"];
|
|
1327
|
+
const thinkingBudgets = z.object({
|
|
1328
|
+
minimal: z.number(),
|
|
1329
|
+
low: z.number(),
|
|
1330
|
+
medium: z.number(),
|
|
1331
|
+
high: z.number()
|
|
1332
|
+
});
|
|
1333
|
+
const compatProfile = z.object({
|
|
1334
|
+
thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS),
|
|
1335
|
+
supportsReasoningEffort: z.boolean()
|
|
1336
|
+
});
|
|
1337
|
+
/**
|
|
1338
|
+
* Keys are the offered levels, values their wire spellings. A valueless key
|
|
1339
|
+
* (`off:`) survives validation because schemastery passes nullable data
|
|
1340
|
+
* through before any member schema runs — `z.const(null)` only controls the
|
|
1341
|
+
* error for non-null wrong values and what a configuration UI renders.
|
|
1342
|
+
* Only resolution decides which levels may leave the value empty, so the
|
|
1343
|
+
* diagnostic can name the route and model. The assertion narrows
|
|
1344
|
+
* schemastery's `Dict`, which types every literal key as required; dict
|
|
1345
|
+
* validation checks only present keys, so the runtime value is a partial record.
|
|
1346
|
+
*/
|
|
1347
|
+
const reasoningEfforts = z.dict(z.union([z.string(), z.const(null)]), z.union(THINKING_LEVELS));
|
|
1348
|
+
/** The fields a `models` entry and a `modelOverrides` value share; only the id's home differs. */
|
|
1349
|
+
const modelFields = {
|
|
1350
|
+
name: z.string(),
|
|
1351
|
+
contextWindow: z.number().step(1).min(1),
|
|
1352
|
+
maxTokens: z.number().step(1).min(1),
|
|
1353
|
+
input: z.array(z.union(MODALITIES)),
|
|
1354
|
+
reasoningEfforts: z.union([z.const(false), reasoningEfforts]),
|
|
1355
|
+
compat: compatProfile
|
|
1356
|
+
};
|
|
1357
|
+
const modelProfile = z.object({
|
|
1358
|
+
id: z.string().required(),
|
|
1359
|
+
...modelFields
|
|
1360
|
+
});
|
|
1361
|
+
/** A {@link modelProfile} whose id lives in the `modelOverrides` dict key. */
|
|
1362
|
+
const modelOverride = z.object(modelFields);
|
|
1363
|
+
const profile = z.object({
|
|
1364
|
+
apiKeyEnv: z.string().role("credential-ref"),
|
|
1365
|
+
displayName: z.string(),
|
|
1366
|
+
api: z.union(supportedProtocols()),
|
|
1367
|
+
baseURL: z.string(),
|
|
1368
|
+
models: z.array(modelProfile),
|
|
1369
|
+
modelOverrides: z.dict(modelOverride),
|
|
1370
|
+
compat: compatProfile,
|
|
1371
|
+
defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
|
|
1372
|
+
defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS),
|
|
1373
|
+
defaultInput: z.array(z.union(MODALITIES)).default([...DEFAULT_INPUT]),
|
|
1374
|
+
headers: z.dict(z.string()),
|
|
1375
|
+
reasoning: z.union(THINKING_LEVELS),
|
|
1376
|
+
thinkingBudgets,
|
|
1377
|
+
cacheRetention: z.union([
|
|
1378
|
+
"none",
|
|
1379
|
+
"short",
|
|
1380
|
+
"long"
|
|
1381
|
+
]),
|
|
1382
|
+
transport: z.union([
|
|
1383
|
+
"sse",
|
|
1384
|
+
"websocket",
|
|
1385
|
+
"websocket-cached",
|
|
1386
|
+
"auto"
|
|
1387
|
+
]),
|
|
1388
|
+
timeoutMs: z.natural(),
|
|
1389
|
+
websocketConnectTimeoutMs: z.natural(),
|
|
1390
|
+
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
|
1391
|
+
retryPolicy: RetryPolicySchema
|
|
1392
|
+
});
|
|
1393
|
+
/** Runtime schema for {@link Config}. */
|
|
1394
|
+
const Config = z.object({ providers: z.dict(profile).default({}) });
|
|
1395
|
+
/**
|
|
1396
|
+
* Reject a section this adapter could not serve. Registered as the settings
|
|
1397
|
+
* namespace's validator, so an unserviceable profile is refused where it is
|
|
1398
|
+
* *written* — `settings.mutate` answers `settings-rejected` with the offending
|
|
1399
|
+
* route and model named — instead of being stored and then quietly disabling
|
|
1400
|
+
* every route in the namespace. It stays a validator rather than a schema
|
|
1401
|
+
* transform because the schema is also the shape a configuration surface
|
|
1402
|
+
* renders and the value an absent section resolves to; wrapping it would break
|
|
1403
|
+
* both.
|
|
1404
|
+
* @param config - the resolved section to check.
|
|
1405
|
+
* @throws Error naming the route and model that cannot be served.
|
|
1406
|
+
*/
|
|
1407
|
+
function assertServiceable(config) {
|
|
1408
|
+
resolveProfiles(config.providers);
|
|
1409
|
+
}
|
|
1410
|
+
/** Reject removed pre-release profile fields and name their replacements. */
|
|
1411
|
+
function rejectRemovedFields(provider, source) {
|
|
1412
|
+
const legacy = source;
|
|
1413
|
+
if ("provider" in legacy) throw new Error(`llm-pi-ai: provider "${provider}" sets "provider", which moved to the providers dict key`);
|
|
1414
|
+
if ("maxRetries" in legacy || "maxRetryDelayMs" in legacy) throw new Error(`llm-pi-ai: provider "${provider}" sets maxRetries or maxRetryDelayMs, which were removed; compose agent recovery with dsh-llm-retry`);
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Validate profiles and return a detached route-keyed map suitable for
|
|
1418
|
+
* per-request reads. This is the one explicit resolve step, so an omitted dict
|
|
1419
|
+
* resolves to the empty (dormant) route set here rather than through a hidden
|
|
1420
|
+
* fallback, and each route's models and pi-ai provider are materialized once.
|
|
1421
|
+
* @param providers - configured provider profiles keyed by route.
|
|
1422
|
+
* @returns validated profiles in configuration order.
|
|
1423
|
+
*/
|
|
1424
|
+
function resolveProfiles(providers) {
|
|
1425
|
+
if (Array.isArray(providers)) throw new Error("llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles");
|
|
1426
|
+
const entries = Object.entries(providers ?? {});
|
|
1427
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
1428
|
+
for (const [provider, source] of entries) {
|
|
1429
|
+
rejectRemovedFields(provider, source);
|
|
1430
|
+
if (provider.length === 0) throw new Error("llm-pi-ai: provider names must be non-empty");
|
|
1431
|
+
if (source.baseURL !== void 0 && source.baseURL.length === 0) throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`);
|
|
1432
|
+
if (source.displayName !== void 0 && source.displayName.length === 0) throw new Error(`llm-pi-ai: provider "${provider}" has an empty displayName`);
|
|
1433
|
+
const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? 3e5;
|
|
1434
|
+
if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) throw new Error(`llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
1435
|
+
const defaultInput = [...source.defaultInput ?? DEFAULT_INPUT];
|
|
1436
|
+
if (defaultInput.length === 0) throw new Error(`llm-pi-ai: provider "${provider}" defaultInput must name at least one modality`);
|
|
1437
|
+
const displayName = source.displayName ?? provider;
|
|
1438
|
+
const catalog = resolveRouteModels({
|
|
1439
|
+
provider,
|
|
1440
|
+
...source.api === void 0 ? {} : { api: source.api },
|
|
1441
|
+
...source.baseURL === void 0 ? {} : { baseURL: source.baseURL },
|
|
1442
|
+
...source.models === void 0 ? {} : { models: source.models },
|
|
1443
|
+
...source.modelOverrides === void 0 ? {} : { modelOverrides: source.modelOverrides },
|
|
1444
|
+
...source.compat === void 0 ? {} : { compat: source.compat },
|
|
1445
|
+
defaultInput,
|
|
1446
|
+
defaultContextWindow: source.defaultContextWindow ?? 262144,
|
|
1447
|
+
defaultMaxTokens: source.defaultMaxTokens ?? 32768
|
|
1448
|
+
});
|
|
1449
|
+
const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source;
|
|
1450
|
+
resolved.set(provider, {
|
|
1451
|
+
...rest,
|
|
1452
|
+
provider,
|
|
1453
|
+
displayName,
|
|
1454
|
+
...apiKeyEnv === void 0 ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) },
|
|
1455
|
+
streamIdleTimeoutMs,
|
|
1456
|
+
retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`),
|
|
1457
|
+
...rest.headers === void 0 ? {} : { headers: { ...rest.headers } },
|
|
1458
|
+
...rest.thinkingBudgets === void 0 ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } },
|
|
1459
|
+
configuredMaxTokens: catalog.configuredMaxTokens,
|
|
1460
|
+
piProvider: buildProvider({
|
|
1461
|
+
provider,
|
|
1462
|
+
displayName,
|
|
1463
|
+
...source.api === void 0 ? {} : { api: source.api },
|
|
1464
|
+
...source.baseURL === void 0 ? {} : { baseURL: source.baseURL },
|
|
1465
|
+
models: catalog.models,
|
|
1466
|
+
namesCredential: apiKeyEnv !== void 0
|
|
1467
|
+
})
|
|
1468
|
+
});
|
|
1469
|
+
}
|
|
1470
|
+
return resolved;
|
|
1471
|
+
}
|
|
1472
|
+
//#endregion
|
|
1473
|
+
//#region lib/types/discovery.js
|
|
1474
|
+
/**
|
|
1475
|
+
* Answering "which models can this provider serve?" for the configuration
|
|
1476
|
+
* surface's "fetch available models" action.
|
|
1477
|
+
*
|
|
1478
|
+
* A route the installed pi-ai catalog ships is answered **from that catalog**,
|
|
1479
|
+
* with no network call at all: pi-ai's registry is the authoritative list for
|
|
1480
|
+
* its own providers, and it carries the capacities a listing endpoint would
|
|
1481
|
+
* not disclose. Only a route the catalog does not describe — a gateway, a
|
|
1482
|
+
* self-hosted server — is interrogated over the wire.
|
|
1483
|
+
*
|
|
1484
|
+
* Neither path is a catalog refresh. Nothing here is stored: the request
|
|
1485
|
+
* carries a draft the user is still editing, and the reply is candidate
|
|
1486
|
+
* metadata the surface offers for adoption. `settings.yaml` remains the only
|
|
1487
|
+
* thing that decides what a route serves.
|
|
1488
|
+
*
|
|
1489
|
+
* Only OpenAI-compatible protocols are interrogated. Their listing is the one
|
|
1490
|
+
* shape a gateway, a self-hosted server, and the official endpoints all agree
|
|
1491
|
+
* on, which is the case this action exists for; every other protocol reports
|
|
1492
|
+
* that it cannot be interrogated so the surface falls back to hand-entry
|
|
1493
|
+
* rather than guessing a response shape.
|
|
1494
|
+
*
|
|
1495
|
+
* @module dsh-llm-pi-ai/discovery
|
|
1496
|
+
*/
|
|
1497
|
+
/**
|
|
1498
|
+
* Protocols whose model listing this module can read: the two that speak
|
|
1499
|
+
* OpenAI's `GET /models` shape with bearer auth. Azure is absent despite its
|
|
1500
|
+
* OpenAI lineage — it authenticates with an `api-key` header and requires an
|
|
1501
|
+
* `api-version` query — and Codex authenticates through OAuth; guessing at
|
|
1502
|
+
* either would report an authentication failure as a provider with no models.
|
|
1503
|
+
* pi-ai's remaining protocols are absent for the same reason.
|
|
1504
|
+
*/
|
|
1505
|
+
const LISTABLE_PROTOCOLS = new Set(["openai-completions", "openai-responses"]);
|
|
1506
|
+
/**
|
|
1507
|
+
* Endpoint replies larger than this are refused. The endpoint is whatever URL
|
|
1508
|
+
* the user typed, so the ceiling holds on the bytes actually read rather than
|
|
1509
|
+
* on the length the server claims — the same two-stage shape `dsh-web-fetch`
|
|
1510
|
+
* uses for its own caller-supplied URLs, except that a truncated model listing
|
|
1511
|
+
* is not parseable, so overflow rejects instead of truncating.
|
|
1512
|
+
*/
|
|
1513
|
+
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
1514
|
+
/** A positive integer field of a listing entry, or `undefined` when absent or unusable. */
|
|
1515
|
+
function capacity(...candidates) {
|
|
1516
|
+
for (const candidate of candidates) if (typeof candidate === "number" && Number.isInteger(candidate) && candidate > 0) return candidate;
|
|
1517
|
+
}
|
|
1518
|
+
/** A non-empty string field of a listing entry, or `undefined`. */
|
|
1519
|
+
function label(...candidates) {
|
|
1520
|
+
for (const candidate of candidates) if (typeof candidate === "string" && candidate.length > 0) return candidate;
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Join the endpoint base with the listing path. The base is treated as a
|
|
1524
|
+
* prefix rather than a URL to resolve against, so a deployment path such as
|
|
1525
|
+
* `https://gateway.example/openai/v1` keeps its segments instead of losing
|
|
1526
|
+
* them to `URL` resolution.
|
|
1527
|
+
*/
|
|
1528
|
+
function listingUrl(baseURL) {
|
|
1529
|
+
return `${baseURL.replace(/\/+$/, "")}/models`;
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Read a reply body, refusing one that outgrows the ceiling. A declared length
|
|
1533
|
+
* is checked first so an honest server is turned away without transferring
|
|
1534
|
+
* anything; the accumulated total is what actually enforces the bound, because
|
|
1535
|
+
* a server that under-declares (or streams) tells us nothing up front.
|
|
1536
|
+
*/
|
|
1537
|
+
async function readBounded(response, url) {
|
|
1538
|
+
const oversized = () => new LlmError(`${url} answered with more than ${MAX_RESPONSE_BYTES} bytes`, "DISCOVERY_FAILED");
|
|
1539
|
+
const declared = Number(response.headers.get("content-length") ?? NaN);
|
|
1540
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
1541
|
+
await response.body?.cancel();
|
|
1542
|
+
throw oversized();
|
|
1543
|
+
}
|
|
1544
|
+
/* v8 ignore next -- fetch always exposes a body stream on a 2xx Response; the null guard is defensive. */
|
|
1545
|
+
if (response.body === null) return "";
|
|
1546
|
+
const reader = response.body.getReader();
|
|
1547
|
+
const chunks = [];
|
|
1548
|
+
let total = 0;
|
|
1549
|
+
try {
|
|
1550
|
+
for (;;) {
|
|
1551
|
+
const { done, value } = await reader.read();
|
|
1552
|
+
if (done) break;
|
|
1553
|
+
total += value.byteLength;
|
|
1554
|
+
if (total > MAX_RESPONSE_BYTES) throw oversized();
|
|
1555
|
+
chunks.push(value);
|
|
1556
|
+
}
|
|
1557
|
+
} finally {
|
|
1558
|
+
/* v8 ignore next 4 -- cancel() after a completed or abandoned read settles without rejecting; unobserved best-effort cleanup. */
|
|
1559
|
+
await reader.cancel().catch(() => {});
|
|
1560
|
+
}
|
|
1561
|
+
const body = new Uint8Array(total);
|
|
1562
|
+
let offset = 0;
|
|
1563
|
+
for (const chunk of chunks) {
|
|
1564
|
+
body.set(chunk, offset);
|
|
1565
|
+
offset += chunk.byteLength;
|
|
1566
|
+
}
|
|
1567
|
+
return new TextDecoder().decode(body);
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* Read one OpenAI-compatible listing reply. Entries without a usable id are
|
|
1571
|
+
* skipped rather than failing the whole interrogation: a single malformed row
|
|
1572
|
+
* should not deny the user the rest of a working endpoint's catalog.
|
|
1573
|
+
*/
|
|
1574
|
+
function readListing(body) {
|
|
1575
|
+
const data = body?.data;
|
|
1576
|
+
if (!Array.isArray(data)) throw new LlmError("the endpoint's model listing has no \"data\" array; enter this provider's models by hand", "DISCOVERY_FAILED");
|
|
1577
|
+
const models = [];
|
|
1578
|
+
for (const raw of data) {
|
|
1579
|
+
const entry = raw;
|
|
1580
|
+
const id = label(entry?.id);
|
|
1581
|
+
if (id === void 0) continue;
|
|
1582
|
+
const name = label(entry?.name, entry?.display_name);
|
|
1583
|
+
const contextWindow = capacity(entry?.context_window, entry?.context_length);
|
|
1584
|
+
const maxTokens = capacity(entry?.max_output_tokens, entry?.max_tokens);
|
|
1585
|
+
models.push({
|
|
1586
|
+
id,
|
|
1587
|
+
...name === void 0 ? {} : { name },
|
|
1588
|
+
...contextWindow === void 0 ? {} : { contextWindow },
|
|
1589
|
+
...maxTokens === void 0 ? {} : { maxTokens }
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
return models;
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* Accept one probe key, or refuse it before the header is built. Without this
|
|
1596
|
+
* the `fetch` below would throw a ByteString `TypeError` that this function's
|
|
1597
|
+
* catch reports as `could not reach <url>` — blaming the network for a local,
|
|
1598
|
+
* deterministic fault.
|
|
1599
|
+
* @param raw - the key typed into the form or read from storage.
|
|
1600
|
+
* @returns the trimmed, usable key.
|
|
1601
|
+
*/
|
|
1602
|
+
function usableProbeKey(raw) {
|
|
1603
|
+
const checked = normalizeApiKey(raw);
|
|
1604
|
+
if (checked.ok) return checked.value;
|
|
1605
|
+
throw new LlmError(checked.reason === "empty" ? "this provider's API key is blank; enter it on the Models page, or clear it to probe unauthenticated" : "this provider's API key contains characters no HTTP header can carry; paste the raw key only", INVALID_CREDENTIAL_CODE);
|
|
1606
|
+
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Interrogate one draft provider endpoint for the models it advertises.
|
|
1609
|
+
* @param request - the endpoint, protocol, and one-shot credential to use.
|
|
1610
|
+
* @param storedApiKey - the credential the named route already stored, asked
|
|
1611
|
+
* for only when the draft carries none and only on the path that reaches the
|
|
1612
|
+
* network. A configuration surface never holds a stored secret — it edits a
|
|
1613
|
+
* redacted descriptor — so without this an already-configured route would be
|
|
1614
|
+
* interrogated unauthenticated and answer 401.
|
|
1615
|
+
* @returns the advertised models in endpoint order.
|
|
1616
|
+
* @throws LlmError when the protocol has no readable listing, the endpoint
|
|
1617
|
+
* refuses or fails the request, or the reply is not a model listing.
|
|
1618
|
+
*/
|
|
1619
|
+
async function discoverModels(request, storedApiKey) {
|
|
1620
|
+
if (request.provider !== void 0) {
|
|
1621
|
+
const installed = catalogModels(request.provider);
|
|
1622
|
+
if (installed.size > 0) return [...installed.values()].map((model) => ({
|
|
1623
|
+
id: model.id,
|
|
1624
|
+
name: model.name,
|
|
1625
|
+
contextWindow: model.contextWindow,
|
|
1626
|
+
maxTokens: model.maxTokens
|
|
1627
|
+
}));
|
|
1628
|
+
}
|
|
1629
|
+
if (request.baseURL === void 0 || request.baseURL.length === 0) throw new LlmError(`pi-ai ships no catalog for provider "${request.provider ?? ""}", so its models can only come from its endpoint; set a baseURL, or enter this provider's models by hand`, "DISCOVERY_FAILED");
|
|
1630
|
+
const api = request.api ?? "openai-completions";
|
|
1631
|
+
if (!LISTABLE_PROTOCOLS.has(api)) throw new LlmError(`pi-ai protocol "${api}" has no model listing this build can read; enter this provider's models by hand`, "DISCOVERY_UNSUPPORTED");
|
|
1632
|
+
const url = listingUrl(request.baseURL);
|
|
1633
|
+
const supplied = request.apiKey ?? await storedApiKey?.();
|
|
1634
|
+
const apiKey = supplied === void 0 ? void 0 : usableProbeKey(supplied);
|
|
1635
|
+
let response;
|
|
1636
|
+
try {
|
|
1637
|
+
response = await fetch(url, {
|
|
1638
|
+
method: "GET",
|
|
1639
|
+
headers: {
|
|
1640
|
+
accept: "application/json",
|
|
1641
|
+
...apiKey === void 0 ? {} : { authorization: `Bearer ${apiKey}` },
|
|
1642
|
+
...attributionHeaders()
|
|
1643
|
+
},
|
|
1644
|
+
...request.signal === void 0 ? {} : { signal: request.signal }
|
|
1645
|
+
});
|
|
1646
|
+
} catch (error) {
|
|
1647
|
+
if (request.signal?.aborted) throw new LlmError("model discovery aborted by caller", "ABORTED", { cause: error });
|
|
1648
|
+
throw new LlmError(`could not reach ${url}`, "DISCOVERY_FAILED", { cause: error });
|
|
1649
|
+
}
|
|
1650
|
+
if (!response.ok) throw new LlmError(`${url} answered ${response.status}${response.status === 401 || response.status === 403 ? "; check the API key" : ""}`, "DISCOVERY_FAILED");
|
|
1651
|
+
let text;
|
|
1652
|
+
try {
|
|
1653
|
+
text = await readBounded(response, url);
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
if (request.signal?.aborted) throw new LlmError("model discovery aborted by caller", "ABORTED", { cause: error });
|
|
1656
|
+
throw error;
|
|
1657
|
+
}
|
|
1658
|
+
let body;
|
|
1659
|
+
try {
|
|
1660
|
+
body = JSON.parse(text);
|
|
1661
|
+
} catch (error) {
|
|
1662
|
+
throw new LlmError(`${url} did not answer with JSON`, "DISCOVERY_FAILED", { cause: error });
|
|
1663
|
+
}
|
|
1664
|
+
return readListing(body);
|
|
1665
|
+
}
|
|
1666
|
+
//#endregion
|
|
1667
|
+
//#region lib/types/index.js
|
|
1668
|
+
/**
|
|
1669
|
+
* Generic pi-ai-backed LLM adapter plugin. One plugin instance owns a dict of
|
|
1670
|
+
* provider routes; a route naming an installed pi-ai provider inherits that
|
|
1671
|
+
* provider's endpoint, protocol, and model catalog as defaults, and a route
|
|
1672
|
+
* pi-ai does not ship is declared outright. Profile facts resolve per request
|
|
1673
|
+
* over the optional `llm-pi-ai` user-settings section and the optional
|
|
1674
|
+
* credential seam, so a changed key, endpoint, model, or knob reaches the next
|
|
1675
|
+
* request without a restart; a changed *route set* (or a route's
|
|
1676
|
+
* registration-captured retry policy) re-registers the same adapter instance
|
|
1677
|
+
* in place.
|
|
1678
|
+
*
|
|
1679
|
+
* ```yaml
|
|
1680
|
+
* - id: llm
|
|
1681
|
+
* name: '@stackstackstack/dsh-llm-pi-ai'
|
|
1682
|
+
* config:
|
|
1683
|
+
* providers:
|
|
1684
|
+
* # Catalog route: everything but the credential comes from pi-ai.
|
|
1685
|
+
* openai:
|
|
1686
|
+
* apiKeyEnv: OPENAI_API_KEY
|
|
1687
|
+
* retryPolicy:
|
|
1688
|
+
* mode: normal
|
|
1689
|
+
* maxRetries: 2
|
|
1690
|
+
* # Catalog route with the catalog narrowed and one capacity corrected.
|
|
1691
|
+
* anthropic:
|
|
1692
|
+
* apiKeyEnv: ANTHROPIC_API_KEY
|
|
1693
|
+
* models:
|
|
1694
|
+
* - id: claude-sonnet-4-5
|
|
1695
|
+
* contextWindow: 200000
|
|
1696
|
+
* # Hand-declared route: pi-ai ships nothing under this key.
|
|
1697
|
+
* acme-gateway:
|
|
1698
|
+
* displayName: Acme Gateway
|
|
1699
|
+
* apiKeyEnv: ACME_GATEWAY_API_KEY
|
|
1700
|
+
* api: openai-completions
|
|
1701
|
+
* baseURL: https://gateway.acme.example/v1
|
|
1702
|
+
* # Reasoning dialect for a URL pi-ai cannot recognize.
|
|
1703
|
+
* compat:
|
|
1704
|
+
* thinkingFormat: deepseek
|
|
1705
|
+
* models:
|
|
1706
|
+
* - id: acme-large
|
|
1707
|
+
* name: Acme Large
|
|
1708
|
+
* contextWindow: 65536
|
|
1709
|
+
* maxTokens: 4096
|
|
1710
|
+
* - id: acme-think
|
|
1711
|
+
* name: Acme Think
|
|
1712
|
+
* contextWindow: 262144
|
|
1713
|
+
* maxTokens: 32768
|
|
1714
|
+
* # key = selectable level, value = wire spelling; only off may
|
|
1715
|
+
* # leave the value empty (supported, send nothing).
|
|
1716
|
+
* reasoningEfforts:
|
|
1717
|
+
* off:
|
|
1718
|
+
* high: high
|
|
1719
|
+
* max: ultra
|
|
1720
|
+
* ```
|
|
1721
|
+
*
|
|
1722
|
+
* @module @stackstackstack/dsh-llm-pi-ai
|
|
1723
|
+
*/
|
|
1724
|
+
const name = "llm-pi-ai";
|
|
1725
|
+
const inject = ["llm"];
|
|
1726
|
+
const NS = settingsNamespace("llm-pi-ai");
|
|
1727
|
+
/**
|
|
1728
|
+
* The registry captures these per route; a change here must re-register.
|
|
1729
|
+
* Sorted by provider so a settings document that merely reorders its keys is
|
|
1730
|
+
* not mistaken for a route change.
|
|
1731
|
+
*/
|
|
1732
|
+
function registrationFacts(profiles) {
|
|
1733
|
+
return [...profiles.entries()].map(([provider, profile]) => ({
|
|
1734
|
+
provider,
|
|
1735
|
+
displayName: profile.displayName,
|
|
1736
|
+
retryPolicy: profile.retryPolicy
|
|
1737
|
+
})).sort((left, right) => left.provider.localeCompare(right.provider));
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* The configurable-provider directory: every installed catalog route this
|
|
1741
|
+
* adapter can authenticate, plus every route the current profiles declare. A
|
|
1742
|
+
* hand-declared route has no catalog entry, so without this union it would
|
|
1743
|
+
* have no settings address and configuration surfaces could neither show nor
|
|
1744
|
+
* edit it.
|
|
1745
|
+
*
|
|
1746
|
+
* The profile half is unconditional, which is what keeps a route already
|
|
1747
|
+
* stored against a withheld provider editable and deletable rather than
|
|
1748
|
+
* stranded in the settings document with nothing on the page to remove it.
|
|
1749
|
+
* @param profiles - the currently resolved provider profiles.
|
|
1750
|
+
* @returns the directory entries in catalog order, declared routes last.
|
|
1751
|
+
*/
|
|
1752
|
+
function directoryEntries(profiles) {
|
|
1753
|
+
const catalog = new Set(catalogProviderIds());
|
|
1754
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1755
|
+
const declare = (provider, displayName) => {
|
|
1756
|
+
entries.set(provider, {
|
|
1757
|
+
provider,
|
|
1758
|
+
displayName,
|
|
1759
|
+
settingsNs: NS,
|
|
1760
|
+
settingsPath: ["providers", provider],
|
|
1761
|
+
declared: !catalog.has(provider)
|
|
1762
|
+
});
|
|
1763
|
+
};
|
|
1764
|
+
for (const provider of catalog) if (catalogProviderTakesApiKey(provider)) declare(provider, provider);
|
|
1765
|
+
for (const [provider, profile] of profiles) declare(provider, profile.displayName);
|
|
1766
|
+
return [...entries.values()];
|
|
1767
|
+
}
|
|
1768
|
+
/** Register one generic pi-ai adapter for all configured provider routes. */
|
|
1769
|
+
function apply(ctx, config) {
|
|
1770
|
+
let current = () => config;
|
|
1771
|
+
let lastRaw;
|
|
1772
|
+
let memoized;
|
|
1773
|
+
/**
|
|
1774
|
+
* The resolved profiles for the current configuration, memoized by the raw
|
|
1775
|
+
* snapshot's identity — which is also what makes the adapter's own snapshot
|
|
1776
|
+
* stable across operations that observe no change.
|
|
1777
|
+
*
|
|
1778
|
+
* No fallback for an unserviceable snapshot lives here: the section schema
|
|
1779
|
+
* resolves the whole profile set, so a write that could not be served is
|
|
1780
|
+
* refused where it is written, and the settings seam keeps a namespace's
|
|
1781
|
+
* last good value for a stored section that fails. Anything reaching this
|
|
1782
|
+
* point has already resolved once.
|
|
1783
|
+
*/
|
|
1784
|
+
const profiles = () => {
|
|
1785
|
+
const raw = current();
|
|
1786
|
+
if (raw === lastRaw && memoized !== void 0) return memoized;
|
|
1787
|
+
const next = resolveProfiles(raw.providers);
|
|
1788
|
+
lastRaw = raw;
|
|
1789
|
+
memoized = next;
|
|
1790
|
+
return next;
|
|
1791
|
+
};
|
|
1792
|
+
profiles();
|
|
1793
|
+
const resolveApiKey = async (provider, profile) => {
|
|
1794
|
+
const ref = profile.apiKeyEnv;
|
|
1795
|
+
if (ref === void 0) return void 0;
|
|
1796
|
+
const credentials = ctx.get("credentials");
|
|
1797
|
+
const hit = credentials !== void 0 ? (await credentials.resolve(ref))?.value : launchEnvironmentOf(ctx).get(ref)?.value;
|
|
1798
|
+
if (hit !== void 0 && hit.length > 0) return assertUsableApiKey(hit, "llm-pi-ai", ref);
|
|
1799
|
+
throw new LlmError(`llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not set — store ${ref} through the credentials service (the web Models page writes it) or export it, and remove apiKeyEnv only if this provider should authenticate from pi-ai's own environment discovery`, "MISSING_CREDENTIAL");
|
|
1800
|
+
};
|
|
1801
|
+
const adapter = new PiAiAdapter({
|
|
1802
|
+
profiles,
|
|
1803
|
+
resolveApiKey,
|
|
1804
|
+
resolveAttachments: () => ctx.get("attachments")
|
|
1805
|
+
});
|
|
1806
|
+
let directory;
|
|
1807
|
+
let directoryFacts;
|
|
1808
|
+
const ensureDirectory = () => {
|
|
1809
|
+
const entries = directoryEntries(profiles());
|
|
1810
|
+
if (deepEqualJson(entries, directoryFacts)) return;
|
|
1811
|
+
if (directory === void 0) directory = ctx.llm.registerConfigurableProviders(entries);
|
|
1812
|
+
else directory.replace(entries);
|
|
1813
|
+
directoryFacts = entries;
|
|
1814
|
+
};
|
|
1815
|
+
ensureDirectory();
|
|
1816
|
+
/**
|
|
1817
|
+
* The credential a named route already resolves, for an interrogation whose
|
|
1818
|
+
* draft carries none. A route being declared for the first time names no
|
|
1819
|
+
* profile yet, and a profile that names no credential defers to pi-ai's own
|
|
1820
|
+
* discovery, so both answer `undefined` and the endpoint is asked
|
|
1821
|
+
* unauthenticated — the same posture a request to that route would take.
|
|
1822
|
+
*/
|
|
1823
|
+
const storedApiKey = async (provider) => {
|
|
1824
|
+
if (provider === void 0) return void 0;
|
|
1825
|
+
const profile = profiles().get(provider);
|
|
1826
|
+
if (profile === void 0) return void 0;
|
|
1827
|
+
return resolveApiKey(provider, profile);
|
|
1828
|
+
};
|
|
1829
|
+
ctx.llm.registerModelDiscovery(NS, (request) => discoverModels(request, () => storedApiKey(request.provider)));
|
|
1830
|
+
let registration;
|
|
1831
|
+
let registeredFacts;
|
|
1832
|
+
const ensureRegistrationFacts = () => {
|
|
1833
|
+
const facts = registrationFacts(profiles());
|
|
1834
|
+
if (deepEqualJson(facts, registeredFacts)) return;
|
|
1835
|
+
const routes = [...profiles().keys()];
|
|
1836
|
+
if (registration === void 0) {
|
|
1837
|
+
if (routes.length === 0) {
|
|
1838
|
+
registeredFacts = facts;
|
|
1839
|
+
return;
|
|
1840
|
+
}
|
|
1841
|
+
registration = ctx.llm.registerAdapter(routes, adapter);
|
|
1842
|
+
} else registration.replace(routes);
|
|
1843
|
+
registeredFacts = facts;
|
|
1844
|
+
};
|
|
1845
|
+
ensureRegistrationFacts();
|
|
1846
|
+
installSettingsSection(ctx, NS, Config, config, {
|
|
1847
|
+
validate: assertServiceable,
|
|
1848
|
+
setSource: (source) => {
|
|
1849
|
+
current = source;
|
|
1850
|
+
},
|
|
1851
|
+
onChange: () => {
|
|
1852
|
+
try {
|
|
1853
|
+
ensureRegistrationFacts();
|
|
1854
|
+
} catch (error) {
|
|
1855
|
+
ctx.logger.error("llm-pi-ai: keeping the previously registered routes after a refused update");
|
|
1856
|
+
ctx.logger.error(error);
|
|
1857
|
+
}
|
|
1858
|
+
try {
|
|
1859
|
+
ensureDirectory();
|
|
1860
|
+
} catch (error) {
|
|
1861
|
+
ctx.logger.error("llm-pi-ai: keeping the previous configurable-provider directory after a refused update");
|
|
1862
|
+
ctx.logger.error(error);
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
//#endregion
|
|
1868
|
+
export { Config, PiAiAdapter, apply, inject, name, supportedProtocols };
|