@dan-ai-studio/dshopencodego 0.1.43 → 0.1.45
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/README.en.md +48 -28
- package/README.md +47 -27
- package/lib/client.js +47 -1
- package/lib/index.js +288 -123
- package/lib/types/adapter.d.ts +26 -3
- package/lib/types/catalog/go-doc.d.ts +9 -5
- package/lib/types/catalog/index.d.ts +22 -7
- package/lib/types/catalog/service.d.ts +44 -1
- package/lib/types/config.d.ts +4 -3
- package/lib/types/conversion/replay.d.ts +30 -1
- package/lib/types/models.d.ts +14 -0
- package/lib/types/usage/meter.d.ts +89 -2
- package/package.json +20 -21
- package/lib/types/adapter.js +0 -384
- package/lib/types/catalog/constants.js +0 -18
- package/lib/types/catalog/contract.js +0 -147
- package/lib/types/catalog/gateway.js +0 -59
- package/lib/types/catalog/go-doc.js +0 -192
- package/lib/types/catalog/index.js +0 -377
- package/lib/types/catalog/json-response.js +0 -73
- package/lib/types/catalog/metadata.js +0 -278
- package/lib/types/catalog/protocol.js +0 -87
- package/lib/types/catalog/reading.js +0 -73
- package/lib/types/catalog/service.js +0 -45
- package/lib/types/config.js +0 -91
- package/lib/types/conversion/context.js +0 -212
- package/lib/types/conversion/index.js +0 -7
- package/lib/types/conversion/replay.js +0 -242
- package/lib/types/conversion/stream.js +0 -221
- package/lib/types/go-limits.js +0 -91
- package/lib/types/index.js +0 -198
- package/lib/types/models.js +0 -86
- package/lib/types/remotes.js +0 -29
- package/lib/types/session-header.js +0 -49
- package/lib/types/update/check.js +0 -138
- package/lib/types/update/contract.js +0 -64
- package/lib/types/update/index.js +0 -10
- package/lib/types/update/service.js +0 -39
- package/lib/types/update/version.js +0 -16
- package/lib/types/usage/contract.js +0 -106
- package/lib/types/usage/index.js +0 -8
- package/lib/types/usage/meter.js +0 -65
- package/lib/types/usage/service.js +0 -74
- package/lib/types/usage/windows.js +0 -84
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Harness request history converted into pi-ai's request vocabulary.
|
|
3
|
-
*
|
|
4
|
-
* Three shapes need care:
|
|
5
|
-
*
|
|
6
|
-
* - The system prompt has one home in pi-ai. `normalizeContext()` folds
|
|
7
|
-
* `Context.systemPrompt` and `Context.tools` into a leading system message,
|
|
8
|
-
* so this module only has to decide *which* text that is.
|
|
9
|
-
* - Tool results are their own message role and must carry the tool's *name*,
|
|
10
|
-
* which the Harness records only on the preceding assistant tool call — so
|
|
11
|
-
* names are recovered by walking the history.
|
|
12
|
-
* - Images go through the durable attachment service under the route's pixel
|
|
13
|
-
* and encoded-byte budgets, and follow the host's offload protocol instead of
|
|
14
|
-
* dropping content locally.
|
|
15
|
-
*
|
|
16
|
-
* The image helpers are reached through a namespace import on purpose: they are
|
|
17
|
-
* newer additions to the LLM seam, and a named import of one that a host build
|
|
18
|
-
* does not export fails at module instantiation — taking the whole plugin down
|
|
19
|
-
* before it can report anything. A namespace read fails only when the feature is
|
|
20
|
-
* actually used.
|
|
21
|
-
*
|
|
22
|
-
* @module @dan-ai-studio/dshopencodego/conversion/context
|
|
23
|
-
*/
|
|
24
|
-
import { brandString } from '@deepseek-ai/dsh-brand';
|
|
25
|
-
import * as llm from '@deepseek-ai/dsh-llm';
|
|
26
|
-
import { contentHasImage, LlmError, requestImageHandleText } from '@deepseek-ai/dsh-llm';
|
|
27
|
-
import { requestImageDimensions } from '@deepseek-ai/dsh-attachment';
|
|
28
|
-
import { toPiAssistant } from './replay.js';
|
|
29
|
-
/** Join the text blocks of one Harness message. */
|
|
30
|
-
function flattenText(message) {
|
|
31
|
-
return message.content.filter(block => block.type === 'text').map(block => block.text).join('');
|
|
32
|
-
}
|
|
33
|
-
/** DSH carries tool results under their own role. */
|
|
34
|
-
function toolMessage(message) {
|
|
35
|
-
return message.role === 'tool' ? message : undefined;
|
|
36
|
-
}
|
|
37
|
-
/** pi-ai cannot replay an image attached to an assistant or system message. */
|
|
38
|
-
function assertSupportedImageRoles(messages) {
|
|
39
|
-
for (const message of messages) {
|
|
40
|
-
if (message.role !== 'user' && toolMessage(message) === undefined && contentHasImage(message.content)) {
|
|
41
|
-
throw new LlmError(`pi-ai cannot represent an image in an in-history ${message.role} message`, 'UNSUPPORTED_CONTENT');
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* Every tool declaration this request carries.
|
|
47
|
-
*
|
|
48
|
-
* `GenerateOptions.toolHistory` is deliberately not projected: projecting it
|
|
49
|
-
* requires the route to declare a `toolUpdate` mode, and no source states one
|
|
50
|
-
* for this gateway's models (models.dev describes tools only as callable). A
|
|
51
|
-
* route that claims a mode the provider does not implement would silently
|
|
52
|
-
* change what the model sees, so every request sends the complete current tool
|
|
53
|
-
* list — the documented behavior when a route declares no mode.
|
|
54
|
-
*/
|
|
55
|
-
function toolsOf(options) {
|
|
56
|
-
return options.tools?.map(tool => ({
|
|
57
|
-
name: tool.name,
|
|
58
|
-
description: tool.description,
|
|
59
|
-
// ToolSchema.parameters is a JSON Schema object; pi-ai's TSchema is
|
|
60
|
-
// structurally JSON Schema, so it assigns directly.
|
|
61
|
-
parameters: tool.parameters,
|
|
62
|
-
}));
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Select the system prompt for pi-ai.
|
|
66
|
-
*
|
|
67
|
-
* `options.system` wins when defined; otherwise a leading `system` message
|
|
68
|
-
* supplies it and leaves the converted history. An empty leading message sends
|
|
69
|
-
* no prompt at all.
|
|
70
|
-
*/
|
|
71
|
-
function splitSystemPrompt(options) {
|
|
72
|
-
if (options.system !== undefined)
|
|
73
|
-
return { systemPrompt: options.system, messages: options.messages };
|
|
74
|
-
const [first, ...rest] = options.messages;
|
|
75
|
-
if (first?.role !== 'system')
|
|
76
|
-
return { systemPrompt: undefined, messages: options.messages };
|
|
77
|
-
const text = flattenText(first);
|
|
78
|
-
return { systemPrompt: text.length > 0 ? text : undefined, messages: rest };
|
|
79
|
-
}
|
|
80
|
-
/** Every image occurrence this request will actually send. */
|
|
81
|
-
function collectImageRefs(blocks, refs) {
|
|
82
|
-
for (const block of blocks) {
|
|
83
|
-
// An occurrence the surface already offloaded is a placeholder, not an image.
|
|
84
|
-
if (block.type === 'image' && block.offloaded !== true)
|
|
85
|
-
refs.set(block.attachment.attachmentId, block.attachment);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
async function prepareRequestImages(messages, attachments, policy, signal) {
|
|
89
|
-
const refs = new Map();
|
|
90
|
-
for (const message of messages)
|
|
91
|
-
collectImageRefs(message.content, refs);
|
|
92
|
-
const ordered = [...refs.values()];
|
|
93
|
-
const prepared = await Promise.all(ordered.map(ref => attachments.readImageRequest(ref, { ...requestImageDimensions(ref.width, ref.height, policy.maxPixels), maxBytes: policy.maxBytes }, signal)));
|
|
94
|
-
const versions = new Map();
|
|
95
|
-
for (const [index, ref] of ordered.entries())
|
|
96
|
-
versions.set(ref.attachmentId, prepared[index]);
|
|
97
|
-
return versions;
|
|
98
|
-
}
|
|
99
|
-
/** Convert one message's content into pi-ai user content. */
|
|
100
|
-
async function userContent(blocks, requestImages, resolveImageAccess) {
|
|
101
|
-
const content = [];
|
|
102
|
-
for (const block of blocks) {
|
|
103
|
-
if (block.type === 'text') {
|
|
104
|
-
if (block.text.length > 0)
|
|
105
|
-
content.push({ type: 'text', text: block.text });
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
if (block.type === 'image') {
|
|
109
|
-
const version = requestImages.get(block.attachment.attachmentId);
|
|
110
|
-
if (version === undefined) {
|
|
111
|
-
throw new LlmError('dshopencodego: an image in this request has no prepared bytes; image input requires the attachment service', 'UNSUPPORTED_CONTENT');
|
|
112
|
-
}
|
|
113
|
-
content.push({
|
|
114
|
-
type: 'text',
|
|
115
|
-
text: requestImageHandleText(block.attachment, version, resolveImageAccess(block.attachment)),
|
|
116
|
-
});
|
|
117
|
-
content.push({ type: 'image', data: Buffer.from(version.data).toString('base64'), mimeType: version.mediaType });
|
|
118
|
-
}
|
|
119
|
-
// Other block types are not user-input vocabulary for pi-ai.
|
|
120
|
-
}
|
|
121
|
-
return content.every(block => block.type === 'text')
|
|
122
|
-
? content.map(block => block.text).join('')
|
|
123
|
-
: content;
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Apply the host's image offload protocol to a request history.
|
|
127
|
-
*
|
|
128
|
-
* The host marks what the surface already offloaded. When the retained
|
|
129
|
-
* occurrences still exceed the base64 bound, the request fails with
|
|
130
|
-
* `IMAGE_OFFLOAD_REQUIRED` naming how many more must go — the host's retry
|
|
131
|
-
* protocol, not a local decision to drop content.
|
|
132
|
-
*/
|
|
133
|
-
function projectImagesForRequest(messages, images, requestImages) {
|
|
134
|
-
const offloadedImageText = llm.offloadedImageText;
|
|
135
|
-
const placeholder = (ref) => offloadedImageText(ref, images.resolveImageAccess(ref));
|
|
136
|
-
if (images.maxRequestImageBytes !== undefined) {
|
|
137
|
-
const over = llm.requiredImageOffload(messages, { representation: 'base64', maxBytes: images.maxRequestImageBytes, byteQuantum: 1 }, block => requestImages.get(block.attachment.attachmentId).bytes);
|
|
138
|
-
if (over > 0) {
|
|
139
|
-
throw new LlmError(`request images exceed the ${images.maxRequestImageBytes}-byte base64 bound;`
|
|
140
|
-
+ ` ${over} more oldest occurrence(s) must be offloaded`, llm.IMAGE_OFFLOAD_REQUIRED_CODE, { offloadImages: over });
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
return llm.projectOffloadedImages(messages, placeholder);
|
|
144
|
-
}
|
|
145
|
-
/**
|
|
146
|
-
* Convert Harness history into a pi-ai request context.
|
|
147
|
-
*
|
|
148
|
-
* The result is a plain `Context`; the adapter normalizes it into the branded
|
|
149
|
-
* transcript pi-ai's providers accept.
|
|
150
|
-
* @param options - the assembled request.
|
|
151
|
-
* @param images - attachment access; omitted selects the text-only conversion,
|
|
152
|
-
* which refuses any image rather than silently dropping it.
|
|
153
|
-
* @param onReplayDegrade - called when stored replay metadata is unusable.
|
|
154
|
-
* @returns the pi-ai context.
|
|
155
|
-
* @throws {LlmError} `UNSUPPORTED_CONTENT` for an image this request cannot
|
|
156
|
-
* carry, and `IMAGE_OFFLOAD_REQUIRED` when retained images still exceed the
|
|
157
|
-
* base64 bound.
|
|
158
|
-
*/
|
|
159
|
-
export async function toPiContext(options, images, onReplayDegrade) {
|
|
160
|
-
assertSupportedImageRoles(options.messages);
|
|
161
|
-
const split = splitSystemPrompt(options);
|
|
162
|
-
const resolveImageAccess = images?.resolveImageAccess ?? (() => undefined);
|
|
163
|
-
const policy = images?.requestImagePolicy ?? { maxPixels: 1_440_000, maxBytes: 400_000 };
|
|
164
|
-
const requestImages = images === undefined
|
|
165
|
-
? new Map()
|
|
166
|
-
: await prepareRequestImages(split.messages, images.attachments, policy, options.signal);
|
|
167
|
-
const history = images === undefined
|
|
168
|
-
? split.messages
|
|
169
|
-
: projectImagesForRequest(split.messages, images, requestImages);
|
|
170
|
-
const toolNames = new Map();
|
|
171
|
-
const messages = [];
|
|
172
|
-
for (const message of history) {
|
|
173
|
-
const tool = toolMessage(message);
|
|
174
|
-
if (tool !== undefined) {
|
|
175
|
-
const content = await userContent(tool.content, requestImages, resolveImageAccess);
|
|
176
|
-
messages.push({
|
|
177
|
-
role: 'toolResult',
|
|
178
|
-
toolCallId: tool.toolCallId,
|
|
179
|
-
toolName: toolNames.get(tool.toolCallId) ?? 'unknown',
|
|
180
|
-
content: typeof content === 'string'
|
|
181
|
-
? [{ type: 'text', text: content.length > 0 ? content : '(no output)' }]
|
|
182
|
-
: content,
|
|
183
|
-
isError: tool.isError ?? false,
|
|
184
|
-
timestamp: 0,
|
|
185
|
-
});
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
if (message.role === 'system') {
|
|
189
|
-
// pi-ai keeps one leading system message; a later one folds into a user
|
|
190
|
-
// message so its position in the history survives.
|
|
191
|
-
messages.push({ role: 'user', content: flattenText(message), timestamp: 0 });
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
if (message.role === 'assistant') {
|
|
195
|
-
const assistant = toPiAssistant(message, onReplayDegrade);
|
|
196
|
-
for (const block of assistant.content) {
|
|
197
|
-
if (block.type === 'toolCall')
|
|
198
|
-
toolNames.set(brandString(block.id), block.name);
|
|
199
|
-
}
|
|
200
|
-
messages.push(assistant);
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
const content = await userContent(message.content, requestImages, resolveImageAccess);
|
|
204
|
-
messages.push({ role: 'user', content, timestamp: 0 });
|
|
205
|
-
}
|
|
206
|
-
const tools = toolsOf(options);
|
|
207
|
-
return {
|
|
208
|
-
...split.systemPrompt === undefined ? {} : { systemPrompt: split.systemPrompt },
|
|
209
|
-
messages,
|
|
210
|
-
...tools === undefined || tools.length === 0 ? {} : { tools },
|
|
211
|
-
};
|
|
212
|
-
}
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Harness ⇄ pi-ai conversion.
|
|
3
|
-
* @module @dan-ai-studio/dshopencodego/conversion
|
|
4
|
-
*/
|
|
5
|
-
export { toPiContext } from './context.js';
|
|
6
|
-
export { classifyPiAiError, mapStopReason, mapUsage, toStreamChunks } from './stream.js';
|
|
7
|
-
export { toPiAssistant, toPiReplayState } from './replay.js';
|
|
@@ -1,242 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Durable pi-ai replay metadata and assistant-history reconstruction.
|
|
3
|
-
*
|
|
4
|
-
* Harness content is the durable source of truth for text and tool calls; this
|
|
5
|
-
* module stores only the provider-native metadata needed to rebuild a pi-ai
|
|
6
|
-
* assistant message on a later request (signatures, response ids, native
|
|
7
|
-
* thinking level). When that metadata is unusable — another adapter wrote it, a
|
|
8
|
-
* future version wrote it, or it no longer matches the content — the message
|
|
9
|
-
* degrades to provider-neutral history instead of failing the request.
|
|
10
|
-
*
|
|
11
|
-
* @module @dan-ai-studio/dshopencodego/conversion/replay
|
|
12
|
-
*/
|
|
13
|
-
import { LlmError } from '@deepseek-ai/dsh-llm';
|
|
14
|
-
/** Tool arguments arrive as raw JSON strings; a malformed one becomes `{}`. */
|
|
15
|
-
function parseArguments(raw) {
|
|
16
|
-
try {
|
|
17
|
-
const parsed = JSON.parse(raw);
|
|
18
|
-
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
19
|
-
return parsed;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
catch {
|
|
23
|
-
// Malformed arguments are the model's fault, not a reason to fail replay.
|
|
24
|
-
}
|
|
25
|
-
return {};
|
|
26
|
-
}
|
|
27
|
-
/** Historical pi-ai messages require a usage value; none of it is replayed. */
|
|
28
|
-
function emptyPiUsage() {
|
|
29
|
-
return {
|
|
30
|
-
input: 0,
|
|
31
|
-
output: 0,
|
|
32
|
-
cacheRead: 0,
|
|
33
|
-
cacheWrite: 0,
|
|
34
|
-
totalTokens: 0,
|
|
35
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
/**
|
|
39
|
-
* Project a successful pi-ai response into the minimal durable replay state.
|
|
40
|
-
* @param message - the completed native response.
|
|
41
|
-
* @param requestedModel - request identity stored on the assistant source.
|
|
42
|
-
* @returns the versioned lossless-JSON envelope; `blocks` is index-aligned with
|
|
43
|
-
* the streamed blocks, so assembly prunes an entry with its block.
|
|
44
|
-
*/
|
|
45
|
-
export function toPiReplayState(message, requestedModel = message.model) {
|
|
46
|
-
const responseModel = message.api === 'anthropic-messages' && message.model !== requestedModel
|
|
47
|
-
? message.model
|
|
48
|
-
: message.responseModel;
|
|
49
|
-
const response = {
|
|
50
|
-
kind: 'pi-ai',
|
|
51
|
-
version: 2,
|
|
52
|
-
api: message.api,
|
|
53
|
-
provider: message.provider,
|
|
54
|
-
model: requestedModel,
|
|
55
|
-
...responseModel === undefined ? {} : { responseModel },
|
|
56
|
-
...message.responseId === undefined ? {} : { responseId: message.responseId },
|
|
57
|
-
...message.providerThinkingLevel === undefined ? {} : { providerThinkingLevel: message.providerThinkingLevel },
|
|
58
|
-
stopReason: message.stopReason,
|
|
59
|
-
};
|
|
60
|
-
return {
|
|
61
|
-
response,
|
|
62
|
-
blocks: message.content.map((block) => {
|
|
63
|
-
switch (block.type) {
|
|
64
|
-
case 'text': return {
|
|
65
|
-
type: 'text',
|
|
66
|
-
...block.textSignature === undefined ? {} : { textSignature: block.textSignature },
|
|
67
|
-
};
|
|
68
|
-
case 'thinking': return {
|
|
69
|
-
type: 'reasoning',
|
|
70
|
-
...block.thinkingSignature === undefined ? {} : { thinkingSignature: block.thinkingSignature },
|
|
71
|
-
...block.redacted === undefined ? {} : { redacted: block.redacted },
|
|
72
|
-
};
|
|
73
|
-
case 'toolCall': return {
|
|
74
|
-
type: 'tool-call',
|
|
75
|
-
...block.thoughtSignature === undefined ? {} : { thoughtSignature: block.thoughtSignature },
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
}),
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
function invalidReplay(message) {
|
|
82
|
-
throw new LlmError(`invalid pi-ai replay state: ${message}`, 'INVALID_REPLAY_STATE');
|
|
83
|
-
}
|
|
84
|
-
/** Validate a durable envelope before any of it reaches pi-ai. */
|
|
85
|
-
function readReplayState(value) {
|
|
86
|
-
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
87
|
-
return invalidReplay('expected a replay envelope');
|
|
88
|
-
const envelope = value;
|
|
89
|
-
const rawResponse = envelope['response'];
|
|
90
|
-
if (typeof rawResponse !== 'object' || rawResponse === null || Array.isArray(rawResponse)) {
|
|
91
|
-
return invalidReplay('expected a response object');
|
|
92
|
-
}
|
|
93
|
-
const response = rawResponse;
|
|
94
|
-
if (response['kind'] !== 'pi-ai')
|
|
95
|
-
return invalidReplay('unknown state kind');
|
|
96
|
-
if (response['version'] !== 2)
|
|
97
|
-
return invalidReplay(`unsupported version ${String(response['version'])}`);
|
|
98
|
-
for (const key of ['api', 'provider', 'model']) {
|
|
99
|
-
if (typeof response[key] !== 'string' || response[key].length === 0)
|
|
100
|
-
return invalidReplay(`${key} must be a non-empty string`);
|
|
101
|
-
}
|
|
102
|
-
if (!['stop', 'length', 'toolUse', 'error', 'aborted'].includes(String(response['stopReason']))) {
|
|
103
|
-
return invalidReplay('unknown stopReason');
|
|
104
|
-
}
|
|
105
|
-
for (const key of ['responseModel', 'responseId', 'providerThinkingLevel']) {
|
|
106
|
-
if (response[key] !== undefined && typeof response[key] !== 'string')
|
|
107
|
-
return invalidReplay(`${key} must be a string`);
|
|
108
|
-
}
|
|
109
|
-
const blocks = envelope['blocks'];
|
|
110
|
-
if (!Array.isArray(blocks))
|
|
111
|
-
return invalidReplay('blocks must be an array');
|
|
112
|
-
for (const [index, value] of blocks.entries()) {
|
|
113
|
-
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
114
|
-
return invalidReplay(`block ${index} must be an object`);
|
|
115
|
-
const block = value;
|
|
116
|
-
if (!['text', 'reasoning', 'tool-call'].includes(String(block['type']))) {
|
|
117
|
-
return invalidReplay(`block ${index} has an unknown type`);
|
|
118
|
-
}
|
|
119
|
-
for (const signature of ['textSignature', 'thinkingSignature', 'thoughtSignature']) {
|
|
120
|
-
if (block[signature] !== undefined && typeof block[signature] !== 'string') {
|
|
121
|
-
return invalidReplay(`block ${index} ${signature} must be a string`);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
if (block['redacted'] !== undefined && typeof block['redacted'] !== 'boolean') {
|
|
125
|
-
return invalidReplay(`block ${index} redacted must be boolean`);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
return { response: response, blocks: blocks };
|
|
129
|
-
}
|
|
130
|
-
/** Convert provider-neutral blocks without claiming same-model fidelity. */
|
|
131
|
-
function foreignAssistant(message) {
|
|
132
|
-
const source = message.source.kind === 'model' ? message.source : undefined;
|
|
133
|
-
const content = [];
|
|
134
|
-
for (const block of message.content) {
|
|
135
|
-
switch (block.type) {
|
|
136
|
-
case 'text':
|
|
137
|
-
content.push({ type: 'text', text: block.text });
|
|
138
|
-
break;
|
|
139
|
-
case 'reasoning':
|
|
140
|
-
content.push({ type: 'thinking', thinking: block.text });
|
|
141
|
-
break;
|
|
142
|
-
case 'tool-call':
|
|
143
|
-
content.push({
|
|
144
|
-
type: 'toolCall', id: block.id, name: block.name, arguments: parseArguments(block.arguments),
|
|
145
|
-
});
|
|
146
|
-
break;
|
|
147
|
-
case 'image':
|
|
148
|
-
throw new LlmError('pi-ai chat history cannot represent structured assistant image output', 'UNSUPPORTED_CONTENT');
|
|
149
|
-
default:
|
|
150
|
-
// Plugin-added block types have no pi-ai representation.
|
|
151
|
-
break;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
return {
|
|
155
|
-
role: 'assistant',
|
|
156
|
-
content,
|
|
157
|
-
// Deliberately never equals a catalog API: absent replay state is foreign
|
|
158
|
-
// even when the source names this same provider and model.
|
|
159
|
-
api: 'dsh-foreign',
|
|
160
|
-
provider: source?.provider ?? 'dsh-foreign',
|
|
161
|
-
model: source?.model ?? 'dsh-foreign',
|
|
162
|
-
usage: emptyPiUsage(),
|
|
163
|
-
stopReason: content.some(piece => piece.type === 'toolCall') ? 'toolUse' : 'stop',
|
|
164
|
-
timestamp: 0,
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
/** Recombine durable content with validated replay metadata. */
|
|
168
|
-
function replayedAssistant(message, source, rawState) {
|
|
169
|
-
const state = readReplayState(rawState);
|
|
170
|
-
if (state.response.provider !== source.provider)
|
|
171
|
-
return invalidReplay('provider does not match assistant source');
|
|
172
|
-
if (state.response.model !== source.model)
|
|
173
|
-
return invalidReplay('model does not match assistant source');
|
|
174
|
-
if (state.blocks.length !== message.content.length)
|
|
175
|
-
return invalidReplay('block count does not match assistant content');
|
|
176
|
-
const content = message.content.map((block, index) => {
|
|
177
|
-
const replay = state.blocks[index];
|
|
178
|
-
if (replay === undefined || replay.type !== block.type)
|
|
179
|
-
return invalidReplay(`block ${index} does not match assistant content`);
|
|
180
|
-
switch (block.type) {
|
|
181
|
-
case 'text': return {
|
|
182
|
-
type: 'text',
|
|
183
|
-
text: block.text,
|
|
184
|
-
...replay.type === 'text' && replay.textSignature !== undefined ? { textSignature: replay.textSignature } : {},
|
|
185
|
-
};
|
|
186
|
-
case 'reasoning': return {
|
|
187
|
-
type: 'thinking',
|
|
188
|
-
thinking: block.text,
|
|
189
|
-
...replay.type === 'reasoning' && replay.thinkingSignature !== undefined
|
|
190
|
-
? { thinkingSignature: replay.thinkingSignature } : {},
|
|
191
|
-
...replay.type === 'reasoning' && replay.redacted !== undefined ? { redacted: replay.redacted } : {},
|
|
192
|
-
};
|
|
193
|
-
case 'tool-call': return {
|
|
194
|
-
type: 'toolCall',
|
|
195
|
-
id: block.id,
|
|
196
|
-
name: block.name,
|
|
197
|
-
arguments: parseArguments(block.arguments),
|
|
198
|
-
...replay.type === 'tool-call' && replay.thoughtSignature !== undefined
|
|
199
|
-
? { thoughtSignature: replay.thoughtSignature } : {},
|
|
200
|
-
};
|
|
201
|
-
default: return invalidReplay(`block ${index} has an unsupported Harness type`);
|
|
202
|
-
}
|
|
203
|
-
});
|
|
204
|
-
return {
|
|
205
|
-
role: 'assistant',
|
|
206
|
-
content,
|
|
207
|
-
api: state.response.api,
|
|
208
|
-
provider: state.response.provider,
|
|
209
|
-
// Anthropic reports aliases and fallbacks as `model`, unlike Completions'
|
|
210
|
-
// informational `responseModel`.
|
|
211
|
-
model: state.response.api === 'anthropic-messages'
|
|
212
|
-
? state.response.responseModel ?? state.response.model
|
|
213
|
-
: state.response.model,
|
|
214
|
-
...state.response.responseModel === undefined ? {} : { responseModel: state.response.responseModel },
|
|
215
|
-
...state.response.responseId === undefined ? {} : { responseId: state.response.responseId },
|
|
216
|
-
...state.response.providerThinkingLevel === undefined
|
|
217
|
-
? {} : { providerThinkingLevel: state.response.providerThinkingLevel },
|
|
218
|
-
usage: emptyPiUsage(),
|
|
219
|
-
stopReason: state.response.stopReason,
|
|
220
|
-
timestamp: 0,
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
/**
|
|
224
|
-
* Convert one durable assistant message into pi-ai history.
|
|
225
|
-
* @param message - assistant content with its model source and optional replay metadata.
|
|
226
|
-
* @param onDegrade - called with the reason when unusable metadata falls back.
|
|
227
|
-
* @returns the native assistant message, or a provider-neutral reconstruction.
|
|
228
|
-
*/
|
|
229
|
-
export function toPiAssistant(message, onDegrade) {
|
|
230
|
-
const source = message.source;
|
|
231
|
-
if (source.kind !== 'model' || source.replayState === undefined)
|
|
232
|
-
return foreignAssistant(message);
|
|
233
|
-
try {
|
|
234
|
-
return replayedAssistant(message, source, source.replayState);
|
|
235
|
-
}
|
|
236
|
-
catch (error) {
|
|
237
|
-
if (!(error instanceof LlmError) || error.code !== 'INVALID_REPLAY_STATE')
|
|
238
|
-
throw error;
|
|
239
|
-
onDegrade?.(error.message);
|
|
240
|
-
return foreignAssistant(message);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* pi-ai assistant events translated into the Harness streaming protocol.
|
|
3
|
-
*
|
|
4
|
-
* pi-ai hands back parsed tool-call arguments while the Harness keeps their raw
|
|
5
|
-
* JSON representation, and it reports failures as terminal stream events rather
|
|
6
|
-
* than throws. Both are normalized here, together with usage and stop reasons.
|
|
7
|
-
*
|
|
8
|
-
* @module @dan-ai-studio/dshopencodego/conversion/stream
|
|
9
|
-
*/
|
|
10
|
-
import { brandString } from '@deepseek-ai/dsh-brand';
|
|
11
|
-
import { CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, } from '@deepseek-ai/dsh-llm';
|
|
12
|
-
import { isContextOverflow } from '@earendil-works/pi-ai/utils/overflow';
|
|
13
|
-
import { toPiReplayState } from './replay.js';
|
|
14
|
-
/**
|
|
15
|
-
* Map pi-ai usage (reasoning already folded into output).
|
|
16
|
-
* @param usage - the terminal event's cumulative usage.
|
|
17
|
-
* @returns Harness counts; cache fields appear only when non-zero, because
|
|
18
|
-
* pi-ai reports zeros rather than absence.
|
|
19
|
-
*/
|
|
20
|
-
export function mapUsage(usage) {
|
|
21
|
-
return {
|
|
22
|
-
inputTokens: usage.input,
|
|
23
|
-
outputTokens: usage.output,
|
|
24
|
-
totalTokens: usage.totalTokens,
|
|
25
|
-
...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
|
|
26
|
-
...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {},
|
|
27
|
-
};
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* The account-quota failure class, written out rather than imported:
|
|
31
|
-
* `dsh-llm` exports it as `ACCOUNT_QUOTA_EXCEEDED_CODE`, and the spelling is
|
|
32
|
-
* the stable protocol value the host routes on, so spelling it here keeps the
|
|
33
|
-
* route correct on every host this plugin supports.
|
|
34
|
-
*/
|
|
35
|
-
const ACCOUNT_QUOTA_CODE = 'ACCOUNT_QUOTA';
|
|
36
|
-
/**
|
|
37
|
-
* Classify a provider error string into a Harness error code.
|
|
38
|
-
*
|
|
39
|
-
* pi-ai flattens a caught transport error to its `message` before this point,
|
|
40
|
-
* so the actionable detail is only available as text. The patterns are ordered
|
|
41
|
-
* from most specific to least, and anything unrecognized stays `PI_AI_ERROR`
|
|
42
|
-
* rather than being guessed into a retryable class.
|
|
43
|
-
*
|
|
44
|
-
* Exhausted quota is reported as `ACCOUNT_QUOTA` rather than the generic
|
|
45
|
-
* `QUOTA`: this gateway meters a prepaid balance (the usage window the plugin
|
|
46
|
-
* shows is the same meter), so the remedy is topping up, not retrying — the
|
|
47
|
-
* same upgrade `llm-deepseek-account` applies to its own prepaid route.
|
|
48
|
-
*/
|
|
49
|
-
export function classifyPiAiError(message) {
|
|
50
|
-
if (/\b(?:401|403)\b/.test(message))
|
|
51
|
-
return 'AUTH';
|
|
52
|
-
if (isQuotaExceededError(message))
|
|
53
|
-
return ACCOUNT_QUOTA_CODE;
|
|
54
|
-
// A 429 typed as `server_error` is the gateway reporting an unavailable
|
|
55
|
-
// upstream, not the caller exceeding a rate limit: the remedy is waiting for
|
|
56
|
-
// the endpoint to recover rather than slowing down, so it classifies as
|
|
57
|
-
// SERVER even though the status code alone reads as a rate limit.
|
|
58
|
-
if (/server_error/i.test(message))
|
|
59
|
-
return 'SERVER';
|
|
60
|
-
if (/\b429\b|rate.?limit/i.test(message))
|
|
61
|
-
return 'RATE_LIMIT';
|
|
62
|
-
if (/\b413\b|payload too large|request body too large|length limit exceeded/i.test(message))
|
|
63
|
-
return 'INVALID_REQUEST';
|
|
64
|
-
if (/\b400\b|invalid.?request/i.test(message))
|
|
65
|
-
return 'INVALID_REQUEST';
|
|
66
|
-
if (/\b5\d\d\b/.test(message))
|
|
67
|
-
return 'SERVER';
|
|
68
|
-
if (/\btime(?:d)?\s*out\b|timeout/i.test(message))
|
|
69
|
-
return 'TIMEOUT';
|
|
70
|
-
if (/stream ended (?:before|without)\b/i.test(message))
|
|
71
|
-
return 'TRANSPORT';
|
|
72
|
-
if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message)
|
|
73
|
-
|| /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message)
|
|
74
|
-
|| /\bterminated\b|premature close/i.test(message)) {
|
|
75
|
-
return 'TRANSPORT';
|
|
76
|
-
}
|
|
77
|
-
return 'PI_AI_ERROR';
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* Map a terminal pi-ai message to the Harness finish reason.
|
|
81
|
-
* @param message - the assistant message carried by `done` or `error`.
|
|
82
|
-
* @param contextWindow - catalog capacity, for usage-based overflow detection.
|
|
83
|
-
* @returns the finish reason; a recognized overflow, a zero-content `stop`, and
|
|
84
|
-
* the non-terminal `pending`/`deferred` states all become errors.
|
|
85
|
-
*/
|
|
86
|
-
export function mapStopReason(message, contextWindow) {
|
|
87
|
-
const overflow = isContextOverflow(message, contextWindow)
|
|
88
|
-
|| (message.stopReason === 'error'
|
|
89
|
-
&& message.errorMessage !== undefined
|
|
90
|
-
&& isContextWindowExceededError(message.errorMessage));
|
|
91
|
-
if (overflow) {
|
|
92
|
-
return {
|
|
93
|
-
kind: 'error',
|
|
94
|
-
failure: {
|
|
95
|
-
message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`,
|
|
96
|
-
code: CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
97
|
-
},
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
switch (message.stopReason) {
|
|
101
|
-
case 'stop':
|
|
102
|
-
if (message.content.length === 0) {
|
|
103
|
-
return {
|
|
104
|
-
kind: 'error',
|
|
105
|
-
failure: {
|
|
106
|
-
message: `model "${message.model}" returned a completed response with no content`,
|
|
107
|
-
code: EMPTY_RESPONSE_CODE,
|
|
108
|
-
},
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
return { kind: 'stop' };
|
|
112
|
-
case 'length': return { kind: 'max-tokens' };
|
|
113
|
-
case 'toolUse': return { kind: 'tool-calls' };
|
|
114
|
-
case 'pending': return {
|
|
115
|
-
kind: 'error',
|
|
116
|
-
failure: { message: `pi-ai stream for model "${message.model}" ended pending`, code: 'PI_AI_ERROR' },
|
|
117
|
-
};
|
|
118
|
-
case 'deferred': return {
|
|
119
|
-
kind: 'error',
|
|
120
|
-
failure: { message: `pi-ai deferred response for model "${message.model}" is not supported`, code: 'PI_AI_ERROR' },
|
|
121
|
-
};
|
|
122
|
-
case 'aborted': return {
|
|
123
|
-
kind: 'aborted',
|
|
124
|
-
failure: { message: message.errorMessage ?? 'pi-ai stream aborted', code: 'ABORTED' },
|
|
125
|
-
};
|
|
126
|
-
case 'error': {
|
|
127
|
-
const text = message.errorMessage ?? 'pi-ai stream error';
|
|
128
|
-
return { kind: 'error', failure: { message: text, code: classifyPiAiError(text) } };
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
/**
|
|
133
|
-
* Translate one assistant turn's pi-ai events into Harness chunks.
|
|
134
|
-
* @param events - the pi-ai event stream for this turn.
|
|
135
|
-
* @param contextWindow - catalog capacity, for usage-based overflow detection.
|
|
136
|
-
* @param callerSignal - caller cancellation; an aborted caller turns an in-band
|
|
137
|
-
* terminal error into an aborted finish.
|
|
138
|
-
* @param requestedModel - request identity recorded in the replay envelope.
|
|
139
|
-
* @returns chunks ending in `usage` then `finish`.
|
|
140
|
-
* @throws {LlmError} `STREAM_CLOSED` when the source ends with no terminal event.
|
|
141
|
-
*/
|
|
142
|
-
export async function* toStreamChunks(events, contextWindow, callerSignal, requestedModel) {
|
|
143
|
-
const toolCalls = new Map();
|
|
144
|
-
for await (const event of events) {
|
|
145
|
-
switch (event.type) {
|
|
146
|
-
case 'start':
|
|
147
|
-
break;
|
|
148
|
-
case 'text_start':
|
|
149
|
-
yield { type: 'block-start', index: event.contentIndex, blockType: 'text' };
|
|
150
|
-
break;
|
|
151
|
-
case 'text_delta':
|
|
152
|
-
yield { type: 'text-delta', index: event.contentIndex, text: event.delta };
|
|
153
|
-
break;
|
|
154
|
-
case 'text_end':
|
|
155
|
-
yield { type: 'block-end', index: event.contentIndex, block: { type: 'text', text: event.content } };
|
|
156
|
-
break;
|
|
157
|
-
case 'thinking_start':
|
|
158
|
-
yield { type: 'block-start', index: event.contentIndex, blockType: 'reasoning' };
|
|
159
|
-
break;
|
|
160
|
-
case 'thinking_delta':
|
|
161
|
-
yield { type: 'reasoning-delta', index: event.contentIndex, text: event.delta };
|
|
162
|
-
break;
|
|
163
|
-
case 'thinking_end':
|
|
164
|
-
yield { type: 'block-end', index: event.contentIndex, block: { type: 'reasoning', text: event.content } };
|
|
165
|
-
break;
|
|
166
|
-
case 'toolcall_start': {
|
|
167
|
-
// The id and name live on the partial message at this index.
|
|
168
|
-
const partial = event.partial.content[event.contentIndex];
|
|
169
|
-
toolCalls.set(event.contentIndex, {
|
|
170
|
-
id: partial?.type === 'toolCall' ? partial.id : '',
|
|
171
|
-
name: partial?.type === 'toolCall' ? partial.name : '',
|
|
172
|
-
});
|
|
173
|
-
yield { type: 'block-start', index: event.contentIndex, blockType: 'tool-call' };
|
|
174
|
-
break;
|
|
175
|
-
}
|
|
176
|
-
case 'toolcall_delta': {
|
|
177
|
-
const known = toolCalls.get(event.contentIndex);
|
|
178
|
-
yield {
|
|
179
|
-
type: 'tool-call-delta',
|
|
180
|
-
index: event.contentIndex,
|
|
181
|
-
id: brandString(known?.id ?? ''),
|
|
182
|
-
...known !== undefined && known.name.length > 0 ? { name: known.name } : {},
|
|
183
|
-
argumentsDelta: event.delta,
|
|
184
|
-
};
|
|
185
|
-
break;
|
|
186
|
-
}
|
|
187
|
-
case 'toolcall_end':
|
|
188
|
-
yield {
|
|
189
|
-
type: 'block-end',
|
|
190
|
-
index: event.contentIndex,
|
|
191
|
-
block: {
|
|
192
|
-
type: 'tool-call',
|
|
193
|
-
id: brandString(event.toolCall.id),
|
|
194
|
-
name: event.toolCall.name,
|
|
195
|
-
// pi-ai parses the arguments; the Harness vocabulary keeps the raw
|
|
196
|
-
// JSON string it stores and replays.
|
|
197
|
-
arguments: JSON.stringify(event.toolCall.arguments),
|
|
198
|
-
},
|
|
199
|
-
};
|
|
200
|
-
break;
|
|
201
|
-
case 'done':
|
|
202
|
-
yield { type: 'usage', usage: mapUsage(event.message.usage) };
|
|
203
|
-
yield {
|
|
204
|
-
type: 'finish',
|
|
205
|
-
reason: mapStopReason(event.message, contextWindow),
|
|
206
|
-
replayState: toPiReplayState(event.message, requestedModel),
|
|
207
|
-
};
|
|
208
|
-
return;
|
|
209
|
-
case 'error':
|
|
210
|
-
// pi-ai delivers failures in-band; the Harness protocol's other
|
|
211
|
-
// sanctioned error path is an error/aborted finish chunk.
|
|
212
|
-
yield { type: 'usage', usage: mapUsage(event.error.usage) };
|
|
213
|
-
yield {
|
|
214
|
-
type: 'finish',
|
|
215
|
-
reason: mapStopReason(callerSignal?.aborted ? { ...event.error, stopReason: 'aborted' } : event.error, contextWindow),
|
|
216
|
-
};
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
throw new LlmError('pi-ai event stream ended without a terminal event', 'STREAM_CLOSED');
|
|
221
|
-
}
|