@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/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 };