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