dsh-gateway-provider 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +234 -0
- package/README.zh.md +218 -0
- package/cordis.patch.yml +10 -0
- package/index.js +398 -0
- package/lib/adapter.js +222 -0
- package/lib/catalog.js +367 -0
- package/lib/client.js +1164 -0
- package/lib/modelsdev.js +113 -0
- package/lib/pi-bridge.js +481 -0
- package/lib/pi-provider.js +148 -0
- package/lib/protocols.js +150 -0
- package/lib/thinking.js +219 -0
- package/package.json +64 -0
package/lib/modelsdev.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* models.dev catalog enrichment.
|
|
3
|
+
*
|
|
4
|
+
* Fetches https://models.dev/models.json (configurable via `modelsUrl`) once
|
|
5
|
+
* per TTL and extracts per-model capability parameters used for automatic
|
|
6
|
+
* model control: context window, output-token cap, reasoning support, and
|
|
7
|
+
* family. The registry keys are namespaced
|
|
8
|
+
* (`minimax/MiniMax-M3`, `deepseek/deepseek-v4-flash`), while the newapi
|
|
9
|
+
* gateway advertises bare ids, so matching is a normalized suffix match.
|
|
10
|
+
*
|
|
11
|
+
* The enrichment is advisory: when the catalog is unreachable the adapter
|
|
12
|
+
* keeps working with configured defaults instead of failing requests.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-gateway-provider/modelsdev
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const DEFAULT_MODELS_URL = "https://models.dev/models.json";
|
|
18
|
+
/** In-memory cache: one raw map per URL, refreshed after the TTL. */
|
|
19
|
+
let modelsDevCache = { url: undefined, at: 0, data: undefined };
|
|
20
|
+
|
|
21
|
+
/** Normalize a model id for fuzzy matching (case + whitespace + slash). */
|
|
22
|
+
function normalizeId(id) {
|
|
23
|
+
return String(id).trim().toLowerCase().replace(/\s+/g, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Match a gateway model id against the namespaced models.dev keys.
|
|
28
|
+
* Exact key wins, then a case-insensitive suffix match after the last "/",
|
|
29
|
+
* then any normalized suffix equality.
|
|
30
|
+
* @param raw - the parsed models.dev map (id -> entry).
|
|
31
|
+
* @param id - the gateway model id.
|
|
32
|
+
* @returns the matched entry, or undefined.
|
|
33
|
+
*/
|
|
34
|
+
export function matchModelsDev(raw, id) {
|
|
35
|
+
if (raw === undefined) return undefined;
|
|
36
|
+
const direct = raw[id];
|
|
37
|
+
if (direct !== undefined) return direct;
|
|
38
|
+
const normalized = normalizeId(id);
|
|
39
|
+
if (normalized.length === 0) return undefined;
|
|
40
|
+
const bare = normalized.includes("/") ? normalized.slice(normalized.lastIndexOf("/") + 1) : normalized;
|
|
41
|
+
for (const [key, entry] of Object.entries(raw)) {
|
|
42
|
+
const keyNorm = normalizeId(key);
|
|
43
|
+
const keyBare = keyNorm.includes("/") ? keyNorm.slice(keyNorm.lastIndexOf("/") + 1) : keyNorm;
|
|
44
|
+
if (keyNorm === normalized || keyBare === bare) return entry;
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Fetch and cache the raw models.dev map.
|
|
51
|
+
* @param modelsUrl - catalog URL (file: URLs work for offline mirrors).
|
|
52
|
+
* @param signal - caller cancellation.
|
|
53
|
+
* @param ttlMs - cache freshness window.
|
|
54
|
+
* @returns the parsed map (id -> entry), or undefined when unavailable.
|
|
55
|
+
*/
|
|
56
|
+
export async function fetchModelsDev(modelsUrl = DEFAULT_MODELS_URL, signal, ttlMs = 6 * 60 * 60 * 1000) {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
const cache = modelsDevCache;
|
|
59
|
+
if (cache.url === modelsUrl && cache.data !== undefined && now - cache.at < ttlMs) {
|
|
60
|
+
return cache.data;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(modelsUrl, { signal });
|
|
64
|
+
if (!res.ok) return cache.url === modelsUrl ? cache.data : undefined;
|
|
65
|
+
const data = await res.json();
|
|
66
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) return undefined;
|
|
67
|
+
modelsDevCache = { url: modelsUrl, at: Date.now(), data };
|
|
68
|
+
return data;
|
|
69
|
+
} catch {
|
|
70
|
+
// Offline or transient failure: keep the last good snapshot when we have one.
|
|
71
|
+
return cache.url === modelsUrl ? cache.data : undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract the adapter-relevant parameters of one models.dev entry.
|
|
77
|
+
* @param entry - a matched models.dev entry (may be undefined).
|
|
78
|
+
* @returns a detached parameter object, all fields optional.
|
|
79
|
+
*/
|
|
80
|
+
export function extractModelsDevParams(entry) {
|
|
81
|
+
if (entry === undefined || typeof entry !== "object" || entry === null) return {};
|
|
82
|
+
const limit = typeof entry.limit === "object" && entry.limit !== null ? entry.limit : {};
|
|
83
|
+
const modalities = typeof entry.modalities === "object" && entry.modalities !== null ? entry.modalities : {};
|
|
84
|
+
const inputModalities = Array.isArray(modalities.input)
|
|
85
|
+
? modalities.input.filter((m) => typeof m === "string" && m.length > 0)
|
|
86
|
+
: [];
|
|
87
|
+
return {
|
|
88
|
+
name: typeof entry.name === "string" && entry.name.length > 0 ? entry.name : undefined,
|
|
89
|
+
description: typeof entry.description === "string" && entry.description.length > 0 ? entry.description : undefined,
|
|
90
|
+
family: typeof entry.family === "string" && entry.family.length > 0 ? entry.family : undefined,
|
|
91
|
+
reasoning: typeof entry.reasoning === "boolean" ? entry.reasoning : undefined,
|
|
92
|
+
input: inputModalities.length > 0 ? inputModalities : undefined,
|
|
93
|
+
attachment: typeof entry.attachment === "boolean" ? entry.attachment : undefined,
|
|
94
|
+
contextWindow: Number.isFinite(limit.context) && limit.context > 0 ? limit.context : undefined,
|
|
95
|
+
maxTokens: Number.isFinite(limit.output) && limit.output > 0 ? limit.output : undefined,
|
|
96
|
+
releaseDate: normalizeReleaseDate(entry.release_date ?? entry.releaseDate),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Coerce a models.dev release date (YYYY-MM-DD, YYYY-MM, or YYYY) into a
|
|
102
|
+
* lexicographically sortable YYYY-MM-DD string (missing month/day padded to
|
|
103
|
+
* -01). Returns "" for anything else so callers can compare with `<`.
|
|
104
|
+
*/
|
|
105
|
+
function normalizeReleaseDate(value) {
|
|
106
|
+
if (typeof value !== "string") return "";
|
|
107
|
+
const parts = value.trim().split("-");
|
|
108
|
+
if (parts.length < 1 || !/^\d{4}$/.test(parts[0])) return "";
|
|
109
|
+
const y = parts[0];
|
|
110
|
+
const m = parts.length > 1 && /^\d{2}$/.test(parts[1]) ? parts[1] : "01";
|
|
111
|
+
const d = parts.length > 2 && /^\d{2}$/.test(parts[2]) ? parts[2] : "01";
|
|
112
|
+
return `${y}-${m}-${d}`;
|
|
113
|
+
}
|
package/lib/pi-bridge.js
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harness ↔ pi-ai conversion bridge.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions extracted from the official `@deepseek-ai/dsh-llm-pi-ai`
|
|
5
|
+
* adapter (its `context`, `stream`, and `replay` modules), so a gateway-based
|
|
6
|
+
* provider can reuse the exact same pi-ai Context assembly and StreamChunk
|
|
7
|
+
* translation as the catalog-backed official provider, without reimplementing
|
|
8
|
+
* the wire formats itself.
|
|
9
|
+
*
|
|
10
|
+
* Three layers:
|
|
11
|
+
* - `toPiContext(options, attachments?)` — harness GenerateOptions → pi-ai Context
|
|
12
|
+
* - `toStreamChunks(events, contextWindow)` — pi-ai EventStream → harness StreamChunks
|
|
13
|
+
* - `mapStopReason(message, contextWindow)` — pi-ai stopReason → harness FinishReason
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-gateway-provider/pi-bridge
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
20
|
+
CallId,
|
|
21
|
+
EMPTY_RESPONSE_CODE,
|
|
22
|
+
LlmError,
|
|
23
|
+
QUOTA_EXCEEDED_CODE,
|
|
24
|
+
attributionHeaders,
|
|
25
|
+
contentHasImage,
|
|
26
|
+
isContextWindowExceededError,
|
|
27
|
+
isQuotaExceededError,
|
|
28
|
+
} from "@deepseek-ai/dsh-llm";
|
|
29
|
+
import { isContextOverflow } from "@earendil-works/pi-ai";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// argument parsing (shared with the old adapter)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/** Parse tool-call argument JSON; tolerate model malformations with {}. */
|
|
36
|
+
export function parseArguments(raw) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
|
|
40
|
+
} catch {}
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Construct the zero usage value required by historical pi-ai messages. */
|
|
45
|
+
function emptyPiUsage() {
|
|
46
|
+
return {
|
|
47
|
+
input: 0,
|
|
48
|
+
output: 0,
|
|
49
|
+
cacheRead: 0,
|
|
50
|
+
cacheWrite: 0,
|
|
51
|
+
totalTokens: 0,
|
|
52
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// replay state (provider-native metadata for assistant history reconstruction)
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Project a successful pi-ai response into the minimal durable replay state.
|
|
62
|
+
* Lifted verbatim from dsh-llm-pi-ai/replay.
|
|
63
|
+
*/
|
|
64
|
+
export function toPiReplayState(message) {
|
|
65
|
+
return {
|
|
66
|
+
kind: "pi-ai",
|
|
67
|
+
version: 1,
|
|
68
|
+
api: message.api,
|
|
69
|
+
provider: message.provider,
|
|
70
|
+
model: message.model,
|
|
71
|
+
...message.responseModel === void 0 ? {} : { responseModel: message.responseModel },
|
|
72
|
+
...message.responseId === void 0 ? {} : { responseId: message.responseId },
|
|
73
|
+
stopReason: message.stopReason,
|
|
74
|
+
blocks: message.content.map((block) => {
|
|
75
|
+
switch (block.type) {
|
|
76
|
+
case "text": return {
|
|
77
|
+
type: "text",
|
|
78
|
+
...block.textSignature === void 0 ? {} : { textSignature: block.textSignature },
|
|
79
|
+
};
|
|
80
|
+
case "thinking": return {
|
|
81
|
+
type: "reasoning",
|
|
82
|
+
...block.thinkingSignature === void 0 ? {} : { thinkingSignature: block.thinkingSignature },
|
|
83
|
+
...block.redacted === void 0 ? {} : { redacted: block.redacted },
|
|
84
|
+
};
|
|
85
|
+
case "toolCall": return {
|
|
86
|
+
type: "tool-call",
|
|
87
|
+
...block.thoughtSignature === void 0 ? {} : { thoughtSignature: block.thoughtSignature },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function invalidReplay(message) {
|
|
95
|
+
throw new LlmError(`invalid pi-ai replay state: ${message}`, "INVALID_REPLAY_STATE");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Validate the adapter-private state before it reaches pi-ai. */
|
|
99
|
+
function readReplayState(value) {
|
|
100
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return invalidReplay("expected an object");
|
|
101
|
+
const state = value;
|
|
102
|
+
if (state["kind"] !== "pi-ai") return invalidReplay("unknown state kind");
|
|
103
|
+
if (state["version"] !== 1) return invalidReplay(`unsupported version ${String(state["version"])}`);
|
|
104
|
+
for (const key of ["api", "provider", "model"]) {
|
|
105
|
+
if (typeof state[key] !== "string" || state[key].length === 0) return invalidReplay(`${key} must be a non-empty string`);
|
|
106
|
+
}
|
|
107
|
+
if (!["stop", "length", "toolUse", "error", "aborted"].includes(String(state["stopReason"]))) return invalidReplay("unknown stopReason");
|
|
108
|
+
if (state["responseModel"] !== void 0 && typeof state["responseModel"] !== "string") return invalidReplay("responseModel must be a string");
|
|
109
|
+
if (state["responseId"] !== void 0 && typeof state["responseId"] !== "string") return invalidReplay("responseId must be a string");
|
|
110
|
+
if (!Array.isArray(state["blocks"])) return invalidReplay("blocks must be an array");
|
|
111
|
+
for (const [index, v] of state["blocks"].entries()) {
|
|
112
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) return invalidReplay(`block ${index} must be an object`);
|
|
113
|
+
const block = v;
|
|
114
|
+
if (!["text", "reasoning", "tool-call"].includes(String(block["type"]))) return invalidReplay(`block ${index} has an unknown type`);
|
|
115
|
+
for (const signature of ["textSignature", "thinkingSignature", "thoughtSignature"]) {
|
|
116
|
+
if (block[signature] !== void 0 && typeof block[signature] !== "string") return invalidReplay(`block ${index} ${signature} must be a string`);
|
|
117
|
+
}
|
|
118
|
+
if (block["redacted"] !== void 0 && typeof block["redacted"] !== "boolean") return invalidReplay(`block ${index} redacted must be boolean`);
|
|
119
|
+
}
|
|
120
|
+
return state;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Convert provider-neutral blocks without trusting them as same-model replay. */
|
|
124
|
+
function foreignAssistant(message) {
|
|
125
|
+
const source = message.source.kind === "model" ? message.source : void 0;
|
|
126
|
+
const content = [];
|
|
127
|
+
for (const block of message.content) switch (block.type) {
|
|
128
|
+
case "text":
|
|
129
|
+
content.push({ type: "text", text: block.text });
|
|
130
|
+
break;
|
|
131
|
+
case "reasoning":
|
|
132
|
+
content.push({ type: "thinking", thinking: block.text });
|
|
133
|
+
break;
|
|
134
|
+
case "tool-call":
|
|
135
|
+
content.push({ type: "toolCall", id: block.id, name: block.name, arguments: parseArguments(block.arguments) });
|
|
136
|
+
break;
|
|
137
|
+
case "image": throw new LlmError("pi-ai chat history cannot represent structured assistant image output", "UNSUPPORTED_CONTENT");
|
|
138
|
+
default: break;
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
role: "assistant",
|
|
142
|
+
content,
|
|
143
|
+
api: "dsh-foreign",
|
|
144
|
+
provider: source?.provider ?? "dsh-foreign",
|
|
145
|
+
model: source?.model ?? "dsh-foreign",
|
|
146
|
+
usage: emptyPiUsage(),
|
|
147
|
+
stopReason: content.some((piece) => piece.type === "toolCall") ? "toolUse" : "stop",
|
|
148
|
+
timestamp: 0,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Recombine durable Harness content with validated pi-ai replay metadata. */
|
|
153
|
+
function replayedAssistant(message, source, rawState) {
|
|
154
|
+
const state = readReplayState(rawState);
|
|
155
|
+
if (state.provider !== source.provider) return invalidReplay("provider does not match assistant source");
|
|
156
|
+
if (state.model !== source.model) return invalidReplay("model does not match assistant source");
|
|
157
|
+
if (state.blocks.length !== message.content.length) return invalidReplay("block count does not match assistant content");
|
|
158
|
+
return {
|
|
159
|
+
role: "assistant",
|
|
160
|
+
content: message.content.map((block, index) => {
|
|
161
|
+
const replay = state.blocks[index];
|
|
162
|
+
if (replay === void 0 || replay.type !== block.type) return invalidReplay(`block ${index} does not match assistant content`);
|
|
163
|
+
switch (block.type) {
|
|
164
|
+
case "text": return {
|
|
165
|
+
type: "text",
|
|
166
|
+
text: block.text,
|
|
167
|
+
...replay.type === "text" && replay.textSignature !== void 0 ? { textSignature: replay.textSignature } : {},
|
|
168
|
+
};
|
|
169
|
+
case "reasoning": return {
|
|
170
|
+
type: "thinking",
|
|
171
|
+
thinking: block.text,
|
|
172
|
+
...replay.type === "reasoning" && replay.thinkingSignature !== void 0 ? { thinkingSignature: replay.thinkingSignature } : {},
|
|
173
|
+
...replay.type === "reasoning" && replay.redacted !== void 0 ? { redacted: replay.redacted } : {},
|
|
174
|
+
};
|
|
175
|
+
case "tool-call": return {
|
|
176
|
+
type: "toolCall",
|
|
177
|
+
id: block.id,
|
|
178
|
+
name: block.name,
|
|
179
|
+
arguments: parseArguments(block.arguments),
|
|
180
|
+
...replay.type === "tool-call" && replay.thoughtSignature !== void 0 ? { thoughtSignature: replay.thoughtSignature } : {},
|
|
181
|
+
};
|
|
182
|
+
default: return invalidReplay(`block ${index} has an unsupported Harness type`);
|
|
183
|
+
}
|
|
184
|
+
}),
|
|
185
|
+
api: state.api,
|
|
186
|
+
provider: state.provider,
|
|
187
|
+
model: state.model,
|
|
188
|
+
...state.responseModel === void 0 ? {} : { responseModel: state.responseModel },
|
|
189
|
+
...state.responseId === void 0 ? {} : { responseId: state.responseId },
|
|
190
|
+
usage: emptyPiUsage(),
|
|
191
|
+
stopReason: state.stopReason,
|
|
192
|
+
timestamp: 0,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Convert one durable Harness assistant message into pi-ai history.
|
|
198
|
+
* Lifted verbatim from dsh-llm-pi-ai/context.
|
|
199
|
+
*/
|
|
200
|
+
function toPiAssistant(message) {
|
|
201
|
+
const source = message.source;
|
|
202
|
+
return source.kind !== "model" || source.replayState === void 0 ? foreignAssistant(message) : replayedAssistant(message, source, source.replayState);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Context assembly (harness GenerateOptions → pi-ai Context)
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
/** Join the text blocks of a harness message. */
|
|
210
|
+
function flattenText(message) {
|
|
211
|
+
return message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Flatten text recursively inside one tool result. */
|
|
215
|
+
function toolResultText(blocks) {
|
|
216
|
+
return blocks.map((block) => block.type === "text" ? block.text : block.type === "tool-result" ? toolResultText(block.content) : "").join("");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Convert user-content blocks; image blocks read through the attachment service. */
|
|
220
|
+
async function userContent(blocks, attachments) {
|
|
221
|
+
const content = [];
|
|
222
|
+
for (const block of blocks) switch (block.type) {
|
|
223
|
+
case "text":
|
|
224
|
+
if (block.text.length > 0) content.push({ type: "text", text: block.text });
|
|
225
|
+
break;
|
|
226
|
+
case "image": {
|
|
227
|
+
const stored = await attachments.readImage(block.attachment);
|
|
228
|
+
content.push({
|
|
229
|
+
type: "image",
|
|
230
|
+
data: Buffer.from(stored.data).toString("base64"),
|
|
231
|
+
mimeType: stored.ref.mediaType,
|
|
232
|
+
});
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case "tool-result": {
|
|
236
|
+
const nested = await userContent(block.content, attachments);
|
|
237
|
+
if (typeof nested === "string") {
|
|
238
|
+
if (nested.length > 0) content.push({ type: "text", text: nested });
|
|
239
|
+
} else content.push(...nested);
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
default: break;
|
|
243
|
+
}
|
|
244
|
+
if (content.every((block) => block.type === "text")) return content.map((block) => block.text).join("");
|
|
245
|
+
return content;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function toolsOf(options) {
|
|
249
|
+
return options.tools?.map((tool) => ({
|
|
250
|
+
name: tool.name,
|
|
251
|
+
description: tool.description,
|
|
252
|
+
parameters: tool.parameters,
|
|
253
|
+
}));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Assemble the request-level pi-ai context envelope shared by both paths. */
|
|
257
|
+
function piContext(options, messages) {
|
|
258
|
+
const tools = toolsOf(options);
|
|
259
|
+
return {
|
|
260
|
+
...options.system !== void 0 ? { systemPrompt: options.system } : {},
|
|
261
|
+
messages,
|
|
262
|
+
...tools !== void 0 && tools.length > 0 ? { tools } : {},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Text-only fast path (no attachment service needed). */
|
|
267
|
+
function textOnlyContext(options) {
|
|
268
|
+
const toolNames = new Map();
|
|
269
|
+
const messages = [];
|
|
270
|
+
for (const message of options.messages) {
|
|
271
|
+
if (contentHasImage(message.content)) throw new LlmError("pi-ai image conversion requires the durable attachment service", "UNSUPPORTED_CONTENT");
|
|
272
|
+
if (message.role === "system") {
|
|
273
|
+
messages.push({ role: "user", content: flattenText(message), timestamp: 0 });
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (message.role === "assistant") {
|
|
277
|
+
const assistant = toPiAssistant(message);
|
|
278
|
+
for (const block of assistant.content) if (block.type === "toolCall") toolNames.set(CallId(block.id), block.name);
|
|
279
|
+
messages.push(assistant);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const text = flattenText(message);
|
|
283
|
+
const results = message.content.filter((block) => block.type === "tool-result");
|
|
284
|
+
if (text.length > 0 || results.length === 0) messages.push({ role: "user", content: text, timestamp: 0 });
|
|
285
|
+
for (const result of results) messages.push({
|
|
286
|
+
role: "toolResult",
|
|
287
|
+
toolCallId: result.toolCallId,
|
|
288
|
+
toolName: toolNames.get(result.toolCallId) ?? "unknown",
|
|
289
|
+
content: [{ type: "text", text: toolResultText(result.content) || "(no output)" }],
|
|
290
|
+
isError: result.isError ?? false,
|
|
291
|
+
timestamp: 0,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return piContext(options, messages);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Image-capable path (reads images through the attachment service). */
|
|
298
|
+
async function toPiContextWithImages(options, attachments) {
|
|
299
|
+
const toolNames = new Map();
|
|
300
|
+
const messages = [];
|
|
301
|
+
for (const message of options.messages) {
|
|
302
|
+
if (message.role === "system") {
|
|
303
|
+
if (contentHasImage(message.content)) throw new LlmError("pi-ai cannot represent an image in an in-history system message", "UNSUPPORTED_CONTENT");
|
|
304
|
+
messages.push({ role: "user", content: flattenText(message), timestamp: 0 });
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (message.role === "assistant") {
|
|
308
|
+
const assistant = toPiAssistant(message);
|
|
309
|
+
for (const block of assistant.content) if (block.type === "toolCall") toolNames.set(CallId(block.id), block.name);
|
|
310
|
+
messages.push(assistant);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const content = await userContent(message.content.filter((block) => block.type !== "tool-result"), attachments);
|
|
314
|
+
const results = message.content.filter((block) => block.type === "tool-result");
|
|
315
|
+
if (content.length > 0 || results.length === 0) messages.push({ role: "user", content, timestamp: 0 });
|
|
316
|
+
for (const result of results) {
|
|
317
|
+
const resultContent = await userContent(result.content, attachments);
|
|
318
|
+
messages.push({
|
|
319
|
+
role: "toolResult",
|
|
320
|
+
toolCallId: result.toolCallId,
|
|
321
|
+
toolName: toolNames.get(result.toolCallId) ?? "unknown",
|
|
322
|
+
content: typeof resultContent === "string"
|
|
323
|
+
? [{ type: "text", text: resultContent || "(no output)" }]
|
|
324
|
+
: resultContent,
|
|
325
|
+
isError: result.isError ?? false,
|
|
326
|
+
timestamp: 0,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return piContext(options, messages);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Convert a harness request into a pi-ai Context.
|
|
335
|
+
* @param options - the harness GenerateOptions.
|
|
336
|
+
* @param attachments - the attachment service, required only when the request carries images.
|
|
337
|
+
* @returns the pi-ai Context for `provider.streamSimple()`.
|
|
338
|
+
*/
|
|
339
|
+
export function toPiContext(options, attachments) {
|
|
340
|
+
return attachments === void 0 ? textOnlyContext(options) : toPiContextWithImages(options, attachments);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// Stream translation (pi-ai EventStream → harness StreamChunks)
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
/** Classify a pi-ai error message into a harness error code. */
|
|
348
|
+
function classifyPiAiError(text) {
|
|
349
|
+
if (isQuotaExceededError(text)) return QUOTA_EXCEEDED_CODE;
|
|
350
|
+
return "PROVIDER_ERROR";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Map pi-ai usage (reasoning folded into output by pi-ai). */
|
|
354
|
+
function mapUsage(usage) {
|
|
355
|
+
return {
|
|
356
|
+
inputTokens: Math.max(0, usage.input),
|
|
357
|
+
outputTokens: usage.output,
|
|
358
|
+
...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
|
|
359
|
+
...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {},
|
|
360
|
+
...usage.reasoning > 0 ? { reasoningTokens: usage.reasoning } : {},
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Map a pi-ai terminal message's stopReason into the harness FinishReason.
|
|
366
|
+
* Lifted verbatim from dsh-llm-pi-ai/stream.
|
|
367
|
+
*/
|
|
368
|
+
export function mapStopReason(message, contextWindow) {
|
|
369
|
+
const piAiOverflow = isContextOverflow(message, contextWindow);
|
|
370
|
+
const harnessOverflow = message.stopReason === "error" && message.errorMessage !== void 0 && isContextWindowExceededError(message.errorMessage);
|
|
371
|
+
if (piAiOverflow || harnessOverflow) return {
|
|
372
|
+
kind: "error",
|
|
373
|
+
failure: {
|
|
374
|
+
message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`,
|
|
375
|
+
code: CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
switch (message.stopReason) {
|
|
379
|
+
case "stop":
|
|
380
|
+
if (message.content.length === 0) return {
|
|
381
|
+
kind: "error",
|
|
382
|
+
failure: {
|
|
383
|
+
message: `model "${message.model}" returned a completed response with no content`,
|
|
384
|
+
code: EMPTY_RESPONSE_CODE,
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
return { kind: "stop" };
|
|
388
|
+
case "length": return { kind: "max-tokens" };
|
|
389
|
+
case "toolUse": return { kind: "tool-calls" };
|
|
390
|
+
case "aborted": return {
|
|
391
|
+
kind: "aborted",
|
|
392
|
+
failure: { message: message.errorMessage ?? "pi-ai stream aborted", code: "ABORTED" },
|
|
393
|
+
};
|
|
394
|
+
case "error": {
|
|
395
|
+
const text = message.errorMessage ?? "pi-ai stream error";
|
|
396
|
+
return { kind: "error", failure: { message: text, code: classifyPiAiError(text) } };
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Translate the pi-ai event stream into harness StreamChunks.
|
|
403
|
+
* Lifted verbatim from dsh-llm-pi-ai/stream.
|
|
404
|
+
* @param events - one assistant turn's pi-ai event stream.
|
|
405
|
+
* @param contextWindow - resolved catalog capacity for usage-based overflow detection.
|
|
406
|
+
* @returns the harness chunks, ending with `usage` then `finish`.
|
|
407
|
+
*/
|
|
408
|
+
export async function* toStreamChunks(events, contextWindow) {
|
|
409
|
+
const toolIds = new Map();
|
|
410
|
+
for await (const event of events) switch (event.type) {
|
|
411
|
+
case "start": break;
|
|
412
|
+
case "text_start":
|
|
413
|
+
yield { type: "block-start", index: event.contentIndex, blockType: "text" };
|
|
414
|
+
break;
|
|
415
|
+
case "text_delta":
|
|
416
|
+
yield { type: "text-delta", index: event.contentIndex, text: event.delta };
|
|
417
|
+
break;
|
|
418
|
+
case "text_end":
|
|
419
|
+
yield { type: "block-end", index: event.contentIndex, block: { type: "text", text: event.content } };
|
|
420
|
+
break;
|
|
421
|
+
case "thinking_start":
|
|
422
|
+
yield { type: "block-start", index: event.contentIndex, blockType: "reasoning" };
|
|
423
|
+
break;
|
|
424
|
+
case "thinking_delta":
|
|
425
|
+
yield { type: "reasoning-delta", index: event.contentIndex, text: event.delta };
|
|
426
|
+
break;
|
|
427
|
+
case "thinking_end":
|
|
428
|
+
yield { type: "block-end", index: event.contentIndex, block: { type: "reasoning", text: event.content } };
|
|
429
|
+
break;
|
|
430
|
+
case "toolcall_start": {
|
|
431
|
+
const partial = event.partial.content[event.contentIndex];
|
|
432
|
+
const id = partial?.type === "toolCall" ? partial.id : "";
|
|
433
|
+
const name = partial?.type === "toolCall" ? partial.name : "";
|
|
434
|
+
toolIds.set(event.contentIndex, { id, name });
|
|
435
|
+
yield { type: "block-start", index: event.contentIndex, blockType: "tool-call" };
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
case "toolcall_delta": {
|
|
439
|
+
const known = toolIds.get(event.contentIndex);
|
|
440
|
+
yield {
|
|
441
|
+
type: "tool-call-delta",
|
|
442
|
+
index: event.contentIndex,
|
|
443
|
+
id: CallId(known?.id ?? ""),
|
|
444
|
+
...known?.name !== void 0 && known.name.length > 0 ? { name: known.name } : {},
|
|
445
|
+
argumentsDelta: event.delta,
|
|
446
|
+
};
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
case "toolcall_end":
|
|
450
|
+
yield {
|
|
451
|
+
type: "block-end",
|
|
452
|
+
index: event.contentIndex,
|
|
453
|
+
block: {
|
|
454
|
+
type: "tool-call",
|
|
455
|
+
id: CallId(event.toolCall.id),
|
|
456
|
+
name: event.toolCall.name,
|
|
457
|
+
arguments: JSON.stringify(event.toolCall.arguments),
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
break;
|
|
461
|
+
case "done":
|
|
462
|
+
yield { type: "usage", usage: mapUsage(event.message.usage) };
|
|
463
|
+
yield { type: "finish", reason: mapStopReason(event.message, contextWindow), replayState: toPiReplayState(event.message) };
|
|
464
|
+
return;
|
|
465
|
+
case "error":
|
|
466
|
+
yield { type: "usage", usage: mapUsage(event.error.usage) };
|
|
467
|
+
yield { type: "finish", reason: mapStopReason(event.error, contextWindow) };
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
throw new LlmError("pi-ai event stream ended without done/error", "STREAM_CLOSED");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Merge deployment headers while removing case-insensitive attribution collisions. */
|
|
474
|
+
export function requestHeaders(headers) {
|
|
475
|
+
const attribution = attributionHeaders();
|
|
476
|
+
const reserved = new Set(Object.keys(attribution).map((name) => name.toLowerCase()));
|
|
477
|
+
return {
|
|
478
|
+
...Object.fromEntries(Object.entries(headers ?? {}).filter(([name]) => !reserved.has(name.toLowerCase()))),
|
|
479
|
+
...attribution,
|
|
480
|
+
};
|
|
481
|
+
}
|