@toddzheng024/dscode-bundle 0.7.6 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/THIRD_PARTY_NOTICES.md +3 -3
  2. package/cordis.patch.yml +26 -5
  3. package/package.json +4 -4
  4. package/plugins/auto-review/index.mjs +6 -1
  5. package/plugins/compaction/tetris.mjs +65 -0
  6. package/plugins/compaction/threshold.mjs +46 -0
  7. package/plugins/credentials/index.mjs +2 -2
  8. package/plugins/i18n/messages.mjs +18 -0
  9. package/plugins/openrouter/adapter.mjs +157 -0
  10. package/plugins/openrouter/index.mjs +112 -0
  11. package/plugins/openrouter/models.mjs +151 -0
  12. package/plugins/openrouter/search.mjs +109 -0
  13. package/plugins/openrouter/wire.mjs +413 -0
  14. package/plugins/providers/catalog.mjs +18 -68
  15. package/plugins/providers/openrouter-account.mjs +171 -0
  16. package/plugins/session-metrics/balance.mjs +29 -19
  17. package/plugins/session-metrics/index.mjs +19 -9
  18. package/plugins/session-metrics/pricing.mjs +26 -6
  19. package/plugins/session-metrics/view.mjs +1 -1
  20. package/plugins/ultra/policy.mjs +0 -16
  21. package/presets/dscode/agent.cordis.yml +1 -1
  22. package/vendor/compaction-basic/index.js +983 -0
  23. package/vendor/compaction-basic/types/config.d.ts +37 -0
  24. package/vendor/compaction-basic/types/index.d.ts +84 -0
  25. package/vendor/compaction-basic/types/region.d.ts +65 -0
  26. package/vendor/compaction-basic/types/summarizer.d.ts +64 -0
  27. package/vendor/compaction-basic/types/types.d.ts +73 -0
  28. package/vendor/tui/dscode-providers/catalog.mjs +18 -68
  29. package/vendor/tui/dscode-providers/openrouter-account.mjs +171 -0
  30. package/vendor/tui/index.mjs +390 -165
  31. package/plugins/session-metrics/openrouter-prices.mjs +0 -96
  32. package/vendor/pi-ai/index.js +0 -2701
  33. package/vendor/pi-ai/types/adapter.d.ts +0 -105
  34. package/vendor/pi-ai/types/auth.d.ts +0 -60
  35. package/vendor/pi-ai/types/catalog.d.ts +0 -355
  36. package/vendor/pi-ai/types/config.d.ts +0 -208
  37. package/vendor/pi-ai/types/context.d.ts +0 -42
  38. package/vendor/pi-ai/types/discovery.d.ts +0 -43
  39. package/vendor/pi-ai/types/index.d.ts +0 -69
  40. package/vendor/pi-ai/types/login.d.ts +0 -21
  41. package/vendor/pi-ai/types/provider.d.ts +0 -59
  42. package/vendor/pi-ai/types/replay.d.ts +0 -63
  43. package/vendor/pi-ai/types/stream.d.ts +0 -43
  44. /package/vendor/{pi-ai → compaction-basic}/LICENSE +0 -0
@@ -1,2701 +0,0 @@
1
- // dscode-pi-ai-ultra-v1
2
- const ULTRA_POLICY = "DSCODE ULTRA — max reasoning with task-proportional execution.\nUse the depth needed to resolve actual uncertainty. Ultra is capability available on demand, not a requirement to maximize investigation, planning, delegation or verification. Briefly choose the smallest sufficient approach, then act. Do not repeatedly reassess a decision without new evidence.\nFor a bounded task such as adding a unit test, a small bug fix or a local edit: work directly in the parent. Read the target implementation, applicable instructions and a nearby relevant example; make the requested change; run the focused test and required project checks; fix observed failures; then report the result and stop. Do not scan the whole repository, add a formal plan, launch reviewers, broaden coverage or refactor unrelated code unless concrete evidence makes it necessary. Once acceptance criteria and required checks pass, do not invent additional work or rerun passing checks without a relevant change. If the task turns out to involve an unclear contract, a broad regression or a shared interface, expand only to resolve that specific uncertainty.\nWhen delegating, explicitly choose reasoning_effort for each child instead of automatically propagating ultra. Prefer low for bounded implementation, unit tests and factual lookup; high for nontrivial debugging or review; max for exceptional uncertainty or complex design. These are guidelines, not a substitute for judging the task. Use only efforts supported by the child model. Omission inherits the parent; choosing a child effort never changes the parent effort. Both subagent and subagent_fork support effort-only selection.\nFor substantial tasks, delegate only independent work that is likely to shorten completion or resolve meaningful uncertainty. Before delegating, identify the independent boundary, concrete wall-clock benefit, and useful work you will do while the child runs. Give each child a bounded objective, relevant context, file ownership and acceptance criteria. Give each child a unique name (1-10 characters, letters, digits and underscores, starting and ending with a letter, such as read_code) and address it as /name in send_message and interrupt_agent; a child addresses you as /. Prefer subagent_fork when established conversation history is relevant; use fresh subagent for self-contained work that does not benefit from that history. Fork excludes the current unfinished turn, so always give a self-contained assignment. Keep useful work for yourself while children run. For read-only work or tasks needing the parent's uncommitted files, omit worktree and assign disjoint files if writing. For independent parallel edits on a clean repository, set worktree: true; the child starts at HEAD in an isolated checkout. Never have multiple agents edit the same files in a shared workspace. Inspect and integrate worktree changes before removing the checkout.\nIn ultra use subagent/subagent_fork and send_message for delegation, not workflow or ralph. Use at most three child agents concurrently across this root session. Children complete their assigned work themselves and cannot delegate again; do not duplicate investigations across agents. The parent owns integration, verifies child claims, resolves conflicts and runs appropriate checks. Seek independent review of substantial changes when useful; do not add a review round merely because ultra is enabled. Parent/child messages are available; sibling direct messaging is not. Preserve the user's permission policy: ultra grants no extra authority. Reuse findings and stop delegating when coordination costs outweigh value. If progress stalls, name the concrete blocker and take the next diagnostic step rather than silently extending deliberation.";
3
- function piAiRequest(options) {
4
- const ultra = options.reasoningEffort === 'ultra';
5
- const next = { ...options, ...(options.tools ? { tools: options.tools.filter(tool => tool.name !== 'workflow' && tool.name !== 'ralph') } : {}), ...(ultra ? { reasoningEffort: 'max' } : {}) };
6
- if (!ultra || options.purpose || !options.tools?.some(t => t.name === 'subagent' || t.name === 'subagent_fork')) return next;
7
- if (typeof next.system === 'string') return { ...next, system: next.system + '\n\n' + ULTRA_POLICY };
8
- const [first, ...rest] = next.messages ?? [];
9
- if (first?.role === 'system' && Array.isArray(first.content)) return { ...next, messages: [{ ...first, content: [...first.content, { type: 'text', text: '\n\n' + ULTRA_POLICY }] }, ...rest] };
10
- return { ...next, messages: [{ role: 'system', content: [{ type: 'text', text: ULTRA_POLICY }] }, ...(next.messages ?? [])] };
11
- }
12
- import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
13
- import { CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, INVALID_CREDENTIAL_CODE, LlmAdapter, LlmError, QUOTA_EXCEEDED_CODE, ReasoningEffortId, RetryPolicySchema, assertUsableApiKey, attributionHeaders, contentHasImage, isContextWindowExceededError, isQuotaExceededError, normalizeApiKey, offloadRequestImagesWithPolicy, offloadedImageText, requestImageHandleText, resolveImageAttachmentAccess, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
14
- import { deepEqualJson } from "@deepseek-ai/dsh-util-values";
15
- import { createModels, createProvider, getSupportedThinkingLevels, isContextOverflow } from "@earendil-works/pi-ai";
16
- import { MAX_TIMER_DELAY_MS, idleWatchdog, timeoutOf } from "@deepseek-ai/dsh-timeout";
17
- import { brandString } from "@deepseek-ai/dsh-brand";
18
- import z from "@deepseek-ai/schemastery";
19
- import { credentialKey, credentialKeyId, credentialKeyScope, credentialRef, isCredentialKeySegment, isCredentialRefName } from "@deepseek-ai/dsh-credentials";
20
- import { builtinProviders, getBuiltinModels, getBuiltinProviders } from "@earendil-works/pi-ai/providers/all";
21
- import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
22
- import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
23
- import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
24
- import { homedir } from "node:os";
25
- import { access } from "node:fs/promises";
26
- import { resolve } from "node:path";
27
- //#region lib/types/replay.js
28
- /**
29
- * Durable pi-ai replay metadata and assistant-history reconstruction.
30
- *
31
- * Harness content remains the durable source for text and tool calls. This
32
- * module stores only the provider-native metadata needed to reconstruct a
33
- * pi-ai assistant message on a later request.
34
- *
35
- * @module dsh-llm-pi-ai/replay
36
- */
37
- /** Parse tool-call argument JSON; tolerate model malformations with {}. */
38
- function parseArguments(raw) {
39
- try {
40
- const parsed = JSON.parse(raw);
41
- if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
42
- } catch {}
43
- return {};
44
- }
45
- /** Construct the zero usage value required by historical pi-ai messages. */
46
- function emptyPiUsage() {
47
- return {
48
- input: 0,
49
- output: 0,
50
- cacheRead: 0,
51
- cacheWrite: 0,
52
- totalTokens: 0,
53
- cost: {
54
- input: 0,
55
- output: 0,
56
- cacheRead: 0,
57
- cacheWrite: 0,
58
- total: 0
59
- }
60
- };
61
- }
62
- /**
63
- * Project a successful pi-ai response into the minimal durable replay state.
64
- * The per-block half is index-aligned with the streamed blocks (pi-ai content
65
- * order), so `BlockAssembler` prunes an entry with its block whenever assembly
66
- * removes one.
67
- * @param message - completed native pi-ai assistant response.
68
- * @param requestedModel - request identity stored in the assistant source; defaults to the native model.
69
- * @returns the versioned lossless-JSON replay projection.
70
- */
71
- function toPiReplayState(message, requestedModel = message.model) {
72
- const responseModel = message.api === "anthropic-messages" && message.model !== requestedModel ? message.model : message.responseModel;
73
- return {
74
- response: {
75
- kind: "pi-ai",
76
- version: 2,
77
- api: message.api,
78
- provider: message.provider,
79
- model: requestedModel,
80
- ...responseModel === void 0 ? {} : { responseModel },
81
- ...message.responseId === void 0 ? {} : { responseId: message.responseId },
82
- ...message.providerThinkingLevel === void 0 ? {} : { providerThinkingLevel: message.providerThinkingLevel },
83
- stopReason: message.stopReason
84
- },
85
- blocks: message.content.map((block) => {
86
- switch (block.type) {
87
- case "text": return {
88
- type: "text",
89
- ...block.textSignature === void 0 ? {} : { textSignature: block.textSignature }
90
- };
91
- case "thinking": return {
92
- type: "reasoning",
93
- ...block.thinkingSignature === void 0 ? {} : { thinkingSignature: block.thinkingSignature },
94
- ...block.redacted === void 0 ? {} : { redacted: block.redacted }
95
- };
96
- case "toolCall": return {
97
- type: "tool-call",
98
- ...block.thoughtSignature === void 0 ? {} : { thoughtSignature: block.thoughtSignature }
99
- };
100
- }
101
- })
102
- };
103
- }
104
- function invalidReplay(message) {
105
- throw new LlmError(`invalid pi-ai replay state: ${message}`, "INVALID_REPLAY_STATE");
106
- }
107
- /** Validate the durable adapter-private envelope before it reaches pi-ai. */
108
- function readReplayState(value) {
109
- if (typeof value !== "object" || value === null || Array.isArray(value)) return invalidReplay("expected a replay envelope");
110
- const envelope = value;
111
- const rawResponse = envelope["response"];
112
- if (typeof rawResponse !== "object" || rawResponse === null || Array.isArray(rawResponse)) return invalidReplay("expected a response object");
113
- const response = rawResponse;
114
- if (response["kind"] !== "pi-ai") return invalidReplay("unknown state kind");
115
- if (response["version"] !== 2) return invalidReplay(`unsupported version ${String(response["version"])}`);
116
- for (const key of [
117
- "api",
118
- "provider",
119
- "model"
120
- ]) if (typeof response[key] !== "string" || response[key].length === 0) return invalidReplay(`${key} must be a non-empty string`);
121
- if (![
122
- "stop",
123
- "length",
124
- "toolUse",
125
- "error",
126
- "aborted"
127
- ].includes(String(response["stopReason"]))) return invalidReplay("unknown stopReason");
128
- if (response["responseModel"] !== void 0 && typeof response["responseModel"] !== "string") return invalidReplay("responseModel must be a string");
129
- if (response["responseId"] !== void 0 && typeof response["responseId"] !== "string") return invalidReplay("responseId must be a string");
130
- if (response["providerThinkingLevel"] !== void 0 && typeof response["providerThinkingLevel"] !== "string") return invalidReplay("providerThinkingLevel must be a string");
131
- const blocks = envelope["blocks"];
132
- if (!Array.isArray(blocks)) return invalidReplay("blocks must be an array");
133
- for (const [index, value] of blocks.entries()) {
134
- if (typeof value !== "object" || value === null || Array.isArray(value)) return invalidReplay(`block ${index} must be an object`);
135
- const block = value;
136
- if (![
137
- "text",
138
- "reasoning",
139
- "tool-call"
140
- ].includes(String(block["type"]))) return invalidReplay(`block ${index} has an unknown type`);
141
- for (const signature of [
142
- "textSignature",
143
- "thinkingSignature",
144
- "thoughtSignature"
145
- ]) if (block[signature] !== void 0 && typeof block[signature] !== "string") return invalidReplay(`block ${index} ${signature} must be a string`);
146
- if (block["redacted"] !== void 0 && typeof block["redacted"] !== "boolean") return invalidReplay(`block ${index} redacted must be boolean`);
147
- }
148
- return {
149
- response,
150
- blocks
151
- };
152
- }
153
- /** Convert provider-neutral blocks without trusting them as same-model replay. */
154
- function foreignAssistant(message) {
155
- const source = message.source.kind === "model" ? message.source : void 0;
156
- const content = [];
157
- for (const block of message.content) switch (block.type) {
158
- case "text":
159
- content.push({
160
- type: "text",
161
- text: block.text
162
- });
163
- break;
164
- case "reasoning":
165
- content.push({
166
- type: "thinking",
167
- thinking: block.text
168
- });
169
- break;
170
- case "tool-call":
171
- content.push({
172
- type: "toolCall",
173
- id: block.id,
174
- name: block.name,
175
- arguments: parseArguments(block.arguments)
176
- });
177
- break;
178
- case "image": throw new LlmError("pi-ai chat history cannot represent structured assistant image output", "UNSUPPORTED_CONTENT");
179
- default: break;
180
- }
181
- return {
182
- role: "assistant",
183
- content,
184
- api: "dsh-foreign",
185
- provider: source?.provider ?? "dsh-foreign",
186
- model: source?.model ?? "dsh-foreign",
187
- usage: emptyPiUsage(),
188
- stopReason: content.some((piece) => piece.type === "toolCall") ? "toolUse" : "stop",
189
- timestamp: 0
190
- };
191
- }
192
- /** Recombine durable Harness content with validated pi-ai replay metadata. */
193
- function replayedAssistant(message, source, rawState) {
194
- const state = readReplayState(rawState);
195
- if (state.response.provider !== source.provider) return invalidReplay("provider does not match assistant source");
196
- if (state.response.model !== source.model) return invalidReplay("model does not match assistant source");
197
- if (state.blocks.length !== message.content.length) return invalidReplay("block count does not match assistant content");
198
- return {
199
- role: "assistant",
200
- content: message.content.map((block, index) => {
201
- const replay = state.blocks[index];
202
- if (replay === void 0 || replay.type !== block.type) return invalidReplay(`block ${index} does not match assistant content`);
203
- switch (block.type) {
204
- case "text": return {
205
- type: "text",
206
- text: block.text,
207
- ...replay.type === "text" && replay.textSignature !== void 0 ? { textSignature: replay.textSignature } : {}
208
- };
209
- case "reasoning": return {
210
- type: "thinking",
211
- thinking: block.text,
212
- ...replay.type === "reasoning" && replay.thinkingSignature !== void 0 ? { thinkingSignature: replay.thinkingSignature } : {},
213
- ...replay.type === "reasoning" && replay.redacted !== void 0 ? { redacted: replay.redacted } : {}
214
- };
215
- case "tool-call": return {
216
- type: "toolCall",
217
- id: block.id,
218
- name: block.name,
219
- arguments: parseArguments(block.arguments),
220
- ...replay.type === "tool-call" && replay.thoughtSignature !== void 0 ? { thoughtSignature: replay.thoughtSignature } : {}
221
- };
222
- /* v8 ignore next -- readReplayState rejects unknown replay tags, so an equal plugin-added Harness tag cannot reach this switch */
223
- default: return invalidReplay(`block ${index} has an unsupported Harness type`);
224
- }
225
- }),
226
- api: state.response.api,
227
- provider: state.response.provider,
228
- model: state.response.api === "anthropic-messages" ? state.response.responseModel ?? state.response.model : state.response.model,
229
- ...state.response.responseModel === void 0 ? {} : { responseModel: state.response.responseModel },
230
- ...state.response.responseId === void 0 ? {} : { responseId: state.response.responseId },
231
- ...state.response.providerThinkingLevel === void 0 ? {} : { providerThinkingLevel: state.response.providerThinkingLevel },
232
- usage: emptyPiUsage(),
233
- stopReason: state.response.stopReason,
234
- timestamp: 0
235
- };
236
- }
237
- /**
238
- * Convert one durable Harness assistant message into pi-ai history.
239
- *
240
- * Durable content is the authoritative record; replay metadata only restores
241
- * native fidelity (ids, signatures). A replay state this build cannot use —
242
- * another adapter's kind, another version, a malformed value, or metadata that
243
- * no longer matches the content — therefore degrades the one message to
244
- * provider-neutral history instead of failing the request.
245
- * @param message - assistant content with required source and optional adapter-owned replay metadata.
246
- * @param onDegrade - called with the diagnostic reason when an unusable replay
247
- * state falls back to provider-neutral conversion.
248
- * @returns a native pi-ai assistant message reconstructed from durable content.
249
- */
250
- function toPiAssistant(message, onDegrade) {
251
- const source = message.source;
252
- if (source.kind !== "model" || source.replayState === void 0) return foreignAssistant(message);
253
- try {
254
- return replayedAssistant(message, source, source.replayState);
255
- } catch (error) {
256
- /* v8 ignore next -- replayedAssistant throws only INVALID_REPLAY_STATE LlmErrors; the
257
- guard keeps a future non-replay failure loud instead of silently degrading it */
258
- if (!(error instanceof LlmError) || error.code !== "INVALID_REPLAY_STATE") throw error;
259
- onDegrade?.(error.message);
260
- return foreignAssistant(message);
261
- }
262
- }
263
- //#endregion
264
- //#region lib/types/catalog.js
265
- /**
266
- * Materialization of one provider route's model catalog. The installed pi-ai
267
- * catalog supplies defaults keyed by model id, and a profile's own model
268
- * entries override them field by field, so a route naming a catalog provider
269
- * stays configuration-free while a route pi-ai has never heard of is fully
270
- * describable from `settings.yaml`.
271
- *
272
- * Strict resolution rejects unserviceable models before settings writes.
273
- * Deferred resolution retains their diagnostics so stored catalog drift does
274
- * not prevent inspection, repair, or requests to independently valid models.
275
- *
276
- * @module dsh-llm-pi-ai/catalog
277
- */
278
- /**
279
- * Pricing for a model the installed catalog does not describe. The harness
280
- * never reads pi-ai's cost metadata — `replay.ts` zeroes it and no consumer
281
- * reports spend — so this is the absence of a fact, not a configurable rate.
282
- */
283
- const NO_COST = {
284
- input: 0,
285
- output: 0,
286
- cacheRead: 0,
287
- cacheWrite: 0
288
- };
289
- /** Every request modality a profile may declare. */
290
- const MODALITIES = Object.keys({
291
- text: true,
292
- image: true
293
- });
294
- /**
295
- * One entry's modality list, or `undefined` when it states no answer. Absent
296
- * and empty mean the same thing — `[]` describes a model that accepts nothing
297
- * and could serve no request — which is what makes an entry naming a catalog
298
- * model without declaring modalities keep the catalog's, since the config
299
- * schema materializes `[]` for an absent array.
300
- * @param configured - the list a `models` or `modelOverrides` entry supplied.
301
- * @returns the declared modalities, or `undefined` to ask the next level.
302
- */
303
- function declaredInput(configured) {
304
- return configured === void 0 || configured.length === 0 ? void 0 : [...configured];
305
- }
306
- /** Every pi-ai thinking level a profile may declare, in escalation order. */
307
- const THINKING_LEVELS = Object.keys({
308
- off: true,
309
- minimal: true,
310
- low: true,
311
- medium: true,
312
- high: true,
313
- xhigh: true,
314
- max: true
315
- });
316
- /** Reasoning-dispatch wire formats a profile may name, most-reached first. */
317
- const SUPPORTED_THINKING_FORMATS = Object.keys({
318
- "openai": true,
319
- "deepseek": true,
320
- "openrouter": true,
321
- "together": true,
322
- "baseten": true,
323
- "zai": true,
324
- "qwen": true,
325
- "chat-template": true,
326
- "qwen-chat-template": true,
327
- "string-thinking": true,
328
- "ant-ling": true
329
- });
330
- /** The output-cap field spellings a profile may name. */
331
- const MAX_TOKENS_FIELDS = Object.keys({
332
- max_completion_tokens: true,
333
- max_tokens: true
334
- });
335
- /** The reasoning-budget field spellings a profile may name. */
336
- const THINKING_TOKEN_BUDGET_FIELDS = Object.keys({
337
- thinking_token_budget: true,
338
- thinking_budget: true,
339
- thinking_budget_tokens: true
340
- });
341
- /** The prompt-cache marker conventions a profile may name. */
342
- const CACHE_CONTROL_FORMATS = Object.keys({ anthropic: true });
343
- /** The request-state placeholders a profile may name. */
344
- const CHAT_TEMPLATE_VARS = Object.keys({
345
- "thinking.enabled": true,
346
- "thinking.effort": true,
347
- "thinking.budget": true
348
- });
349
- let providerIndex;
350
- /**
351
- * Installed catalog providers by id, constructed once. Each entry owns the API
352
- * implementations for its own models, which is why a catalog route reuses this
353
- * provider instead of being rebuilt from parts.
354
- * @returns the catalog provider index.
355
- */
356
- function catalogProviders() {
357
- providerIndex ??= new Map(builtinProviders().map((provider) => [provider.id, provider]));
358
- return providerIndex;
359
- }
360
- /**
361
- * The installed catalog provider for one route, when pi-ai ships one.
362
- * @param provider - provider route key.
363
- * @returns the catalog provider, or `undefined` for a route pi-ai does not ship.
364
- */
365
- function catalogProvider(provider) {
366
- return catalogProviders().get(provider);
367
- }
368
- /**
369
- * Every provider route the installed pi-ai catalog ships.
370
- * @returns the catalog provider ids.
371
- */
372
- function catalogProviderIds() {
373
- return getBuiltinProviders();
374
- }
375
- /**
376
- * The installed catalog models for one route, indexed by model id.
377
- * @param provider - provider route key.
378
- * @returns catalog models by id; empty for a route pi-ai does not ship.
379
- */
380
- function catalogModels(provider) {
381
- if (!catalogProviders().has(provider)) return /* @__PURE__ */ new Map();
382
- const models = getBuiltinModels(provider);
383
- return new Map(models.map((model) => [model.id, model]));
384
- }
385
- /**
386
- * Disposition of every `OpenAICompletionsCompat` field. The `Record` key type
387
- * is a drift gate: a pi-ai upgrade that adds a field fails compilation here
388
- * until it is classified, so the offer never silently lags the upstream set.
389
- */
390
- const COMPLETIONS_COMPAT_GATE = {
391
- supportsStore: "offer",
392
- supportsDeveloperRole: "offer",
393
- supportsReasoningEffort: "offer",
394
- supportsUsageInStreaming: "offer",
395
- supportsFinishReason: "offer",
396
- maxTokensField: "offer",
397
- requiresToolResultName: "offer",
398
- requiresAssistantAfterToolResult: "offer",
399
- requiresThinkingAsText: "offer",
400
- requiresReasoningContentOnAssistantMessages: "offer",
401
- thinkingFormat: "offer",
402
- chatTemplateKwargs: "offer",
403
- chatTemplateArgs: "offer",
404
- supportsThinkingTokenBudget: "offer",
405
- thinkingTokenBudgetField: "offer",
406
- vllmPriority: "offer",
407
- supportsStrictMode: "offer",
408
- cacheControlFormat: "offer",
409
- supportsLongCacheRetention: "offer",
410
- openRouterRouting: "withhold",
411
- vercelGatewayRouting: "withhold",
412
- zaiToolStream: "withhold",
413
- supportsOpenAIGrammarTools: "withhold",
414
- sendSessionAffinityHeaders: "withhold",
415
- deferredToolsMode: "withhold",
416
- sessionAffinityFormat: "withhold"
417
- };
418
- /** Disposition of every `OpenAIResponsesCompat` field; a drift gate like the one above. */
419
- const RESPONSES_COMPAT_GATE = {
420
- supportsDeveloperRole: "offer",
421
- supportsMaxOutputTokens: "offer",
422
- supportsStrictMode: "offer",
423
- supportsLongCacheRetention: "offer",
424
- sessionAffinityFormat: "withhold",
425
- supportsOpenAIGrammarTools: "withhold",
426
- supportsAdditionalTools: "withhold",
427
- supportsToolSearch: "withhold",
428
- supportsExplicitPromptCacheMode: "withhold"
429
- };
430
- /**
431
- * The compat gate of every wire protocol a profile may configure.
432
- *
433
- * Keyed by protocol, but grouped by pi-ai's compat *type*: the three Responses
434
- * protocols share `OpenAIResponsesCompat`, so a switch settable on one is
435
- * settable on all three. Keying by protocol alone would refuse
436
- * `azure-openai-responses` and `openai-codex-responses` the fields their own
437
- * models declare.
438
- */
439
- const COMPAT_GATES = {
440
- "openai-completions": COMPLETIONS_COMPAT_GATE,
441
- "openai-responses": RESPONSES_COMPAT_GATE,
442
- "azure-openai-responses": RESPONSES_COMPAT_GATE,
443
- "openai-codex-responses": RESPONSES_COMPAT_GATE,
444
- "anthropic-messages": {
445
- supportsEagerToolInputStreaming: "offer",
446
- supportsLongCacheRetention: "offer",
447
- supportsCacheControlOnTools: "offer",
448
- supportsTemperature: "offer",
449
- forceAdaptiveThinking: "offer",
450
- allowEmptySignature: "offer",
451
- supportsStrictTools: "offer",
452
- sendSessionAffinityHeaders: "withhold",
453
- supportsToolReferences: "withhold",
454
- supportsMidConvoEffort: "withhold",
455
- allowedFallbackModels: "withhold"
456
- },
457
- "bedrock-converse-stream": { supportsStrictMode: "offer" }
458
- };
459
- /**
460
- * The compat gate of one resolved protocol. A `string` lookup rather than a
461
- * keyed read: a route's `api` is configuration, so it may name a protocol
462
- * pi-ai gives no compat type — or none at all.
463
- * @param api - resolved wire protocol.
464
- * @returns that protocol's field gate, or `undefined` when it takes no compat.
465
- */
466
- function compatGate(api) {
467
- return COMPAT_GATES[api];
468
- }
469
- /**
470
- * The compat entries a profile actually set.
471
- *
472
- * schemastery materializes an absent dict as `{}` — the behavior
473
- * `reasoningEfforts` works around with a union — so every parsed profile
474
- * carries both template-argument keys whether or not anyone wrote them. An
475
- * empty one states nothing here: it would send no arguments, which is exactly
476
- * what leaving the field out does, so absent and empty are the same request
477
- * and neither may make a route look like it configured a switch. A valueless
478
- * scalar is the other thing schemastery lets through, and it is refused by
479
- * {@link assertOfferedCompatFields} before this runs rather than filtered.
480
- * @param compat - the configured switches, when any.
481
- * @returns the entries carrying a value, in declaration order.
482
- */
483
- function configuredCompatEntries(compat) {
484
- return Object.entries(compat ?? {}).flatMap(([field, value]) => {
485
- return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0 ? [] : [[field, value]];
486
- });
487
- }
488
- /**
489
- * The protocols offering one compat field, in {@link COMPAT_GATES} order.
490
- * @param field - configured compat field name.
491
- * @returns the protocols whose compat takes it; empty when none does, which
492
- * is either a withheld field or a name no upstream compat type declares.
493
- */
494
- function compatProtocols(field) {
495
- return Object.entries(COMPAT_GATES).flatMap(([api, gate]) => gate[field] === "offer" ? [api] : []);
496
- }
497
- /**
498
- * The compat fields one protocol offers, for a diagnostic that has to show
499
- * what was available instead of the name that missed.
500
- * @param api - wire protocol.
501
- * @returns the offered field names, or an empty list for a protocol taking no compat.
502
- */
503
- function offeredCompatFields(api) {
504
- return Object.entries(compatGate(api) ?? {}).flatMap(([field, disposition]) => disposition === "offer" ? [field] : []);
505
- }
506
- /**
507
- * Every offered field name, deduplicated, for the one diagnostic that cannot
508
- * narrow by protocol: the vocabulary check runs before any protocol resolves,
509
- * which is what lets it refuse a misspelling on a route whose models would
510
- * never have reached the protocol that declares the intended field.
511
- * @returns the offered field names across every protocol, in gate order.
512
- */
513
- function allOfferedCompatFields() {
514
- const fields = /* @__PURE__ */ new Set();
515
- for (const api of Object.keys(COMPAT_GATES)) for (const field of offeredCompatFields(api)) fields.add(field);
516
- return [...fields];
517
- }
518
- /**
519
- * Reject a compat key no protocol offers. Runs before any protocol is
520
- * resolved, so a withheld field or a misspelling fails even on a route whose
521
- * models never reach the protocol that would have taken it — the alternative
522
- * being the silent drop that let an unreadable switch look applied.
523
- * @param provider - provider route key, for diagnostics.
524
- * @param site - the configuration site, for diagnostics.
525
- * @param compat - the configured switches, when any.
526
- * @throws Error naming the offending key.
527
- */
528
- function assertOfferedCompatFields(provider, site, compat) {
529
- for (const [field, value] of Object.entries(compat ?? {})) {
530
- if (compatProtocols(field).length === 0) {
531
- if (Object.values(COMPAT_GATES).some((gate) => gate[field] !== void 0)) invalid(provider, `${site} sets compat "${field}", which is not configurable here: pi-ai's installed catalog sets it for the vendors that need it, so name that provider as the route instead`);
532
- invalid(provider, `${site} sets compat "${field}", which no wire protocol declares; the configurable switches are ${allOfferedCompatFields().join(", ")}`);
533
- }
534
- if (value == null) invalid(provider, `${site} sets compat "${field}" with no value; give it one, or remove the key to leave the field to the next layer — the installed catalog entry, then pi-ai's own detection`);
535
- }
536
- }
537
- /** An expected configuration failure that stored-catalog reads may retain for repair. */
538
- var PiAiCatalogError = class extends Error {};
539
- /** Report a route the deployment cannot serve, naming the settings key at fault. */
540
- function invalid(provider, detail) {
541
- throw new PiAiCatalogError(`llm-pi-ai: provider "${provider}" ${detail}`);
542
- }
543
- /**
544
- * The one wire protocol a catalog route's shipped models agree on. This is what
545
- * lets a deployment add a model the installed catalog has not caught up with —
546
- * a provider's newest release — without restating the protocol its siblings
547
- * already use. A route whose shipped models disagree (an OpenAI-style catalog
548
- * spanning Responses and Chat Completions) has no such answer, so a model it
549
- * does not describe must name its protocol at the route.
550
- */
551
- function sharedCatalogApi(defaults) {
552
- const apis = /* @__PURE__ */ new Set();
553
- for (const model of defaults.values()) apis.add(model.api);
554
- return apis.size === 1 ? [...apis][0] : void 0;
555
- }
556
- /**
557
- * Resolve one model's reasoning capability from its declared efforts.
558
- *
559
- * A declared dict translates to pi-ai's `thinkingLevelMap` with every level
560
- * decided explicitly: declared levels carry their wire spelling, undeclared
561
- * levels are pinned to `null` (unsupported). Pinning matters because pi-ai's
562
- * own defaulting is asymmetric — an absent key means "supported" for the five
563
- * base levels but "unsupported" for `xhigh`/`max` — and a profile author
564
- * should not need to know that. A declared `off` with no value is the one
565
- * exception: it stays absent from the map, which pi-ai reads as "supported,
566
- * send nothing" — the correct dispatch where not thinking is the parameter's
567
- * absence — while `off` with a value sends that value.
568
- * @param provider - provider route key, for diagnostics.
569
- * @param entry - the configured model entry.
570
- * @param base - the installed catalog entry of the same id, when one exists.
571
- * @returns the reasoning fields the materialized model carries.
572
- */
573
- function resolveModelReasoning(provider, entry, base) {
574
- const efforts = entry.reasoningEfforts;
575
- if (efforts === void 0) return { reasoning: base?.reasoning ?? false };
576
- if (efforts === false) return { reasoning: false };
577
- 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`);
578
- const declared = THINKING_LEVELS.flatMap((level) => {
579
- const wire = efforts[level];
580
- return wire === void 0 ? [] : [[level, wire]];
581
- });
582
- for (const [level, wire] of declared) if (wire === null) {
583
- if (level !== "off") invalid(provider, `model "${entry.id}" reasoningEfforts.${level} needs the wire value dispatch should send; only "off" may leave it empty`);
584
- } else if (wire.length === 0) invalid(provider, `model "${entry.id}" reasoningEfforts.${level} must not be an empty string`);
585
- 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`);
586
- const map = {};
587
- for (const level of THINKING_LEVELS) {
588
- const wire = efforts[level];
589
- if (wire === void 0) map[level] = null;
590
- else if (wire !== null) map[level] = wire;
591
- }
592
- return {
593
- reasoning: true,
594
- thinkingLevelMap: map
595
- };
596
- }
597
- /**
598
- * Resolve one model's compat block from the profile's switches.
599
- *
600
- * A model switch wins over the route switch field by field; whatever neither
601
- * sets keeps the installed entry's value, and a field no layer decides falls
602
- * through to pi-ai's own detection. A model-level switch its protocol does not
603
- * take fails resolution — about one named model it can only be a mistake —
604
- * while a route-level one skips past such models, since a route default must
605
- * stay settable on a route whose models do not all speak one protocol. Every
606
- * field reaching here is offered by some protocol; {@link
607
- * assertOfferedCompatFields} has already refused the rest.
608
- * @param provider - provider route key, for diagnostics.
609
- * @param entry - the configured model entry.
610
- * @param route - the route-level switches, when any.
611
- * @param base - the installed catalog entry of the same id, when one exists.
612
- * @param api - the model's resolved wire protocol.
613
- * @returns a `compat` field to spread into the model, or nothing.
614
- */
615
- function resolveModelCompat(provider, entry, route, base, api) {
616
- const gate = compatGate(api);
617
- const configured = {};
618
- for (const [field, value] of configuredCompatEntries(route)) {
619
- if (gate?.[field] !== "offer") continue;
620
- configured[field] = value;
621
- }
622
- for (const [field, value] of configuredCompatEntries(entry.compat)) {
623
- if (gate?.[field] !== "offer") {
624
- const offered = offeredCompatFields(api);
625
- invalid(provider, `model "${entry.id}" sets compat "${field}", but its api is "${api}", which does not take it; that switch exists on ${compatProtocols(field).join(", ")}, and "${api}" offers ${offered.length === 0 ? "no configurable compat" : offered.join(", ")}`);
626
- }
627
- configured[field] = value;
628
- }
629
- if (Object.keys(configured).length === 0) return {};
630
- return { compat: {
631
- ...base?.api === api ? base.compat : void 0,
632
- ...configured
633
- } };
634
- }
635
- /**
636
- * Materialize one route's catalog by merging the installed catalog defaults
637
- * under the configured entries. A route with no configured `models` serves the
638
- * installed catalog unchanged, which is what keeps an existing
639
- * `providers: { deepseek: { apiKeyEnv: … } }` profile working untouched.
640
- * @param request - the route-level catalog facts.
641
- * @param validation - strict writes reject every error; deferred reads retain model diagnostics.
642
- * @returns the materialized models and the explicitly configured request caps.
643
- */
644
- function resolveRouteModels(request, validation = "strict") {
645
- const { provider } = request;
646
- const defaults = catalogModels(provider);
647
- const providerBaseUrl = catalogProvider(provider)?.baseUrl;
648
- const configured = request.models ?? [];
649
- const overrides = request.modelOverrides ?? {};
650
- const modelErrors = /* @__PURE__ */ new Map();
651
- for (const [id, override] of Object.entries(overrides)) {
652
- if (id.length === 0) invalid(provider, "has a modelOverrides entry with an empty model id");
653
- 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`);
654
- 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`);
655
- if (!defaults.has(id)) {
656
- const message = `modelOverrides names "${id}", which the installed catalog does not describe`;
657
- if (validation === "strict") invalid(provider, message);
658
- modelErrors.set(id, `llm-pi-ai: provider "${provider}" ${message}`);
659
- }
660
- if ("id" in override) invalid(provider, `modelOverrides entry "${id}" sets "id", which is the dict key`);
661
- }
662
- const entries = configured.length > 0 ? configured : [...defaults.values()].map((model) => ({
663
- id: model.id,
664
- ...overrides[model.id]
665
- }));
666
- 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");
667
- const routeApi = sharedCatalogApi(defaults);
668
- assertOfferedCompatFields(provider, "route", request.compat);
669
- const seen = /* @__PURE__ */ new Set();
670
- const configuredMaxTokens = /* @__PURE__ */ new Map();
671
- const resolveEntry = (entry) => {
672
- assertOfferedCompatFields(provider, `model "${entry.id}"`, entry.compat);
673
- if (entry.id.length === 0) invalid(provider, "has a model with an empty id");
674
- if (seen.has(entry.id)) invalid(provider, `lists model "${entry.id}" more than once`);
675
- seen.add(entry.id);
676
- const base = defaults.get(entry.id);
677
- const api = request.api ?? base?.api ?? routeApi;
678
- 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`);
679
- const baseUrl = request.baseURL ?? base?.baseUrl ?? providerBaseUrl;
680
- if (baseUrl === void 0) invalid(provider, `model "${entry.id}" needs a baseURL; the installed catalog does not describe this route`);
681
- const contextWindow = entry.contextWindow ?? base?.contextWindow ?? request.defaultContextWindow;
682
- if (!Number.isInteger(contextWindow) || contextWindow <= 0) invalid(provider, `model "${entry.id}" contextWindow must be a positive integer`);
683
- const maxTokens = entry.maxTokens ?? base?.maxTokens ?? request.defaultMaxTokens;
684
- if (!Number.isInteger(maxTokens) || maxTokens <= 0) invalid(provider, `model "${entry.id}" maxTokens must be a positive integer`);
685
- if (entry.maxTokens !== void 0) configuredMaxTokens.set(entry.id, entry.maxTokens);
686
- return {
687
- ...base,
688
- id: entry.id,
689
- name: entry.name ?? base?.name ?? entry.id,
690
- api,
691
- provider,
692
- baseUrl,
693
- input: declaredInput(entry.input) ?? base?.input ?? [...request.defaultInput],
694
- cost: base?.cost ?? NO_COST,
695
- contextWindow,
696
- maxTokens,
697
- ...resolveModelReasoning(provider, entry, base),
698
- ...resolveModelCompat(provider, entry, request.compat, base, api)
699
- };
700
- };
701
- const models = [];
702
- for (const entry of entries) {
703
- let model;
704
- try {
705
- model = resolveEntry(entry);
706
- } catch (error) {
707
- if (validation === "strict" || !(error instanceof PiAiCatalogError)) throw error;
708
- modelErrors.set(entry.id, error.message);
709
- continue;
710
- }
711
- models.push(model);
712
- }
713
- const serviceableModels = models.filter((model) => !modelErrors.has(model.id));
714
- for (const [field] of configuredCompatEntries(request.compat)) {
715
- const takers = compatProtocols(field);
716
- if (serviceableModels.some((model) => takers.includes(model.api))) continue;
717
- invalid(provider, `sets compat "${field}", but no model on the route speaks a protocol that takes it; it exists on ${takers.join(", ")}`);
718
- }
719
- return {
720
- models: serviceableModels,
721
- configuredMaxTokens,
722
- modelErrors
723
- };
724
- }
725
- //#endregion
726
- //#region lib/types/provider.js
727
- /**
728
- * Construction of the pi-ai `Provider` that one configured route registers into
729
- * the adapter's `Models` collection.
730
- *
731
- * Two constructions, one decision: a route the installed catalog ships, whose
732
- * profile does not override the wire protocol, **reuses that catalog provider**
733
- * with its models replaced — the catalog provider owns API implementations this
734
- * package cannot reconstruct (Bedrock loads its Smithy module through a
735
- * separate entry point), so rebuilding it from parts would silently narrow
736
- * which providers work. Every other route — one pi-ai has never heard of, or a
737
- * catalog route pointed at a different protocol — is built by `createProvider`
738
- * over the protocol table below.
739
- *
740
- * Credentials never reach this module's storage: the harness resolves a route's
741
- * key through `ctx.credentials` before the request enters pi-ai and hands it
742
- * over as a stream option, which `Models` presents to `resolve()` as the
743
- * credential key.
744
- *
745
- * @module dsh-llm-pi-ai/provider
746
- */
747
- /**
748
- * Wire protocols a configured route may name, mapped to pi-ai's lazily loaded
749
- * implementations. Each entry is the factory that pi-ai's matching provider
750
- * factory uses, so a hand-declared route reaches exactly the implementation a
751
- * catalog route would.
752
- *
753
- * The table is deliberately narrow: the protocols a hand-declared route
754
- * actually reads, each completely describable with a key, an
755
- * endpoint, and headers. Bedrock signs with SigV4 over AWS credentials and a
756
- * region, Vertex needs a project, a location, and application-default
757
- * credentials, Azure needs provider environment plus an api-version, and Codex
758
- * authenticates through OAuth — none of which this configuration shape can
759
- * express, so offering them would hand back a provider that cannot
760
- * authenticate. The remainder are absent for want of a consumer rather than a
761
- * blocker: each is one line here once a deployment needs it. Catalog routes
762
- * still reach every protocol through their own provider; only an explicit
763
- * override is refused.
764
- */
765
- const PROTOCOLS = {
766
- "openai-completions": openAICompletionsApi,
767
- "openai-responses": openAIResponsesApi,
768
- "anthropic-messages": anthropicMessagesApi
769
- };
770
- /**
771
- * Every wire protocol a configured route may name, most-reached first. The
772
- * order is the table's and therefore stable; a configuration surface offering
773
- * a choice presents the first as its default, which is why the protocol a
774
- * hand-declared gateway most often speaks — and the one endpoint interrogation
775
- * can read — leads.
776
- * @returns the supported protocol identifiers.
777
- */
778
- function supportedProtocols() {
779
- return Object.keys(PROTOCOLS);
780
- }
781
- /**
782
- * Api-key auth for a route the harness authenticates itself. `Models` calls
783
- * this after the adapter has already resolved the route's credential, so a
784
- * missing key here is not this layer's failure: a named-but-unresolvable
785
- * reference has already failed the request with `MISSING_CREDENTIAL`, and a
786
- * route naming no credential at all is deliberately unauthenticated. Reporting
787
- * it as configured hands the decision to the protocol, which is where the
788
- * requirement actually lives — pi-ai's OpenAI-compatible implementation, for
789
- * one, still insists on a key or an `Authorization` header of its own.
790
- * @param name - display name used as the resolution's status label.
791
- * @returns the api-key auth for a harness-authenticated route.
792
- */
793
- function harnessApiKeyAuth(name) {
794
- return {
795
- name,
796
- resolve: ({ credential }) => Promise.resolve({
797
- auth: credential?.key === void 0 ? {} : { apiKey: credential.key },
798
- source: name
799
- })
800
- };
801
- }
802
- /**
803
- * The auth one route resolves its credential through.
804
- *
805
- * A catalog route keeps the installed provider's own auth, which is what
806
- * preserves provider-native ambient discovery for a profile naming no
807
- * credential. That holds even when the profile repoints the protocol: which
808
- * environment a provider reads is a property of the provider, not of the wire
809
- * format its models speak.
810
- *
811
- * The single addition covers a catalog provider that offers no api-key method
812
- * at all. pi-ai resolves a request's `apiKey` override only when the provider
813
- * declares one (`resolveProviderAuth` checks `provider.auth.apiKey` before
814
- * honouring the override), so an OAuth-only provider — `openai-codex` is the
815
- * one the installed catalog ships — would refuse a profile's explicit key with
816
- * `Provider is not configured` before any request went out. Adding the harness
817
- * method beside the provider's own restores that route. A keyless profile adds
818
- * nothing and still reports the honest refusal, because this adapter resolves
819
- * credentials through its own seam and holds no OAuth store to fall back on.
820
- * @param spec - the resolved route facts.
821
- * @param catalog - the installed catalog provider, when pi-ai ships one.
822
- * @returns the auth to construct this route's provider with.
823
- */
824
- function routeAuth(spec, catalog) {
825
- if (catalog === void 0) return { apiKey: harnessApiKeyAuth(spec.displayName) };
826
- if (catalog.auth.apiKey !== void 0 || !spec.namesCredential) return catalog.auth;
827
- return {
828
- ...catalog.auth,
829
- apiKey: harnessApiKeyAuth(spec.displayName)
830
- };
831
- }
832
- /**
833
- * Reuse an installed catalog provider with this route's models and identity.
834
- * Model dispatch stays with the catalog provider, so its API implementations,
835
- * compatibility quirks, and ambient credential discovery are preserved exactly.
836
- * Catalog-owned dynamic refresh is dropped: this route's catalog is the
837
- * settings document, and a background refresh would contradict it.
838
- */
839
- function reuseCatalogProvider(base, spec) {
840
- const baseUrl = spec.baseURL ?? base.baseUrl;
841
- return {
842
- id: spec.provider,
843
- name: spec.displayName,
844
- ...baseUrl === void 0 ? {} : { baseUrl },
845
- auth: routeAuth(spec, base),
846
- getModels: () => spec.models,
847
- stream: (model, context, options) => base.stream(model, context, options),
848
- streamSimple: (model, context, options) => base.streamSimple(model, context, options)
849
- };
850
- }
851
- /**
852
- * Build the pi-ai provider for one resolved route.
853
- * @param spec - the resolved route facts.
854
- * @returns the provider to register in the adapter's `Models` collection.
855
- * @throws Error when the route names a wire protocol this build cannot serve.
856
- */
857
- function buildProvider(spec) {
858
- const catalog = catalogProvider(spec.provider);
859
- if (catalog !== void 0 && spec.api === void 0) return reuseCatalogProvider(catalog, spec);
860
- const factory = spec.api === void 0 ? void 0 : PROTOCOLS[spec.api];
861
- if (factory === void 0) throw new PiAiCatalogError(`llm-pi-ai: provider "${spec.provider}" names api "${spec.api}", which this build cannot serve; supported protocols are ${supportedProtocols().join(", ")}`);
862
- return createProvider({
863
- id: spec.provider,
864
- name: spec.displayName,
865
- ...spec.baseURL === void 0 ? {} : { baseUrl: spec.baseURL },
866
- auth: routeAuth(spec, catalog),
867
- models: spec.models,
868
- api: factory()
869
- });
870
- }
871
- //#endregion
872
- //#region lib/types/config.js
873
- /**
874
- * Configuration schema and provider-profile validation for the pi-ai adapter.
875
- * Profiles are a dict keyed by provider route, so the composition base and a
876
- * user-settings layer merge per provider and the route set is structural.
877
- *
878
- * A route key is not required to name an installed pi-ai provider. When it does,
879
- * that provider's endpoint, protocol, display name, and model catalog are the
880
- * profile's defaults and the profile overrides them field by field; when it does
881
- * not, the profile is the whole provider declaration. Stored reads retain
882
- * catalog diagnostics beside serviceable models; writes validate every changed
883
- * provider before persistence. Self-contained profile constraints apply to both.
884
- *
885
- * @module dsh-llm-pi-ai/config
886
- */
887
- /** Default maximum idle interval while an adapter stream read is outstanding. */
888
- const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 3e5;
889
- /**
890
- * Default request-level bound on base64-encoded image payload. Every image in
891
- * history is re-encoded into every request body, so an unbounded conversation
892
- * eventually exceeds a provider or gateway request-size cap and the session
893
- * can never complete another request. The 20MiB default admits fifteen 1MiB
894
- * request versions after base64 expansion and reserves request capacity for
895
- * system prompts, history, tools, and JSON.
896
- * Deployments behind stricter gateways lower it per route.
897
- */
898
- const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024;
899
- /** Default total-pixel budget preserves the complete 2048px normalized attachment. */
900
- const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 2048 * 2048;
901
- /** Default raw encoded-byte target before inline base64 expansion; the smallest quality-ladder output is used when no quality fits. */
902
- const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024;
903
- /** Context capacity assumed for a model neither configuration nor the catalog sizes. */
904
- const DEFAULT_CONTEXT_WINDOW = 262144;
905
- /** Output capability assumed for a model neither configuration nor the catalog sizes. */
906
- const DEFAULT_MAX_TOKENS = 32768;
907
- /**
908
- * Modalities assumed for a model neither configuration nor the catalog
909
- * declares. Text is the floor every supported protocol certainly carries, so
910
- * this is the absence of a declaration rather than a guess at the endpoint:
911
- * nothing can interrogate a gateway for its modalities, and the two wrong
912
- * answers do not cost the same. Under-claiming refuses the image before it is
913
- * attached, naming the model. Over-claiming admits one the provider then
914
- * rejects mid-turn, after the message is durable, leaving the session
915
- * repeating a request that cannot succeed.
916
- */
917
- const DEFAULT_INPUT = ["text"];
918
- const thinkingBudgets = z.object({
919
- minimal: z.number(),
920
- low: z.number(),
921
- medium: z.number(),
922
- high: z.number()
923
- });
924
- /**
925
- * One `chat_template_kwargs` or `chat_template_args` value. The `$var` member
926
- * is pi-ai's placeholder for a value dispatch fills from the request's
927
- * thinking state, which makes a template-driven gateway configurable without
928
- * restating its template.
929
- */
930
- const chatTemplateKwarg = z.union([
931
- z.string(),
932
- z.number(),
933
- z.boolean(),
934
- z.const(null),
935
- z.object({
936
- $var: z.union(CHAT_TEMPLATE_VARS).required(),
937
- omitWhenOff: z.boolean()
938
- })
939
- ]);
940
- const compatProfile = z.object({
941
- supportsStore: z.boolean(),
942
- supportsDeveloperRole: z.boolean(),
943
- supportsReasoningEffort: z.boolean(),
944
- supportsUsageInStreaming: z.boolean(),
945
- supportsFinishReason: z.boolean(),
946
- maxTokensField: z.union(MAX_TOKENS_FIELDS),
947
- requiresToolResultName: z.boolean(),
948
- requiresAssistantAfterToolResult: z.boolean(),
949
- requiresThinkingAsText: z.boolean(),
950
- requiresReasoningContentOnAssistantMessages: z.boolean(),
951
- thinkingFormat: z.union(SUPPORTED_THINKING_FORMATS),
952
- chatTemplateKwargs: z.dict(chatTemplateKwarg),
953
- chatTemplateArgs: z.dict(chatTemplateKwarg),
954
- supportsThinkingTokenBudget: z.boolean(),
955
- thinkingTokenBudgetField: z.union(THINKING_TOKEN_BUDGET_FIELDS),
956
- vllmPriority: z.number().step(1),
957
- supportsMaxOutputTokens: z.boolean(),
958
- supportsStrictMode: z.boolean(),
959
- cacheControlFormat: z.union(CACHE_CONTROL_FORMATS),
960
- supportsLongCacheRetention: z.boolean(),
961
- supportsEagerToolInputStreaming: z.boolean(),
962
- supportsCacheControlOnTools: z.boolean(),
963
- supportsTemperature: z.boolean(),
964
- forceAdaptiveThinking: z.boolean(),
965
- allowEmptySignature: z.boolean(),
966
- supportsStrictTools: z.boolean()
967
- });
968
- /**
969
- * Keys are the offered levels, values their wire spellings. A valueless key
970
- * (`off:`) survives validation because schemastery passes nullable data
971
- * through before any member schema runs — `z.const(null)` only controls the
972
- * error for non-null wrong values and what a configuration UI renders.
973
- * Only resolution decides which levels may leave the value empty, so the
974
- * diagnostic can name the route and model. The assertion narrows
975
- * schemastery's `Dict`, which types every literal key as required; dict
976
- * validation checks only present keys, so the runtime value is a partial record.
977
- */
978
- const reasoningEfforts = z.dict(z.union([z.string(), z.const(null)]), z.union(THINKING_LEVELS));
979
- /** The fields a `models` entry and a `modelOverrides` value share; only the id's home differs. */
980
- const modelFields = {
981
- name: z.string(),
982
- contextWindow: z.number().step(1).min(1),
983
- maxTokens: z.number().step(1).min(1),
984
- input: z.array(z.union(MODALITIES)),
985
- reasoningEfforts: z.union([z.const(false), reasoningEfforts]),
986
- compat: compatProfile
987
- };
988
- const modelProfile = z.object({
989
- id: z.string().required(),
990
- ...modelFields
991
- });
992
- /** A {@link modelProfile} whose id lives in the `modelOverrides` dict key. */
993
- const modelOverride = z.object(modelFields);
994
- const profile = z.object({
995
- apiKeyEnv: z.string().role("credential-ref"),
996
- displayName: z.string(),
997
- api: z.union(supportedProtocols()),
998
- baseURL: z.string(),
999
- models: z.array(modelProfile),
1000
- modelOverrides: z.dict(modelOverride),
1001
- compat: compatProfile,
1002
- defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
1003
- defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS),
1004
- defaultInput: z.array(z.union(MODALITIES)).default([...DEFAULT_INPUT]),
1005
- headers: z.dict(z.string()),
1006
- reasoning: z.union(THINKING_LEVELS),
1007
- thinkingBudgets,
1008
- cacheRetention: z.union([
1009
- "none",
1010
- "short",
1011
- "long"
1012
- ]),
1013
- transport: z.union([
1014
- "sse",
1015
- "websocket",
1016
- "websocket-cached",
1017
- "auto"
1018
- ]),
1019
- timeoutMs: z.natural(),
1020
- websocketConnectTimeoutMs: z.natural(),
1021
- streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
1022
- maxRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_IMAGE_BYTES),
1023
- requestImagePixelBudget: z.number().step(1).min(1).default(DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET),
1024
- requestImageMaxBytes: z.number().step(1).min(1).default(DEFAULT_REQUEST_IMAGE_MAX_BYTES),
1025
- retryPolicy: RetryPolicySchema
1026
- });
1027
- /** Runtime schema for {@link Config}. */
1028
- const Config = z.object({ providers: z.dict(profile).default({}) });
1029
- /**
1030
- * Reject new or changed provider profiles that cannot be served. Unchanged
1031
- * stored profiles may need repair after a catalog upgrade and do not block
1032
- * edits to another provider. Removed profiles require no catalog validation.
1033
- * @param config - the resolved section to check.
1034
- * @param previous - current resolved section; omission checks every provider.
1035
- * @throws Error naming the route and configuration entry that cannot be served.
1036
- */
1037
- function assertServiceable(config, previous) {
1038
- resolveProfiles(Object.fromEntries(Object.entries(config.providers ?? {}).filter(([provider, profile]) => !deepEqualJson(profile, previous?.providers?.[provider]))));
1039
- }
1040
- /** Reject removed pre-release profile fields and name their replacements. */
1041
- function rejectRemovedFields(provider, source) {
1042
- const legacy = source;
1043
- if ("provider" in legacy) throw new Error(`llm-pi-ai: provider "${provider}" sets "provider", which moved to the providers dict key`);
1044
- 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`);
1045
- }
1046
- /** Reject a profile header that Fetch cannot put on a provider request. */
1047
- function assertValidHeaders(provider, headers) {
1048
- for (const [name, value] of Object.entries(headers ?? {})) try {
1049
- new Headers([[name, value]]);
1050
- } catch {
1051
- throw new Error(`llm-pi-ai: provider "${provider}" header "${name}" is not valid for Fetch; use a valid HTTP field name and a single-line value representable as bytes`);
1052
- }
1053
- }
1054
- /**
1055
- * Resolve scalar defaults and materialize each route's serviceable models.
1056
- * Deferred catalog validation retains diagnostics without deleting configured
1057
- * routes. An omitted dict resolves to the empty, dormant route set.
1058
- * @param providers - configured provider profiles keyed by route.
1059
- * @param validation - writes require a complete catalog; stored reads retain catalog diagnostics.
1060
- * @returns validated profiles in configuration order.
1061
- */
1062
- function resolveProfiles(providers, validation = "strict") {
1063
- if (Array.isArray(providers)) throw new Error("llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles");
1064
- const entries = Object.entries(providers ?? {});
1065
- const resolved = /* @__PURE__ */ new Map();
1066
- for (const [provider, source] of entries) {
1067
- rejectRemovedFields(provider, source);
1068
- if (provider.length === 0) throw new Error("llm-pi-ai: provider names must be non-empty");
1069
- if (source.baseURL !== void 0 && source.baseURL.length === 0) throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`);
1070
- if (source.displayName !== void 0 && source.displayName.length === 0) throw new Error(`llm-pi-ai: provider "${provider}" has an empty displayName`);
1071
- assertValidHeaders(provider, source.headers);
1072
- const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? 3e5;
1073
- 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}`);
1074
- const maxRequestImageBytes = source.maxRequestImageBytes ?? 20971520;
1075
- if (!Number.isInteger(maxRequestImageBytes) || maxRequestImageBytes <= 0) throw new Error(`llm-pi-ai: provider "${provider}" maxRequestImageBytes must be a positive integer`);
1076
- const requestImagePixelBudget = source.requestImagePixelBudget ?? 4194304;
1077
- if (!Number.isSafeInteger(requestImagePixelBudget) || requestImagePixelBudget <= 0) throw new Error(`llm-pi-ai: provider "${provider}" requestImagePixelBudget must be a positive safe integer`);
1078
- const requestImageMaxBytes = source.requestImageMaxBytes ?? 1048576;
1079
- if (!Number.isSafeInteger(requestImageMaxBytes) || requestImageMaxBytes <= 0) throw new Error(`llm-pi-ai: provider "${provider}" requestImageMaxBytes must be a positive safe integer`);
1080
- const defaultInput = [...source.defaultInput ?? DEFAULT_INPUT];
1081
- if (defaultInput.length === 0) throw new Error(`llm-pi-ai: provider "${provider}" defaultInput must name at least one modality`);
1082
- const displayName = source.displayName ?? provider;
1083
- let catalog;
1084
- let piProvider;
1085
- let catalogError;
1086
- try {
1087
- catalog = resolveRouteModels({
1088
- provider,
1089
- ...source.api === void 0 ? {} : { api: source.api },
1090
- ...source.baseURL === void 0 ? {} : { baseURL: source.baseURL },
1091
- ...source.models === void 0 ? {} : { models: source.models },
1092
- ...source.modelOverrides === void 0 ? {} : { modelOverrides: source.modelOverrides },
1093
- ...source.compat === void 0 ? {} : { compat: source.compat },
1094
- defaultInput,
1095
- defaultContextWindow: source.defaultContextWindow ?? 262144,
1096
- defaultMaxTokens: source.defaultMaxTokens ?? 32768
1097
- }, validation);
1098
- catalogError = catalog.modelErrors.values().next().value;
1099
- piProvider = buildProvider({
1100
- provider,
1101
- displayName,
1102
- ...source.api === void 0 ? {} : { api: source.api },
1103
- ...source.baseURL === void 0 ? {} : { baseURL: source.baseURL },
1104
- models: catalog.models,
1105
- namesCredential: source.apiKeyEnv !== void 0
1106
- });
1107
- } catch (error) {
1108
- if (validation === "strict" || !(error instanceof PiAiCatalogError)) throw error;
1109
- catalogError ??= error.message;
1110
- }
1111
- const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source;
1112
- resolved.set(provider, {
1113
- ...rest,
1114
- provider,
1115
- displayName,
1116
- ...apiKeyEnv === void 0 ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) },
1117
- streamIdleTimeoutMs,
1118
- maxRequestImageBytes,
1119
- requestImagePixelBudget,
1120
- requestImageMaxBytes,
1121
- retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`),
1122
- ...rest.headers === void 0 ? {} : { headers: { ...rest.headers } },
1123
- ...rest.thinkingBudgets === void 0 ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } },
1124
- configuredMaxTokens: catalog?.configuredMaxTokens ?? /* @__PURE__ */ new Map(),
1125
- modelErrors: catalog?.modelErrors ?? /* @__PURE__ */ new Map(),
1126
- ...piProvider === void 0 ? {} : { piProvider },
1127
- ...catalogError === void 0 ? {} : { catalogError }
1128
- });
1129
- }
1130
- return resolved;
1131
- }
1132
- //#endregion
1133
- //#region lib/types/context.js
1134
- /**
1135
- * Harness request-history conversion into pi-ai's Context vocabulary.
1136
- *
1137
- * @module dsh-llm-pi-ai/context
1138
- */
1139
- /** Join the text blocks of a harness message. */
1140
- function flattenText(message) {
1141
- return message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
1142
- }
1143
- /** Flatten text recursively inside one tool result. */
1144
- function toolResultText(blocks) {
1145
- return blocks.map((block) => block.type === "text" ? block.text : block.type === "tool-result" ? toolResultText(block.content) : "").join("");
1146
- }
1147
- /** Reject image roles that pi-ai cannot replay before request-size offloading can replace them. */
1148
- function assertSupportedImageRoles(messages) {
1149
- for (const message of messages) if (message.role !== "user" && contentHasImage(message.content)) throw new LlmError(`pi-ai cannot represent an image in an in-history ${message.role} message`, "UNSUPPORTED_CONTENT");
1150
- }
1151
- async function userContent(blocks, requestImages, resolveImageAccess) {
1152
- const content = [];
1153
- for (const block of blocks) switch (block.type) {
1154
- case "text":
1155
- if (block.text.length > 0) content.push({
1156
- type: "text",
1157
- text: block.text
1158
- });
1159
- break;
1160
- case "image": {
1161
- const version = requestImages.get(block.attachment.attachmentId);
1162
- content.push({
1163
- type: "text",
1164
- text: requestImageHandleText(block.attachment, version, resolveImageAccess(block.attachment))
1165
- });
1166
- content.push({
1167
- type: "image",
1168
- data: Buffer.from(version.data).toString("base64"),
1169
- mimeType: version.mediaType
1170
- });
1171
- break;
1172
- }
1173
- case "tool-result":
1174
- {
1175
- const nested = await userContent(block.content, requestImages, resolveImageAccess);
1176
- if (typeof nested === "string") {
1177
- if (nested.length > 0) content.push({
1178
- type: "text",
1179
- text: nested
1180
- });
1181
- } else content.push(...nested);
1182
- }
1183
- break;
1184
- default: break;
1185
- }
1186
- if (content.every((block) => block.type === "text")) return content.map((block) => block.text).join("");
1187
- return content;
1188
- }
1189
- function collectImageRefs(blocks, refs) {
1190
- for (const block of blocks) if (block.type === "image") refs.set(block.attachment.attachmentId, block.attachment);
1191
- else if (block.type === "tool-result") collectImageRefs(block.content, refs);
1192
- }
1193
- async function prepareRequestImages(messages, attachments, policy, signal) {
1194
- const refs = /* @__PURE__ */ new Map();
1195
- for (const message of messages) collectImageRefs(message.content, refs);
1196
- const orderedRefs = [...refs.values()];
1197
- const prepared = await Promise.all(orderedRefs.map((ref) => attachments.readImageRequest(ref, policy, signal)));
1198
- const versions = /* @__PURE__ */ new Map();
1199
- for (const [index, ref] of orderedRefs.entries()) versions.set(ref.attachmentId, prepared[index]);
1200
- return versions;
1201
- }
1202
- function toolsOf(options) {
1203
- return options.tools?.map((tool) => ({
1204
- name: tool.name,
1205
- description: tool.description,
1206
- parameters: tool.parameters
1207
- }));
1208
- }
1209
- /**
1210
- * Select the pi-ai `systemPrompt` source shared by both conversion paths.
1211
- * `options.system` wins when defined and every history message converts,
1212
- * including a leading `system` message, which then folds into a `user`
1213
- * message. Otherwise a leading `system` history message supplies the prompt
1214
- * and leaves the converted history; empty leading text sends no prompt.
1215
- */
1216
- function splitSystemPrompt(options) {
1217
- if (options.system !== void 0) return {
1218
- systemPrompt: options.system,
1219
- messages: options.messages
1220
- };
1221
- const [first, ...rest] = options.messages;
1222
- if (first?.role !== "system") return {
1223
- systemPrompt: void 0,
1224
- messages: options.messages
1225
- };
1226
- const text = flattenText(first);
1227
- return {
1228
- systemPrompt: text.length > 0 ? text : void 0,
1229
- messages: rest
1230
- };
1231
- }
1232
- /** Assemble the request-level pi-ai context envelope shared by both conversion paths. */
1233
- function piContext(systemPrompt, options, messages) {
1234
- const tools = toolsOf(options);
1235
- return {
1236
- ...systemPrompt !== void 0 ? { systemPrompt } : {},
1237
- messages,
1238
- ...tools !== void 0 && tools.length > 0 ? { tools } : {}
1239
- };
1240
- }
1241
- function appendAssistant(message, messages, toolNames, onReplayDegrade) {
1242
- const assistant = toPiAssistant(message, onReplayDegrade);
1243
- for (const block of assistant.content) if (block.type === "toolCall") toolNames.set(brandString(block.id), block.name);
1244
- messages.push(assistant);
1245
- }
1246
- function textOnlyContext(options, onReplayDegrade) {
1247
- assertSupportedImageRoles(options.messages);
1248
- const split = splitSystemPrompt(options);
1249
- const toolNames = /* @__PURE__ */ new Map();
1250
- const messages = [];
1251
- for (const message of split.messages) {
1252
- if (contentHasImage(message.content)) throw new LlmError("pi-ai image conversion requires the durable attachment service", "UNSUPPORTED_CONTENT");
1253
- if (message.role === "system") {
1254
- messages.push({
1255
- role: "user",
1256
- content: flattenText(message),
1257
- timestamp: 0
1258
- });
1259
- continue;
1260
- }
1261
- if (message.role === "assistant") {
1262
- appendAssistant(message, messages, toolNames, onReplayDegrade);
1263
- continue;
1264
- }
1265
- const text = flattenText(message);
1266
- const results = message.content.filter((block) => block.type === "tool-result");
1267
- if (text.length > 0 || results.length === 0) messages.push({
1268
- role: "user",
1269
- content: text,
1270
- timestamp: 0
1271
- });
1272
- for (const result of results) messages.push({
1273
- role: "toolResult",
1274
- toolCallId: result.toolCallId,
1275
- toolName: toolNames.get(result.toolCallId) ?? "unknown",
1276
- content: [{
1277
- type: "text",
1278
- text: toolResultText(result.content) || "(no output)"
1279
- }],
1280
- isError: result.isError ?? false,
1281
- timestamp: 0
1282
- });
1283
- }
1284
- return piContext(split.systemPrompt, options, messages);
1285
- }
1286
- function toPiContext(options, images, onReplayDegrade) {
1287
- return images === void 0 ? textOnlyContext(options, onReplayDegrade) : toPiContextWithImages(options, images, onReplayDegrade);
1288
- }
1289
- async function toPiContextWithImages(options, images, onReplayDegrade) {
1290
- const { attachments, resolveImageAccess, maxRequestImageBytes } = images;
1291
- const requestImagePolicy = images.requestImagePolicy ?? {
1292
- maxPixels: 4194304,
1293
- maxBytes: 1048576
1294
- };
1295
- assertSupportedImageRoles(options.messages);
1296
- const split = splitSystemPrompt(options);
1297
- const requestMessages = offloadRequestImagesWithPolicy(split.messages, {
1298
- representation: "base64",
1299
- ...maxRequestImageBytes === void 0 ? {} : { maxBytes: maxRequestImageBytes },
1300
- byteQuantum: 1,
1301
- byteLength: (ref) => Math.min(ref.bytes, requestImagePolicy.maxBytes),
1302
- placeholder: (ref) => offloadedImageText(ref, resolveImageAccess(ref))
1303
- });
1304
- const requestImages = await prepareRequestImages(requestMessages, attachments, requestImagePolicy, options.signal);
1305
- const exactMessages = offloadRequestImagesWithPolicy(requestMessages, {
1306
- representation: "base64",
1307
- ...maxRequestImageBytes === void 0 ? {} : { maxBytes: maxRequestImageBytes },
1308
- byteQuantum: 1,
1309
- byteLength: (ref) => requestImages.get(ref.attachmentId).bytes,
1310
- placeholder: (ref) => offloadedImageText(ref, resolveImageAccess(ref))
1311
- });
1312
- const toolNames = /* @__PURE__ */ new Map();
1313
- const messages = [];
1314
- for (const message of exactMessages) {
1315
- if (message.role === "system") {
1316
- messages.push({
1317
- role: "user",
1318
- content: flattenText(message),
1319
- timestamp: 0
1320
- });
1321
- continue;
1322
- }
1323
- if (message.role === "assistant") {
1324
- appendAssistant(message, messages, toolNames, onReplayDegrade);
1325
- continue;
1326
- }
1327
- const content = await userContent(message.content.filter((block) => block.type !== "tool-result"), requestImages, resolveImageAccess);
1328
- const results = message.content.filter((block) => block.type === "tool-result");
1329
- if (content.length > 0 || results.length === 0) messages.push({
1330
- role: "user",
1331
- content,
1332
- timestamp: 0
1333
- });
1334
- for (const result of results) {
1335
- const resultContent = await userContent(result.content, requestImages, resolveImageAccess);
1336
- messages.push({
1337
- role: "toolResult",
1338
- toolCallId: result.toolCallId,
1339
- toolName: toolNames.get(result.toolCallId) ?? "unknown",
1340
- content: typeof resultContent === "string" ? [{
1341
- type: "text",
1342
- text: resultContent || "(no output)"
1343
- }] : resultContent,
1344
- isError: result.isError ?? false,
1345
- timestamp: 0
1346
- });
1347
- }
1348
- }
1349
- return piContext(split.systemPrompt, options, messages);
1350
- }
1351
- //#endregion
1352
- //#region lib/types/stream.js
1353
- /**
1354
- * pi-ai assistant event translation into the Harness streaming protocol.
1355
- *
1356
- * pi-ai tool-call arguments are parsed objects while the Harness keeps their
1357
- * raw JSON representation. pi-ai also reports failures as terminal stream
1358
- * events, which this module maps into Harness finish chunks.
1359
- *
1360
- * @module dsh-llm-pi-ai/stream
1361
- */
1362
- /**
1363
- * Map pi-ai usage (reasoning folded into output by pi-ai).
1364
- * @param usage - cumulative usage from the terminal pi-ai event.
1365
- * @returns harness counts with pi-ai's exact total; cache fields appear only
1366
- * when non-zero (pi-ai reports zeros, not absence).
1367
- */
1368
- function mapUsage(usage) {
1369
- return {
1370
- inputTokens: usage.input,
1371
- outputTokens: usage.output,
1372
- totalTokens: usage.totalTokens,
1373
- ...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
1374
- ...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {}
1375
- };
1376
- }
1377
- function classifyPiAiError(message) {
1378
- if (/\b(?:401|403)\b/.test(message)) return "AUTH";
1379
- if (isQuotaExceededError(message)) return QUOTA_EXCEEDED_CODE;
1380
- if (/\b429\b|rate.?limit/i.test(message)) return "RATE_LIMIT";
1381
- if (/\b413\b|failed to buffer the request body:\s*length limit exceeded|payload too large|request body too large/i.test(message)) return "INVALID_REQUEST";
1382
- if (/\b400\b|invalid.?request/i.test(message)) return "INVALID_REQUEST";
1383
- if (/\b5\d\d\b/.test(message)) return "SERVER";
1384
- if (/\btime(?:d)?\s*out\b|timeout/i.test(message)) return "TIMEOUT";
1385
- if (/stream ended (?:before|without)\b/i.test(message)) return "TRANSPORT";
1386
- 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";
1387
- return "PI_AI_ERROR";
1388
- }
1389
- /**
1390
- * Map a terminal pi-ai event to the harness finish reason.
1391
- * @param message - the assistant message carried by the `done` or `error` event.
1392
- * @param contextWindow - resolved catalog capacity for usage-based overflow detection.
1393
- * @returns the mapped harness reason. Recognized error text, `stop` usage above
1394
- * `contextWindow`, and zero-output `length` usage that fills the window map
1395
- * to `CONTEXT_WINDOW_EXCEEDED`; a `stop` with no content blocks maps to an
1396
- * `EMPTY_RESPONSE` error, while terminal `pending` and `deferred` states map
1397
- * to non-retryable `PI_AI_ERROR` failures.
1398
- */
1399
- function mapStopReason(message, contextWindow) {
1400
- const piAiOverflow = isContextOverflow(message, contextWindow);
1401
- const harnessOverflow = message.stopReason === "error" && message.errorMessage !== void 0 && isContextWindowExceededError(message.errorMessage);
1402
- if (piAiOverflow || harnessOverflow) return {
1403
- kind: "error",
1404
- failure: {
1405
- message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`,
1406
- code: CONTEXT_WINDOW_EXCEEDED_CODE
1407
- }
1408
- };
1409
- switch (message.stopReason) {
1410
- case "stop":
1411
- if (message.content.length === 0) return {
1412
- kind: "error",
1413
- failure: {
1414
- message: `model "${message.model}" returned a completed response with no content`,
1415
- code: EMPTY_RESPONSE_CODE
1416
- }
1417
- };
1418
- return { kind: "stop" };
1419
- case "length": return { kind: "max-tokens" };
1420
- case "toolUse": return { kind: "tool-calls" };
1421
- case "pending": return {
1422
- kind: "error",
1423
- failure: {
1424
- message: `pi-ai stream for model "${message.model}" ended pending`,
1425
- code: "PI_AI_ERROR"
1426
- }
1427
- };
1428
- case "deferred": return {
1429
- kind: "error",
1430
- failure: {
1431
- message: `pi-ai deferred response for model "${message.model}" is not supported`,
1432
- code: "PI_AI_ERROR"
1433
- }
1434
- };
1435
- case "aborted": return {
1436
- kind: "aborted",
1437
- failure: {
1438
- message: message.errorMessage ?? "pi-ai stream aborted",
1439
- code: "ABORTED"
1440
- }
1441
- };
1442
- case "error": {
1443
- const text = message.errorMessage ?? "pi-ai stream error";
1444
- return {
1445
- kind: "error",
1446
- failure: {
1447
- message: text,
1448
- code: classifyPiAiError(text)
1449
- }
1450
- };
1451
- }
1452
- }
1453
- }
1454
- /**
1455
- * Translate the pi-ai event stream into StreamChunks. pi-ai never throws
1456
- * mid-stream — failures arrive as `error` events, which become error/aborted
1457
- * `finish` chunks (the harness protocol's other error-delivery style).
1458
- * @param events - one assistant turn's pi-ai event stream.
1459
- * @param contextWindow - resolved catalog capacity for usage-based overflow detection.
1460
- * @param callerSignal - caller cancellation state; an aborted caller makes any
1461
- * in-band terminal error an aborted finish.
1462
- * @param requestedModel - request model identity for durable replay provenance.
1463
- * @returns the harness chunks, ending with `usage` then `finish`; throws
1464
- * `LlmError` (`STREAM_CLOSED`) if the source ends without a terminal event.
1465
- */
1466
- async function* toStreamChunks(events, contextWindow, callerSignal, requestedModel) {
1467
- const toolIds = /* @__PURE__ */ new Map();
1468
- for await (const event of events) switch (event.type) {
1469
- case "start": break;
1470
- case "text_start":
1471
- yield {
1472
- type: "block-start",
1473
- index: event.contentIndex,
1474
- blockType: "text"
1475
- };
1476
- break;
1477
- case "text_delta":
1478
- yield {
1479
- type: "text-delta",
1480
- index: event.contentIndex,
1481
- text: event.delta
1482
- };
1483
- break;
1484
- case "text_end":
1485
- yield {
1486
- type: "block-end",
1487
- index: event.contentIndex,
1488
- block: {
1489
- type: "text",
1490
- text: event.content
1491
- }
1492
- };
1493
- break;
1494
- case "thinking_start":
1495
- yield {
1496
- type: "block-start",
1497
- index: event.contentIndex,
1498
- blockType: "reasoning"
1499
- };
1500
- break;
1501
- case "thinking_delta":
1502
- yield {
1503
- type: "reasoning-delta",
1504
- index: event.contentIndex,
1505
- text: event.delta
1506
- };
1507
- break;
1508
- case "thinking_end":
1509
- yield {
1510
- type: "block-end",
1511
- index: event.contentIndex,
1512
- block: {
1513
- type: "reasoning",
1514
- text: event.content
1515
- }
1516
- };
1517
- break;
1518
- case "toolcall_start": {
1519
- const partial = event.partial.content[event.contentIndex];
1520
- const id = partial?.type === "toolCall" ? partial.id : "";
1521
- const name = partial?.type === "toolCall" ? partial.name : "";
1522
- toolIds.set(event.contentIndex, {
1523
- id,
1524
- name
1525
- });
1526
- yield {
1527
- type: "block-start",
1528
- index: event.contentIndex,
1529
- blockType: "tool-call"
1530
- };
1531
- break;
1532
- }
1533
- case "toolcall_delta": {
1534
- const known = toolIds.get(event.contentIndex);
1535
- yield {
1536
- type: "tool-call-delta",
1537
- index: event.contentIndex,
1538
- id: brandString(known?.id ?? ""),
1539
- ...known?.name !== void 0 && known.name.length > 0 ? { name: known.name } : {},
1540
- argumentsDelta: event.delta
1541
- };
1542
- break;
1543
- }
1544
- case "toolcall_end":
1545
- yield {
1546
- type: "block-end",
1547
- index: event.contentIndex,
1548
- block: {
1549
- type: "tool-call",
1550
- id: brandString(event.toolCall.id),
1551
- name: event.toolCall.name,
1552
- arguments: JSON.stringify(event.toolCall.arguments)
1553
- }
1554
- };
1555
- break;
1556
- case "done":
1557
- yield {
1558
- type: "usage",
1559
- usage: mapUsage(event.message.usage)
1560
- };
1561
- yield {
1562
- type: "finish",
1563
- reason: mapStopReason(event.message, contextWindow),
1564
- replayState: toPiReplayState(event.message, requestedModel)
1565
- };
1566
- return;
1567
- case "error":
1568
- yield {
1569
- type: "usage",
1570
- usage: mapUsage(event.error.usage)
1571
- };
1572
- yield {
1573
- type: "finish",
1574
- reason: mapStopReason(callerSignal?.aborted ? {
1575
- ...event.error,
1576
- stopReason: "aborted"
1577
- } : event.error, contextWindow)
1578
- };
1579
- return;
1580
- }
1581
- throw new LlmError("pi-ai event stream ended without done/error", "STREAM_CLOSED");
1582
- }
1583
- //#endregion
1584
- //#region lib/types/adapter.js
1585
- /**
1586
- * Generic pi-ai-backed implementation of the Harness LLM seam.
1587
- *
1588
- * Each resolution produces one **immutable** snapshot — the profiles plus a
1589
- * `Models` collection holding the `Provider` each route built — and an
1590
- * operation captures a whole snapshot before its first `await`. A
1591
- * configuration change builds a *new* collection rather than mutating the one
1592
- * in use, because `Models.streamSimple()` is lazy: it resolves the provider
1593
- * when the stream is first consumed, which is after the credential await, so a
1594
- * mutated collection would let a request that started under one configuration
1595
- * finish under another — or fail with a provider that no longer exists. This is
1596
- * what makes the seam's per-step call freeze (`llm.prepareCall()`) hold all the
1597
- * way down: switching models mid-reply takes effect on the next step, never
1598
- * inside the one in flight.
1599
- *
1600
- * A route naming a credential reference still resolves it through the harness
1601
- * seam and passes it as the request's `apiKey` option, which pi-ai treats as
1602
- * the highest-priority auth override — that is what keeps the fail-loud
1603
- * reference semantics. Everything that override does not cover reaches pi-ai
1604
- * through the collection's own auth: the credential store holds the records a
1605
- * login wrote and a refresh rotates, and the auth context answers the ambient
1606
- * questions a provider asks while resolving. Both are stable across snapshots,
1607
- * so a configuration change rebuilds the collection without forgetting who is
1608
- * signed in.
1609
- *
1610
- * @module dsh-llm-pi-ai/adapter
1611
- */
1612
- var __addDisposableResource = function(env, value, async) {
1613
- if (value !== null && value !== void 0) {
1614
- if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
1615
- var dispose, inner;
1616
- if (async) {
1617
- if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
1618
- dispose = value[Symbol.asyncDispose];
1619
- }
1620
- if (dispose === void 0) {
1621
- if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
1622
- dispose = value[Symbol.dispose];
1623
- if (async) inner = dispose;
1624
- }
1625
- if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
1626
- if (inner) dispose = function() {
1627
- try {
1628
- inner.call(this);
1629
- } catch (e) {
1630
- return Promise.reject(e);
1631
- }
1632
- };
1633
- env.stack.push({
1634
- value,
1635
- dispose,
1636
- async
1637
- });
1638
- } else if (async) env.stack.push({ async: true });
1639
- return value;
1640
- };
1641
- var __disposeResources = (function(SuppressedError) {
1642
- return function(env) {
1643
- function fail(e) {
1644
- env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
1645
- env.hasError = true;
1646
- }
1647
- var r, s = 0;
1648
- function next() {
1649
- while (r = env.stack.pop()) try {
1650
- if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
1651
- if (r.dispose) {
1652
- var result = r.dispose.call(r.value);
1653
- if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
1654
- fail(e);
1655
- return next();
1656
- });
1657
- } else s |= 1;
1658
- } catch (e) {
1659
- fail(e);
1660
- }
1661
- if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
1662
- if (env.hasError) throw env.error;
1663
- }
1664
- return next();
1665
- };
1666
- })(typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
1667
- var e = new Error(message);
1668
- return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
1669
- });
1670
- /** Copy profile stream knobs into pi-ai's common option vocabulary. */
1671
- function profileOptions(profile, reasoning, apiKey) {
1672
- const enabledReasoning = reasoning === "off" ? void 0 : reasoning;
1673
- return {
1674
- ...apiKey === void 0 ? {} : { apiKey },
1675
- ...enabledReasoning === void 0 ? {} : { reasoning: enabledReasoning },
1676
- ...profile.thinkingBudgets === void 0 ? {} : { thinkingBudgets: profile.thinkingBudgets },
1677
- ...profile.cacheRetention === void 0 ? {} : { cacheRetention: profile.cacheRetention },
1678
- ...profile.transport === void 0 ? {} : { transport: profile.transport },
1679
- ...profile.timeoutMs === void 0 ? {} : { timeoutMs: profile.timeoutMs },
1680
- ...profile.websocketConnectTimeoutMs === void 0 ? {} : { websocketConnectTimeoutMs: profile.websocketConnectTimeoutMs },
1681
- maxRetries: 0
1682
- };
1683
- }
1684
- /**
1685
- * The profile default this exact model can actually take, for DESCRIBING it.
1686
- * A configured level the model does not support yields none rather than
1687
- * throwing: `resolveModel` builds the model catalog, and a catalog that fails
1688
- * takes its whole provider out of every picker — so one mis-set profile field
1689
- * would hide every model on the route, including the ones that support the
1690
- * level. The request path still refuses, which is where a bad configuration
1691
- * belongs: describing what a model can do must not fail because a deployment
1692
- * asked it for something it cannot.
1693
- * @param model - the resolved model descriptor.
1694
- * @param effort - the profile's configured level, if any.
1695
- * @returns the level when this model supports it, otherwise undefined.
1696
- */
1697
- function describableReasoningLevel(model, effort) {
1698
- if (effort === void 0) return void 0;
1699
- return getSupportedThinkingLevels(model).some((level) => level === effort) ? effort : void 0;
1700
- }
1701
- /** Validate an explicit Harness/profile effort without invoking pi-ai's clamp. */
1702
- function resolveReasoningLevel(model, effort) {
1703
- if (effort === void 0) return void 0;
1704
- if (effort === "ultra" && getSupportedThinkingLevels(model).includes("max")) return "max";
1705
- if (getSupportedThinkingLevels(model).some((level) => level === effort)) return effort;
1706
- throw new LlmError(`pi-ai provider "${model.provider}" model "${model.id}" does not support reasoning effort "${effort}"`, "UNSUPPORTED_REASONING_EFFORT");
1707
- }
1708
- /**
1709
- * Selectable reasoning efforts for one model, or nothing at all.
1710
- *
1711
- * A model that carries no reasoning metadata — every hand-declared one, and
1712
- * every catalog model pi-ai marks as non-reasoning — is reported by pi-ai as
1713
- * supporting the single level `off`. Passing that through would offer a control
1714
- * that cannot do what it says: `off` is translated to *omitting* the reasoning
1715
- * option, which for such a model is byte-for-byte the same request as naming no
1716
- * effort — so a provider whose own default is to think would keep thinking with
1717
- * `off` selected. Omitting `reasoning` entirely is the seam's way of saying the
1718
- * capability is unavailable, which leaves the surface offering only the
1719
- * provider's default.
1720
- * @param model - the resolved model descriptor.
1721
- * @param defaultLevel - the profile's configured effort, already validated.
1722
- * @returns the `reasoning` field, or an empty object when none can be offered.
1723
- */
1724
- function reasoningInfo(model, defaultLevel) {
1725
- if (!model.reasoning) return {};
1726
- return { reasoning: {
1727
- efforts: getSupportedThinkingLevels(model).map((level) => ({
1728
- id: ReasoningEffortId(level),
1729
- name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`
1730
- })).concat(getSupportedThinkingLevels(model).includes("max") ? [{ id: ReasoningEffortId("ultra"), name: "Ultra", description: "DSCODE: max reasoning plus deliberate subagent collaboration; higher total token use." }] : []),
1731
- ...defaultLevel === void 0 ? {} : { defaultEffort: ReasoningEffortId(defaultLevel) }
1732
- } };
1733
- }
1734
- /** Merge deployment headers while removing case-insensitive attribution collisions. */
1735
- function requestHeaders(headers) {
1736
- const attribution = attributionHeaders();
1737
- const reserved = new Set(Object.keys(attribution).map((name) => name.toLowerCase()));
1738
- return {
1739
- ...Object.fromEntries(Object.entries(headers ?? {}).filter(([name]) => !reserved.has(name.toLowerCase()))),
1740
- ...attribution
1741
- };
1742
- }
1743
- /**
1744
- * pi-ai-backed multi-provider adapter. Each operation reads the current
1745
- * profiles, so a configuration change reaches the next request without a
1746
- * restart; model descriptors come from the collection those profiles built.
1747
- */
1748
- var PiAiAdapter = class extends LlmAdapter {
1749
- config;
1750
- snapshot;
1751
- constructor(config) {
1752
- super();
1753
- this.config = config;
1754
- }
1755
- /**
1756
- * The snapshot for the current profiles. Resolution memoizes its result, so
1757
- * an unchanged configuration is recognized by identity; a changed one gets a
1758
- * brand-new collection, leaving any snapshot an operation already captured
1759
- * untouched for as long as that operation holds it.
1760
- */
1761
- current() {
1762
- const profiles = this.config.profiles();
1763
- if (this.snapshot?.profiles === profiles) return this.snapshot;
1764
- const models = createModels(this.config.auth);
1765
- for (const profile of profiles.values()) if (profile.piProvider !== void 0) models.setProvider(profile.piProvider);
1766
- this.snapshot = {
1767
- profiles,
1768
- models
1769
- };
1770
- return this.snapshot;
1771
- }
1772
- /** The profile for one route within one snapshot, or the not-owned failure. */
1773
- profileOf(snapshot, provider) {
1774
- const profile = snapshot.profiles.get(provider);
1775
- if (profile === void 0) throw new LlmError(`pi-ai adapter does not own provider "${provider}"`, "NO_ADAPTER");
1776
- return profile;
1777
- }
1778
- /** The configured descriptor for one exact route/model pair within one snapshot. */
1779
- modelOf(snapshot, provider, model) {
1780
- const profile = this.profileOf(snapshot, provider);
1781
- const failure = profile.modelErrors.get(model) ?? (profile.piProvider === void 0 ? profile.catalogError : void 0);
1782
- if (failure !== void 0) throw new LlmError(failure, "INVALID_CONFIG");
1783
- const resolved = snapshot.models.getModel(provider, model);
1784
- if (resolved === void 0) throw new LlmError(`pi-ai provider "${provider}" has no configured model "${model}"`, "UNKNOWN_MODEL");
1785
- return resolved;
1786
- }
1787
- providerInfo(provider) {
1788
- return {
1789
- id: provider,
1790
- name: this.current().profiles.get(provider)?.displayName ?? provider
1791
- };
1792
- }
1793
- providerRetryPolicy(provider) {
1794
- return this.current().profiles.get(provider)?.retryPolicy;
1795
- }
1796
- listModels(provider) {
1797
- return Promise.resolve().then(() => {
1798
- const snapshot = this.current();
1799
- this.profileOf(snapshot, provider);
1800
- return snapshot.models.getModels(provider).map((model) => ({
1801
- provider,
1802
- id: model.id,
1803
- name: model.name,
1804
- inputModalities: [...model.input]
1805
- }));
1806
- });
1807
- }
1808
- resolveModel(provider, model, _signal) {
1809
- return Promise.resolve().then(() => {
1810
- const snapshot = this.current();
1811
- return this.modelInfo(snapshot, provider, model);
1812
- });
1813
- }
1814
- modelInfo(snapshot, provider, model) {
1815
- const profile = this.profileOf(snapshot, provider);
1816
- const resolvedModel = this.modelOf(snapshot, provider, model);
1817
- const defaultLevel = describableReasoningLevel(resolvedModel, profile.reasoning);
1818
- const configuredMaxTokens = profile.configuredMaxTokens.get(model);
1819
- return {
1820
- provider,
1821
- id: model,
1822
- name: resolvedModel.name,
1823
- inputModalities: [...resolvedModel.input],
1824
- context: { contextWindow: resolvedModel.contextWindow },
1825
- ...configuredMaxTokens === void 0 ? {} : { defaultMaxTokens: configuredMaxTokens },
1826
- ...reasoningInfo(resolvedModel, defaultLevel)
1827
- };
1828
- }
1829
- prepareCall(provider, model, _signal) {
1830
- const snapshot = this.current();
1831
- return Promise.resolve({
1832
- model: this.modelInfo(snapshot, provider, model),
1833
- stream: (options) => this.streamWithSnapshot(options, snapshot)
1834
- });
1835
- }
1836
- stream(options) {
1837
- return this.streamWithSnapshot(options, this.current());
1838
- }
1839
- async *streamWithSnapshot(options, snapshot) {
1840
- options = piAiRequest(options);
1841
- const env_1 = {
1842
- stack: [],
1843
- error: void 0,
1844
- hasError: false
1845
- };
1846
- try {
1847
- if (options.stop !== void 0) throw new LlmError("llm-pi-ai does not support GenerateOptions.stop", "UNSUPPORTED_OPTION");
1848
- const profile = this.profileOf(snapshot, options.provider);
1849
- const model = this.modelOf(snapshot, options.provider, options.model);
1850
- const reasoning = resolveReasoningLevel(model, options.reasoningEffort ?? describableReasoningLevel(model, profile.reasoning));
1851
- const apiKey = await this.config.resolveApiKey(options.provider, profile);
1852
- const consumer = new AbortController();
1853
- const upstream = options.signal === void 0 ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]);
1854
- const streamIdleTimeoutMs = profile.streamIdleTimeoutMs;
1855
- const watchdog = __addDisposableResource(env_1, idleWatchdog(upstream, streamIdleTimeoutMs, "LLM_STREAM_IDLE_TIMEOUT"), false);
1856
- try {
1857
- const containsImage = options.messages.some((message) => contentHasImage(message.content));
1858
- if (containsImage && !model.input.includes("image")) throw new LlmError(`pi-ai model "${model.id}" does not support image input`, "UNSUPPORTED_CONTENT");
1859
- const attachments = containsImage ? this.config.resolveAttachments?.() : void 0;
1860
- if (containsImage && attachments === void 0) throw new LlmError("pi-ai image input requires the durable attachment service", "UNSUPPORTED_CONTENT");
1861
- const onReplayDegrade = (reason) => {
1862
- this.config.onReplayDegrade?.({
1863
- provider: options.provider,
1864
- model: options.model,
1865
- reason
1866
- });
1867
- };
1868
- const context = attachments === void 0 ? toPiContext(options, void 0, onReplayDegrade) : await toPiContext({
1869
- ...options,
1870
- signal: watchdog.signal
1871
- }, {
1872
- attachments,
1873
- resolveImageAccess: (ref) => this.config.resolveImageAccess?.(attachments, ref),
1874
- maxRequestImageBytes: profile.maxRequestImageBytes,
1875
- requestImagePolicy: {
1876
- maxPixels: profile.requestImagePixelBudget,
1877
- maxBytes: profile.requestImageMaxBytes
1878
- }
1879
- }, onReplayDegrade);
1880
- const iterator = toStreamChunks(snapshot.models.streamSimple(model, context, {
1881
- ...profileOptions(profile, reasoning, apiKey),
1882
- ...options.temperature === void 0 ? {} : { temperature: options.temperature },
1883
- ...options.maxTokens === void 0 ? {} : { maxTokens: options.maxTokens },
1884
- ...options.sessionId === void 0 ? {} : { sessionId: String(options.sessionId) },
1885
- signal: watchdog.signal,
1886
- headers: requestHeaders(profile.headers)
1887
- }), model.contextWindow, options.signal, model.id)[Symbol.asyncIterator]();
1888
- let exhausted = false;
1889
- try {
1890
- while (true) {
1891
- const result = await watchdog.next(iterator);
1892
- const timeout = timeoutOf(watchdog.signal, "LLM_STREAM_IDLE_TIMEOUT");
1893
- if (timeout !== void 0) throw timeout;
1894
- if (result.done) {
1895
- exhausted = true;
1896
- return;
1897
- }
1898
- yield result.value;
1899
- }
1900
- } finally {
1901
- if (!exhausted) {
1902
- consumer.abort("pi-ai stream consumer stopped");
1903
- try {
1904
- await iterator.return(void 0);
1905
- } catch (_abortedSdkTeardown) {}
1906
- }
1907
- }
1908
- } catch (error) {
1909
- if (timeoutOf(watchdog.signal, "LLM_STREAM_IDLE_TIMEOUT") !== void 0) throw new LlmError(`pi-ai stream idle timeout after ${streamIdleTimeoutMs}ms`, "TIMEOUT", { cause: error });
1910
- if (options.signal?.aborted) throw new LlmError("pi-ai request aborted by caller", "ABORTED", { cause: error });
1911
- throw error;
1912
- } finally {
1913
- consumer.abort("pi-ai stream consumer stopped");
1914
- }
1915
- } catch (e_1) {
1916
- env_1.error = e_1;
1917
- env_1.hasError = true;
1918
- } finally {
1919
- __disposeResources(env_1);
1920
- }
1921
- }
1922
- };
1923
- //#endregion
1924
- //#region lib/types/auth.js
1925
- /**
1926
- * The three adapters between pi-ai's auth model and the harness credential
1927
- * plane. Every pi-ai-specific concept stays on this side of them: the harness
1928
- * seams they consume — `ctx.credentials` records and `ctx.authorization` flows —
1929
- * name nothing from this library, so another adapter family can arrive with a
1930
- * different auth model and share the same two seams.
1931
- *
1932
- * @module dsh-llm-pi-ai/auth
1933
- */
1934
- /**
1935
- * The record scope every credential this adapter family stores is written
1936
- * under. It is the plugin's registered name, which is what tells a later
1937
- * reader — a configuration UI, or a second adapter family serving the same
1938
- * provider name — that this plugin owns the format inside the record.
1939
- */
1940
- const RECORD_SCOPE = "llm-pi-ai";
1941
- /**
1942
- * The record address for one pi-ai provider id.
1943
- * @param providerId - pi-ai's own provider id, which is also the harness route key.
1944
- * @returns the scoped credential key this adapter family reads and writes.
1945
- */
1946
- function recordKeyFor(providerId) {
1947
- return credentialKey(RECORD_SCOPE, providerId);
1948
- }
1949
- /**
1950
- * The JSON image of one grant payload: plain objects lose their
1951
- * explicitly-undefined members and array entries JSON cannot hold become
1952
- * null, exactly as `JSON.stringify` would render them. pi-ai credentials
1953
- * idiomatically carry optional members as explicit `undefined` (a github.com
1954
- * Copilot grant holds `enterpriseUrl: undefined`), which the credential
1955
- * store's strict validator refuses as unrepresentable. Everything else —
1956
- * non-finite numbers and foreign prototypes included — passes through
1957
- * untouched, so a genuinely unstorable value still fails loud at the store.
1958
- * @param value - the value to render.
1959
- * @returns the value's JSON image.
1960
- */
1961
- function jsonImage(value) {
1962
- if (Array.isArray(value)) return value.map((entry) => entry === void 0 ? null : jsonImage(entry));
1963
- if (typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype) {
1964
- const image = {};
1965
- for (const [key, member] of Object.entries(value)) if (member !== void 0) image[key] = jsonImage(member);
1966
- return image;
1967
- }
1968
- return value;
1969
- }
1970
- /**
1971
- * Translate a stored record into the credential pi-ai expects.
1972
- *
1973
- * An `api-key` record is structural on both sides, so it is rebuilt field by
1974
- * field. A `grant` payload is pi-ai's own OAuth credential, stored verbatim:
1975
- * the seam treats it as opaque JSON precisely so a library that owns a token
1976
- * format keeps owning it, refresh fields and all.
1977
- * @param record - the stored record, or undefined when nothing is stored.
1978
- * @returns the pi-ai credential, or undefined for an absent record.
1979
- */
1980
- function toPiCredential(record) {
1981
- if (record === void 0) return void 0;
1982
- if (record.kind === "api-key") return {
1983
- type: "api_key",
1984
- ...record.key === void 0 ? {} : { key: record.key },
1985
- ...record.env === void 0 ? {} : { env: { ...record.env } }
1986
- };
1987
- return record.payload;
1988
- }
1989
- /**
1990
- * Translate a pi-ai credential into the record to store.
1991
- * @param credential - what a login or refresh produced.
1992
- * @returns the record to commit, in the union the credential seam stores.
1993
- */
1994
- function toRecord(credential) {
1995
- if (credential.type === "api_key") return {
1996
- kind: "api-key",
1997
- ...credential.key === void 0 ? {} : { key: credential.key },
1998
- ...credential.env === void 0 ? {} : { env: { ...credential.env } }
1999
- };
2000
- return {
2001
- kind: "grant",
2002
- payload: jsonImage(credential)
2003
- };
2004
- }
2005
- /**
2006
- * The credential service, or the failure that names what is missing. Reads
2007
- * answer "nothing stored" without a service, because a composition with no
2008
- * credential plane genuinely holds no credential; writes refuse, because a
2009
- * login whose grant silently evaporated would report success and then fail
2010
- * every request.
2011
- * @param ctx - the plugin context.
2012
- * @returns the live service.
2013
- * @throws {LlmError} code `NO_CREDENTIAL_STORE` when none is mounted.
2014
- */
2015
- function writableStore(ctx) {
2016
- const credentials = ctx.get("credentials");
2017
- if (credentials === void 0) throw new LlmError("llm-pi-ai: this composition mounts no credentials service, so there is nowhere to store the credential a sign-in produces; mount one (dsh-credentials-local) to sign in", "NO_CREDENTIAL_STORE");
2018
- return credentials;
2019
- }
2020
- /**
2021
- * A pi-ai `CredentialStore` over the harness credential records.
2022
- *
2023
- * pi-ai runs OAuth refresh *inside* `modify()`, so this store's exclusion has
2024
- * to cover a network round trip rather than a file rename — which is why the
2025
- * record write path takes a wait limit of its own rather than the short one a
2026
- * local write would need.
2027
- *
2028
- * pi-ai asks this store about every provider in the collection, hand-declared
2029
- * routes included, and a route key is an arbitrary settings dict key while a
2030
- * record id is not. An id outside the record grammar can never have stored a
2031
- * record, so reads answer "nothing stored" and a delete has nothing to remove;
2032
- * only `modify` refuses it, because a write that cannot land must not report
2033
- * that it did.
2034
- * @param ctx - the plugin context carrying the optional `ctx.credentials`.
2035
- * @returns the store to hand `createModels()`.
2036
- */
2037
- function credentialStoreFrom(ctx) {
2038
- return {
2039
- async read(providerId) {
2040
- const credentials = ctx.get("credentials");
2041
- if (credentials === void 0) return void 0;
2042
- if (!isCredentialKeySegment(providerId)) return void 0;
2043
- return toPiCredential(await credentials.readRecord(recordKeyFor(providerId)));
2044
- },
2045
- async list() {
2046
- const stored = await ctx.get("credentials")?.listRecords() ?? [];
2047
- const mine = [];
2048
- for (const entry of stored) {
2049
- if (credentialKeyScope(entry.key) !== "llm-pi-ai") continue;
2050
- mine.push({
2051
- providerId: credentialKeyId(entry.key),
2052
- type: entry.kind === "api-key" ? "api_key" : "oauth"
2053
- });
2054
- }
2055
- return mine;
2056
- },
2057
- async modify(providerId, mutate) {
2058
- if (!isCredentialKeySegment(providerId)) throw new LlmError(`llm-pi-ai: provider id "${providerId}" cannot address a stored credential record (a record id is a lowercase hyphenated identifier); authenticate this route through apiKeyEnv instead of a stored credential`, "UNSTORABLE_PROVIDER_ID");
2059
- return toPiCredential(await writableStore(ctx).modifyRecord(recordKeyFor(providerId), async (current) => {
2060
- const next = await mutate(toPiCredential(current));
2061
- return next === void 0 ? void 0 : toRecord(next);
2062
- }));
2063
- },
2064
- async delete(providerId) {
2065
- if (!isCredentialKeySegment(providerId)) return;
2066
- await writableStore(ctx).deleteRecord(recordKeyFor(providerId));
2067
- }
2068
- };
2069
- }
2070
- /**
2071
- * A pi-ai `AuthContext` over the harness credential plane and the host
2072
- * filesystem.
2073
- *
2074
- * `env()` answers from the credential seam first, so a value a deployment
2075
- * stored through the harness is found by a provider's own ambient discovery —
2076
- * without this, that discovery reads only the process environment and a stored
2077
- * `AWS_ACCESS_KEY_ID` is invisible to it. `fileExists()` answers about the host
2078
- * process's own filesystem rather than the workspace `ctx.fs` seam, because the
2079
- * paths it is asked about (`~/.aws/credentials`, application-default
2080
- * credentials) are facts about where this process runs, not about the project
2081
- * under edit.
2082
- * @param ctx - the plugin context carrying the optional `ctx.credentials`.
2083
- * @returns the auth context to hand `createModels()`.
2084
- */
2085
- function authContextFrom(ctx) {
2086
- return {
2087
- async env(name) {
2088
- if (isCredentialRefName(name)) {
2089
- const hit = await ctx.get("credentials")?.resolve(credentialRef(name));
2090
- if (hit !== void 0) return hit.value;
2091
- }
2092
- return launchEnvironmentOf(ctx).get(name)?.value;
2093
- },
2094
- async fileExists(path) {
2095
- const expanded = path.startsWith("~/") || path === "~" ? resolve(homedir(), path.slice(1).replace(/^\//, "")) : path;
2096
- try {
2097
- await access(expanded);
2098
- return true;
2099
- } catch {
2100
- return false;
2101
- }
2102
- }
2103
- };
2104
- }
2105
- //#endregion
2106
- //#region lib/types/discovery.js
2107
- /**
2108
- * Answering "which models can this provider serve?" for the configuration
2109
- * surface's "fetch available models" action.
2110
- *
2111
- * A route the installed pi-ai catalog ships is answered **from that catalog**,
2112
- * with no network call at all: pi-ai's registry is the authoritative list for
2113
- * its own providers, and it carries the capacities a listing endpoint would
2114
- * not disclose. Only a route the catalog does not describe — a gateway, a
2115
- * self-hosted server — is interrogated over the wire.
2116
- *
2117
- * Neither path is a catalog refresh. Nothing here is stored: the request
2118
- * carries a draft the user is still editing, and the reply is candidate
2119
- * metadata the surface offers for adoption. `settings.yaml` remains the only
2120
- * thing that decides what a route serves.
2121
- *
2122
- * OpenAI-compatible and Anthropic Messages protocols are interrogated through
2123
- * their native model-listing endpoints. The parser accepts the standard
2124
- * `data` array and the enriched `models` map some compatible gateways expose.
2125
- * Every other protocol reports that it cannot be interrogated so the surface
2126
- * falls back to hand-entry rather than guessing its response fields.
2127
- *
2128
- * @module dsh-llm-pi-ai/discovery
2129
- */
2130
- /**
2131
- * Protocols whose model listing this module can read. OpenAI protocols use
2132
- * bearer auth at `GET {baseURL}/models`; Anthropic Messages uses `x-api-key`
2133
- * and `anthropic-version` at its native `GET /v1/models`. Azure is absent
2134
- * despite its OpenAI lineage — it authenticates with an `api-key` header and
2135
- * requires an `api-version` query — and Codex authenticates through OAuth;
2136
- * guessing at either would report an authentication failure as a provider
2137
- * with no models. pi-ai's remaining protocols are absent for the same reason.
2138
- */
2139
- const LISTABLE_PROTOCOLS = new Set([
2140
- "anthropic-messages",
2141
- "openai-completions",
2142
- "openai-responses"
2143
- ]);
2144
- /** Stable API version required by Anthropic's model-listing endpoint. */
2145
- const ANTHROPIC_VERSION = "2023-06-01";
2146
- /** Largest model-list page accepted by Anthropic's public endpoint; discovery reads one page and does not follow `has_more`. */
2147
- const ANTHROPIC_MODEL_LIMIT = 1e3;
2148
- /**
2149
- * Endpoint replies larger than this are refused. The endpoint is whatever URL
2150
- * the user typed, so the ceiling holds on the bytes actually read rather than
2151
- * on the length the server claims — the same two-stage shape `dsh-web-fetch`
2152
- * uses for its own caller-supplied URLs, except that a truncated model listing
2153
- * is not parseable, so overflow rejects instead of truncating.
2154
- */
2155
- const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
2156
- /** A positive integer field of a listing entry, or `undefined` when absent or unusable. */
2157
- function capacity(...candidates) {
2158
- for (const candidate of candidates) if (typeof candidate === "number" && Number.isInteger(candidate) && candidate > 0) return candidate;
2159
- }
2160
- /** A non-empty string field of a listing entry, or `undefined`. */
2161
- function label(...candidates) {
2162
- for (const candidate of candidates) if (typeof candidate === "string" && candidate.length > 0) return candidate;
2163
- }
2164
- /**
2165
- * Join the endpoint base with the protocol's listing path. The base is
2166
- * treated as a prefix rather than a URL to resolve against, so a deployment
2167
- * path such as `https://gateway.example/openai/v1` keeps its segments instead
2168
- * of losing them to `URL` resolution. OpenAI protocols list at
2169
- * `{baseURL}/models`. Anthropic lists at `{root}/v1/models`, where the root is
2170
- * the base without trailing slashes and without one trailing `/v1` segment:
2171
- * gateway documentation publishes both spellings of the same root. Only this
2172
- * listing URL normalizes that segment; model requests receive the configured
2173
- * `baseURL` unchanged.
2174
- */
2175
- function listingUrl(baseURL, api) {
2176
- const base = baseURL.replace(/\/+$/, "");
2177
- if (api !== "anthropic-messages") return `${base}/models`;
2178
- return `${base.endsWith("/v1") ? base.slice(0, -3) : base}/v1/models?limit=${String(ANTHROPIC_MODEL_LIMIT)}`;
2179
- }
2180
- /**
2181
- * Read a reply body, refusing one that outgrows the ceiling. A declared length
2182
- * is checked first so an honest server is turned away without transferring
2183
- * anything; the accumulated total is what actually enforces the bound, because
2184
- * a server that under-declares (or streams) tells us nothing up front.
2185
- */
2186
- async function readBounded(response, url) {
2187
- const oversized = () => new LlmError(`${url} answered with more than ${MAX_RESPONSE_BYTES} bytes`, "DISCOVERY_FAILED");
2188
- const declared = Number(response.headers.get("content-length") ?? NaN);
2189
- if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
2190
- await response.body?.cancel();
2191
- throw oversized();
2192
- }
2193
- /* v8 ignore next -- fetch always exposes a body stream on a 2xx Response; the null guard is defensive. */
2194
- if (response.body === null) return "";
2195
- const reader = response.body.getReader();
2196
- const chunks = [];
2197
- let total = 0;
2198
- try {
2199
- for (;;) {
2200
- const { done, value } = await reader.read();
2201
- if (done) break;
2202
- total += value.byteLength;
2203
- if (total > MAX_RESPONSE_BYTES) throw oversized();
2204
- chunks.push(value);
2205
- }
2206
- } finally {
2207
- /* v8 ignore next 4 -- cancel() after a completed or abandoned read settles without rejecting; unobserved best-effort cleanup. */
2208
- await reader.cancel().catch(() => {});
2209
- }
2210
- const body = new Uint8Array(total);
2211
- let offset = 0;
2212
- for (const chunk of chunks) {
2213
- body.set(chunk, offset);
2214
- offset += chunk.byteLength;
2215
- }
2216
- return new TextDecoder().decode(body);
2217
- }
2218
- /**
2219
- * Read one supported model-listing reply. The standard `data` array takes
2220
- * precedence when both supported formats are present. An enriched `models`
2221
- * map uses each property key as the endpoint-facing id; its nested `id` is
2222
- * only a fallback for an empty key because gateways may put a canonical model
2223
- * identity there instead of the alias they accept on requests. Only
2224
- * object-valued map entries are models; primitive properties are ignored
2225
- * because they may be directory metadata rather than model records.
2226
- *
2227
- * Entries without a usable id are skipped rather than failing the whole
2228
- * interrogation: a single malformed row should not deny the user the rest of
2229
- * a working endpoint's catalog. Missing names fall back to the adopted id so
2230
- * the Web form receives a complete human-readable row.
2231
- */
2232
- function readListing(body) {
2233
- const listing = body;
2234
- const data = listing?.data;
2235
- let listed;
2236
- if (Array.isArray(data)) listed = data.map((raw) => ({ raw }));
2237
- else {
2238
- const models = listing?.models;
2239
- if (models === null || typeof models !== "object" || Array.isArray(models)) throw new LlmError("the endpoint's model listing has neither a \"data\" array nor a \"models\" object; enter this provider's models by hand", "DISCOVERY_FAILED");
2240
- listed = Object.entries(models).filter(([, raw]) => raw !== null && typeof raw === "object" && !Array.isArray(raw)).map(([key, raw]) => ({
2241
- key,
2242
- raw
2243
- }));
2244
- }
2245
- const models = [];
2246
- for (const { key, raw } of listed) {
2247
- const entry = raw;
2248
- const id = label(key, entry?.id);
2249
- if (id === void 0) continue;
2250
- const name = label(entry?.name, entry?.display_name, entry?.displayName) ?? id;
2251
- const contextWindow = capacity(entry?.contextWindow, entry?.context_window, entry?.context_length, entry?.max_input_tokens, entry?.limit?.context);
2252
- const maxTokens = capacity(entry?.maxOutputTokens, entry?.max_output_tokens, entry?.maxTokens, entry?.max_tokens, entry?.limit?.output, entry?.top_provider?.max_completion_tokens);
2253
- models.push({
2254
- id,
2255
- name,
2256
- ...contextWindow === void 0 ? {} : { contextWindow },
2257
- ...maxTokens === void 0 ? {} : { maxTokens }
2258
- });
2259
- }
2260
- return models;
2261
- }
2262
- /**
2263
- * Accept one probe key, or refuse it before the header is built. Without this
2264
- * the `fetch` below would throw a ByteString `TypeError` that this function's
2265
- * catch reports as `could not reach <url>` — blaming the network for a local,
2266
- * deterministic fault.
2267
- * @param raw - the key typed into the form or read from storage.
2268
- * @returns the trimmed, usable key.
2269
- */
2270
- function usableProbeKey(raw) {
2271
- const checked = normalizeApiKey(raw);
2272
- if (checked.ok) return checked.value;
2273
- 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);
2274
- }
2275
- /**
2276
- * Interrogate one draft provider endpoint for the models it advertises.
2277
- * @param request - the endpoint, protocol, and one-shot credential to use.
2278
- * @param storedProfile - Host-owned headers and lazy credential resolution for
2279
- * the named route. It is read only on the path that reaches the network; the
2280
- * credential is resolved only when the draft carries none.
2281
- * @returns the advertised models in endpoint order.
2282
- * @throws LlmError when the protocol has no readable listing, the endpoint
2283
- * refuses or fails the request, or the reply is not a model listing.
2284
- */
2285
- async function discoverModels(request, storedProfile) {
2286
- if (request.provider !== void 0) {
2287
- const installed = catalogModels(request.provider);
2288
- if (installed.size > 0) return [...installed.values()].map((model) => ({
2289
- id: model.id,
2290
- name: model.name,
2291
- contextWindow: model.contextWindow,
2292
- maxTokens: model.maxTokens
2293
- }));
2294
- }
2295
- 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");
2296
- const api = request.api ?? "openai-completions";
2297
- 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");
2298
- const url = listingUrl(request.baseURL, api);
2299
- const stored = storedProfile?.();
2300
- const supplied = request.apiKey ?? await stored?.resolveApiKey();
2301
- const apiKey = supplied === void 0 ? void 0 : usableProbeKey(supplied);
2302
- let response;
2303
- try {
2304
- const headers = new Headers(stored?.headers === void 0 ? void 0 : Object.entries(stored.headers));
2305
- headers.set("accept", "application/json");
2306
- if (api === "anthropic-messages") {
2307
- headers.set("anthropic-version", ANTHROPIC_VERSION);
2308
- if (apiKey !== void 0) headers.set("x-api-key", apiKey);
2309
- } else if (apiKey !== void 0) headers.set("authorization", `Bearer ${apiKey}`);
2310
- for (const [name, value] of Object.entries(attributionHeaders())) headers.set(name, value);
2311
- response = await fetch(url, {
2312
- method: "GET",
2313
- headers,
2314
- ...request.signal === void 0 ? {} : { signal: request.signal }
2315
- });
2316
- } catch (error) {
2317
- if (request.signal?.aborted) throw new LlmError("model discovery aborted by caller", "ABORTED", { cause: error });
2318
- throw new LlmError(`could not reach ${url}`, "DISCOVERY_FAILED", { cause: error });
2319
- }
2320
- if (!response.ok) throw new LlmError(`${url} answered ${response.status}${response.status === 401 || response.status === 403 ? "; check the API key" : ""}`, "DISCOVERY_FAILED");
2321
- let text;
2322
- try {
2323
- text = await readBounded(response, url);
2324
- } catch (error) {
2325
- if (request.signal?.aborted) throw new LlmError("model discovery aborted by caller", "ABORTED", { cause: error });
2326
- throw error;
2327
- }
2328
- let body;
2329
- try {
2330
- body = JSON.parse(text);
2331
- } catch (error) {
2332
- throw new LlmError(`${url} did not answer with JSON`, "DISCOVERY_FAILED", { cause: error });
2333
- }
2334
- return readListing(body);
2335
- }
2336
- //#endregion
2337
- //#region lib/types/login.js
2338
- /**
2339
- * Authorization flows for the pi-ai providers that ship a login. This is the
2340
- * whole of the translation between the harness's neutral notice/prompt
2341
- * vocabulary and pi-ai's `AuthInteraction`; nothing above it knows which
2342
- * library ran the conversation.
2343
- *
2344
- * @module dsh-llm-pi-ai/login
2345
- */
2346
- /**
2347
- * The login methods one catalog provider offers.
2348
- *
2349
- * A method appears only when pi-ai can actually run it: `oauth` always carries
2350
- * a `login`, while an api-key method has one only when the provider collects
2351
- * its key interactively — which every installed one currently does, so a key is
2352
- * typed into pi-ai's own prompt rather than into the settings form.
2353
- * @param provider - the installed catalog provider, if pi-ai ships one.
2354
- * @returns its methods, most preferred first; empty when it offers no login.
2355
- */
2356
- function loginMethods(provider) {
2357
- const methods = [];
2358
- const oauth = provider?.auth.oauth;
2359
- if (oauth !== void 0) methods.push({
2360
- id: "oauth",
2361
- label: oauth.loginLabel ?? oauth.name
2362
- });
2363
- const apiKey = provider?.auth.apiKey;
2364
- if (apiKey?.login !== void 0) methods.push({
2365
- id: "api-key",
2366
- label: apiKey.name
2367
- });
2368
- return methods;
2369
- }
2370
- /**
2371
- * Restate one pi-ai login event in the seam's vocabulary.
2372
- *
2373
- * A device-code grant is the one event carrying two things the human needs at
2374
- * once — where to go and what to type there — which is why the neutral notice
2375
- * has a `code` beside its `url` rather than folding the code into the message.
2376
- * @param event - what pi-ai reported.
2377
- * @param session - the attempt to report it to.
2378
- */
2379
- function relay(event, session) {
2380
- switch (event.type) {
2381
- case "info": {
2382
- const link = event.links?.[0];
2383
- session.notify({
2384
- message: event.message,
2385
- ...link === void 0 ? {} : { url: link.url }
2386
- });
2387
- return;
2388
- }
2389
- case "auth_url":
2390
- session.notify({
2391
- message: event.instructions ?? "Open this page to continue signing in.",
2392
- url: event.url
2393
- });
2394
- return;
2395
- case "device_code":
2396
- session.notify({
2397
- message: "Enter this code on the verification page to finish signing in.",
2398
- url: event.verificationUri,
2399
- code: event.userCode
2400
- });
2401
- return;
2402
- case "progress":
2403
- session.notify({ message: event.message });
2404
- return;
2405
- default: session.notify({ message: "Signing in…" });
2406
- }
2407
- }
2408
- /**
2409
- * Restate one pi-ai prompt in the seam's vocabulary.
2410
- *
2411
- * `manual_code` becomes a plain text question because the difference pi-ai
2412
- * draws — a code the human copies from a browser rather than a value they know
2413
- * — changes nothing a surface renders. Its own `signal` is carried through, and
2414
- * that is the part which matters: it is how a flow racing a typed code against
2415
- * a browser callback withdraws the losing question.
2416
- * @param prompt - what pi-ai asked.
2417
- * @returns the neutral prompt to put to the human.
2418
- */
2419
- function restate(prompt) {
2420
- const signal = prompt.signal === void 0 ? {} : { signal: prompt.signal };
2421
- switch (prompt.type) {
2422
- case "select": return {
2423
- ...signal,
2424
- kind: "select",
2425
- message: prompt.message,
2426
- options: prompt.options
2427
- };
2428
- case "secret": return {
2429
- ...signal,
2430
- kind: "secret",
2431
- message: prompt.message,
2432
- ...prompt.placeholder === void 0 ? {} : { placeholder: prompt.placeholder }
2433
- };
2434
- default: return {
2435
- ...signal,
2436
- kind: "text",
2437
- message: prompt.message,
2438
- ...prompt.placeholder === void 0 ? {} : { placeholder: prompt.placeholder }
2439
- };
2440
- }
2441
- }
2442
- /**
2443
- * Register one authorization flow per installed provider that ships a login.
2444
- *
2445
- * Registration is unconditional on configuration: a provider has to be signed
2446
- * into before a route for it is worth adding, so the flow exists from the
2447
- * moment the plugin mounts rather than appearing once a profile does.
2448
- * @param ctx - the plugin context carrying `ctx.authorization`.
2449
- * @param auth - the injectables every collection here is built with.
2450
- */
2451
- function registerPiAiFlows(ctx, auth) {
2452
- for (const providerId of catalogProviderIds()) {
2453
- const provider = catalogProvider(providerId);
2454
- const [first, ...rest] = loginMethods(provider);
2455
- /* v8 ignore next 3 -- every id here names an installed provider and every
2456
- installed provider ships a login, so no entry is skipped; the guard
2457
- is what keeps that from becoming a crash if either stops being true. */
2458
- if (provider === void 0 || first === void 0) continue;
2459
- /* v8 ignore next 7 -- every installed catalog id is a lowercase
2460
- hyphenated identifier; the guard keeps a future upstream id outside the
2461
- record grammar (dotted or uppercase, as vendor ids elsewhere already
2462
- are) from throwing in `recordKeyFor` and failing the whole mount. */
2463
- if (!isCredentialKeySegment(providerId)) {
2464
- ctx.logger.warn("llm-pi-ai: catalog provider \"%s\" cannot address a credential record; its sign-in is not offered", providerId);
2465
- continue;
2466
- }
2467
- ctx.authorization.registerFlow({
2468
- key: recordKeyFor(providerId),
2469
- label: provider.name,
2470
- methods: [first, ...rest],
2471
- async run(session) {
2472
- const models = createModels(auth);
2473
- models.setProvider(provider);
2474
- const type = session.method === "oauth" ? "oauth" : "api_key";
2475
- await models.login(providerId, type, {
2476
- signal: session.signal,
2477
- notify: (event) => {
2478
- relay(event, session);
2479
- },
2480
- prompt: (prompt) => session.prompt(restate(prompt))
2481
- });
2482
- }
2483
- });
2484
- }
2485
- }
2486
- //#endregion
2487
- //#region lib/types/index.js
2488
- /**
2489
- * Generic pi-ai-backed LLM adapter plugin. One plugin instance owns a dict of
2490
- * provider routes; a route naming an installed pi-ai provider inherits that
2491
- * provider's endpoint, protocol, and model catalog as defaults, and a route
2492
- * pi-ai does not ship is declared outright. Profile facts resolve per request
2493
- * over the optional `llm-pi-ai` user-settings section and the optional
2494
- * credential seam, so a changed key, endpoint, model, or knob reaches the next
2495
- * request without a restart; a changed *route set* (or a route's
2496
- * registration-captured retry policy) re-registers the same adapter instance
2497
- * in place.
2498
- *
2499
- * ```yaml
2500
- * - id: llm
2501
- * name: '@deepseek-ai/dsh-llm-pi-ai'
2502
- * config:
2503
- * providers:
2504
- * # Catalog route: everything but the credential comes from pi-ai.
2505
- * openai:
2506
- * apiKeyEnv: OPENAI_API_KEY
2507
- * retryPolicy:
2508
- * mode: normal
2509
- * maxRetries: 2
2510
- * # Catalog route with the catalog narrowed and one capacity corrected.
2511
- * anthropic:
2512
- * apiKeyEnv: ANTHROPIC_API_KEY
2513
- * models:
2514
- * - id: claude-sonnet-4-5
2515
- * contextWindow: 200000
2516
- * # Hand-declared route: pi-ai ships nothing under this key.
2517
- * acme-gateway:
2518
- * displayName: Acme Gateway
2519
- * apiKeyEnv: ACME_GATEWAY_API_KEY
2520
- * api: openai-completions
2521
- * baseURL: https://gateway.acme.example/v1
2522
- * # Reasoning dialect for a URL pi-ai cannot recognize.
2523
- * compat:
2524
- * thinkingFormat: deepseek
2525
- * models:
2526
- * - id: acme-large
2527
- * name: Acme Large
2528
- * contextWindow: 65536
2529
- * maxTokens: 4096
2530
- * - id: acme-think
2531
- * name: Acme Think
2532
- * contextWindow: 262144
2533
- * maxTokens: 32768
2534
- * # key = selectable level, value = wire spelling; only off may
2535
- * # leave the value empty (supported, send nothing).
2536
- * reasoningEfforts:
2537
- * off:
2538
- * high: high
2539
- * max: ultra
2540
- * ```
2541
- *
2542
- * @module @deepseek-ai/dsh-llm-pi-ai
2543
- */
2544
- const name = "llm-pi-ai";
2545
- const inject = ["llm"];
2546
- const NS = "llm-pi-ai";
2547
- /**
2548
- * The registry captures these per route; a change here must re-register.
2549
- * Sorted by provider so a settings document that merely reorders its keys is
2550
- * not mistaken for a route change.
2551
- */
2552
- function registrationFacts(profiles) {
2553
- return [...profiles.entries()].map(([provider, profile]) => ({
2554
- provider,
2555
- displayName: profile.displayName,
2556
- retryPolicy: profile.retryPolicy
2557
- })).sort((left, right) => left.provider.localeCompare(right.provider));
2558
- }
2559
- /**
2560
- * The configurable-provider directory: every installed catalog route, plus
2561
- * every route the current profiles declare. A hand-declared route has no
2562
- * catalog entry, so without this union it would have no settings address and
2563
- * configuration surfaces could neither show nor edit it.
2564
- * @param profiles - the currently resolved provider profiles.
2565
- * @returns the directory entries in catalog order, declared routes last.
2566
- */
2567
- function directoryEntries(profiles) {
2568
- const catalog = new Set(catalogProviderIds());
2569
- const entries = /* @__PURE__ */ new Map();
2570
- const declare = (provider, displayName, error) => {
2571
- entries.set(provider, {
2572
- provider,
2573
- displayName,
2574
- settingsNs: NS,
2575
- settingsPath: ["providers", provider],
2576
- declared: !catalog.has(provider),
2577
- ...error === void 0 ? {} : { error }
2578
- });
2579
- };
2580
- for (const provider of catalog) declare(provider, provider);
2581
- for (const [provider, profile] of profiles) declare(provider, profile.displayName, profile.catalogError);
2582
- return [...entries.values()];
2583
- }
2584
- /** Register one generic pi-ai adapter for all configured provider routes. */
2585
- function apply(ctx, config) {
2586
- let current = () => config;
2587
- let lastRaw;
2588
- let memoized;
2589
- /**
2590
- * The resolved profiles for the current configuration, memoized by the raw
2591
- * snapshot's identity — which is also what makes the adapter's own snapshot
2592
- * stable across operations that observe no change.
2593
- *
2594
- * Catalog diagnostics stay in the snapshot beside serviceable models, so
2595
- * stored configuration remains visible after an installed catalog changes.
2596
- * Scalar configuration errors still reject resolution.
2597
- */
2598
- const profiles = () => {
2599
- const raw = current();
2600
- if (raw === lastRaw && memoized !== void 0) return memoized;
2601
- const next = resolveProfiles(raw.providers, "deferred");
2602
- lastRaw = raw;
2603
- memoized = next;
2604
- return next;
2605
- };
2606
- profiles();
2607
- const resolveApiKey = async (provider, profile) => {
2608
- const ref = profile.apiKeyEnv;
2609
- if (ref === void 0) return void 0;
2610
- const credentials = ctx.get("credentials");
2611
- const hit = credentials !== void 0 ? (await credentials.resolve(ref))?.value : launchEnvironmentOf(ctx).get(ref)?.value;
2612
- if (hit !== void 0 && hit.length > 0) return assertUsableApiKey(hit, "llm-pi-ai", ref);
2613
- 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");
2614
- };
2615
- const auth = {
2616
- credentials: credentialStoreFrom(ctx),
2617
- authContext: authContextFrom(ctx)
2618
- };
2619
- const adapter = new PiAiAdapter({
2620
- profiles,
2621
- resolveApiKey,
2622
- auth,
2623
- resolveAttachments: () => ctx.get("attachments"),
2624
- resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(attachments, (hostPath) => ctx.get("fs")?.processPathFromHostPath(hostPath), ref),
2625
- onReplayDegrade: ({ provider, model, reason }) => {
2626
- ctx.logger.warn(`llm-pi-ai: unusable replay state on assistant history for route "${provider}/${model}"; sending that message as provider-neutral content (${reason})`);
2627
- }
2628
- });
2629
- ctx.inject(["authorization"], (authorized) => {
2630
- registerPiAiFlows(authorized, auth);
2631
- });
2632
- let directory;
2633
- let directoryFacts;
2634
- const ensureDirectory = () => {
2635
- const entries = directoryEntries(profiles());
2636
- if (deepEqualJson(entries, directoryFacts)) return;
2637
- if (directory === void 0) directory = ctx.llm.registerConfigurableProviders(entries);
2638
- else directory.replace(entries);
2639
- directoryFacts = entries;
2640
- };
2641
- ensureDirectory();
2642
- /** Host-owned request inputs for discovery of one configured route. */
2643
- const storedDiscoveryProfile = (provider) => {
2644
- if (provider === void 0) return void 0;
2645
- const profile = profiles().get(provider);
2646
- if (profile === void 0) return void 0;
2647
- return {
2648
- headers: profile.headers,
2649
- resolveApiKey: () => resolveApiKey(provider, profile)
2650
- };
2651
- };
2652
- ctx.llm.registerModelDiscovery(NS, (request, signal) => discoverModels({
2653
- ...request,
2654
- ...signal === void 0 ? {} : { signal }
2655
- }, () => storedDiscoveryProfile(request.provider)));
2656
- let registration;
2657
- let registeredFacts;
2658
- const ensureRegistrationFacts = () => {
2659
- const facts = registrationFacts(profiles());
2660
- if (deepEqualJson(facts, registeredFacts)) return;
2661
- const routes = [...profiles().keys()];
2662
- if (registration === void 0) {
2663
- if (routes.length === 0) {
2664
- registeredFacts = facts;
2665
- return;
2666
- }
2667
- registration = ctx.llm.registerAdapter(routes, adapter);
2668
- } else registration.replace(routes);
2669
- registeredFacts = facts;
2670
- };
2671
- ensureRegistrationFacts();
2672
- ctx.inject(["settings"], (settingsCtx) => {
2673
- let registering = true;
2674
- settingsCtx.settings.installSection(ctx, NS, Config, config, {
2675
- validate: (value) => {
2676
- if (registering) resolveProfiles(value.providers, "deferred");
2677
- else assertServiceable(value, current());
2678
- },
2679
- setSource: (source) => {
2680
- current = source;
2681
- },
2682
- onChange: () => {
2683
- try {
2684
- ensureRegistrationFacts();
2685
- } catch (error) {
2686
- ctx.logger.error("llm-pi-ai: keeping the previously registered routes after a refused update");
2687
- ctx.logger.error(error);
2688
- }
2689
- try {
2690
- ensureDirectory();
2691
- } catch (error) {
2692
- ctx.logger.error("llm-pi-ai: keeping the previous configurable-provider directory after a refused update");
2693
- ctx.logger.error(error);
2694
- }
2695
- }
2696
- });
2697
- registering = false;
2698
- });
2699
- }
2700
- //#endregion
2701
- export { Config, PiAiAdapter, apply, inject, name, recordKeyFor, supportedProtocols };