@dan-ai-studio/dshopencodego 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +156 -0
- package/README.md +156 -0
- package/cordis.patch.yml +3 -0
- package/lib/build-info.json +11 -0
- package/lib/client.js +1215 -0
- package/lib/index.js +2036 -0
- package/lib/types/adapter.d.ts +84 -0
- package/lib/types/adapter.js +311 -0
- package/lib/types/catalog/constants.d.ts +16 -0
- package/lib/types/catalog/constants.js +16 -0
- package/lib/types/catalog/contract.d.ts +26 -0
- package/lib/types/catalog/contract.js +131 -0
- package/lib/types/catalog/gateway.d.ts +20 -0
- package/lib/types/catalog/gateway.js +59 -0
- package/lib/types/catalog/index.d.ts +108 -0
- package/lib/types/catalog/index.js +288 -0
- package/lib/types/catalog/json-response.d.ts +19 -0
- package/lib/types/catalog/json-response.js +72 -0
- package/lib/types/catalog/metadata.d.ts +73 -0
- package/lib/types/catalog/metadata.js +259 -0
- package/lib/types/catalog/protocol.d.ts +65 -0
- package/lib/types/catalog/protocol.js +87 -0
- package/lib/types/catalog/reading.d.ts +41 -0
- package/lib/types/catalog/reading.js +68 -0
- package/lib/types/catalog/service.d.ts +32 -0
- package/lib/types/catalog/service.js +45 -0
- package/lib/types/config.d.ts +93 -0
- package/lib/types/config.js +76 -0
- package/lib/types/conversion/context.d.ts +55 -0
- package/lib/types/conversion/context.js +202 -0
- package/lib/types/conversion/index.d.ts +9 -0
- package/lib/types/conversion/index.js +7 -0
- package/lib/types/conversion/replay.d.ts +56 -0
- package/lib/types/conversion/replay.js +242 -0
- package/lib/types/conversion/stream.d.ts +46 -0
- package/lib/types/conversion/stream.js +203 -0
- package/lib/types/go-limits.d.ts +41 -0
- package/lib/types/go-limits.js +79 -0
- package/lib/types/index.d.ts +54 -0
- package/lib/types/index.js +195 -0
- package/lib/types/models.d.ts +90 -0
- package/lib/types/models.js +86 -0
- package/lib/types/remotes.d.ts +12 -0
- package/lib/types/remotes.js +28 -0
- package/lib/types/session-header.d.ts +36 -0
- package/lib/types/session-header.js +45 -0
- package/lib/types/usage/contract.d.ts +39 -0
- package/lib/types/usage/contract.js +106 -0
- package/lib/types/usage/index.d.ts +11 -0
- package/lib/types/usage/index.js +8 -0
- package/lib/types/usage/meter.d.ts +53 -0
- package/lib/types/usage/meter.js +65 -0
- package/lib/types/usage/service.d.ts +48 -0
- package/lib/types/usage/service.js +74 -0
- package/lib/types/usage/windows.d.ts +51 -0
- package/lib/types/usage/windows.js +84 -0
- package/package.json +147 -0
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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 type { FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm';
|
|
11
|
+
import type { AssistantMessage, AssistantMessageEvent, Usage as PiUsage } from '@earendil-works/pi-ai';
|
|
12
|
+
/**
|
|
13
|
+
* Map pi-ai usage (reasoning already folded into output).
|
|
14
|
+
* @param usage - the terminal event's cumulative usage.
|
|
15
|
+
* @returns Harness counts; cache fields appear only when non-zero, because
|
|
16
|
+
* pi-ai reports zeros rather than absence.
|
|
17
|
+
*/
|
|
18
|
+
export declare function mapUsage(usage: PiUsage): TokenUsage;
|
|
19
|
+
/**
|
|
20
|
+
* Classify a provider error string into a Harness error code.
|
|
21
|
+
*
|
|
22
|
+
* pi-ai flattens a caught transport error to its `message` before this point,
|
|
23
|
+
* so the actionable detail is only available as text. The patterns are ordered
|
|
24
|
+
* from most specific to least, and anything unrecognized stays `PI_AI_ERROR`
|
|
25
|
+
* rather than being guessed into a retryable class.
|
|
26
|
+
*/
|
|
27
|
+
export declare function classifyPiAiError(message: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Map a terminal pi-ai message to the Harness finish reason.
|
|
30
|
+
* @param message - the assistant message carried by `done` or `error`.
|
|
31
|
+
* @param contextWindow - catalog capacity, for usage-based overflow detection.
|
|
32
|
+
* @returns the finish reason; a recognized overflow, a zero-content `stop`, and
|
|
33
|
+
* the non-terminal `pending`/`deferred` states all become errors.
|
|
34
|
+
*/
|
|
35
|
+
export declare function mapStopReason(message: AssistantMessage, contextWindow?: number): FinishReason;
|
|
36
|
+
/**
|
|
37
|
+
* Translate one assistant turn's pi-ai events into Harness chunks.
|
|
38
|
+
* @param events - the pi-ai event stream for this turn.
|
|
39
|
+
* @param contextWindow - catalog capacity, for usage-based overflow detection.
|
|
40
|
+
* @param callerSignal - caller cancellation; an aborted caller turns an in-band
|
|
41
|
+
* terminal error into an aborted finish.
|
|
42
|
+
* @param requestedModel - request identity recorded in the replay envelope.
|
|
43
|
+
* @returns chunks ending in `usage` then `finish`.
|
|
44
|
+
* @throws {LlmError} `STREAM_CLOSED` when the source ends with no terminal event.
|
|
45
|
+
*/
|
|
46
|
+
export declare function toStreamChunks(events: AsyncIterable<AssistantMessageEvent>, contextWindow?: number, callerSignal?: AbortSignal, requestedModel?: string): AsyncGenerator<StreamChunk>;
|
|
@@ -0,0 +1,203 @@
|
|
|
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, QUOTA_EXCEEDED_CODE, } from '@deepseek-ai/dsh-llm';
|
|
12
|
+
import { isContextOverflow } from '@earendil-works/pi-ai';
|
|
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
|
+
* Classify a provider error string into a Harness error code.
|
|
31
|
+
*
|
|
32
|
+
* pi-ai flattens a caught transport error to its `message` before this point,
|
|
33
|
+
* so the actionable detail is only available as text. The patterns are ordered
|
|
34
|
+
* from most specific to least, and anything unrecognized stays `PI_AI_ERROR`
|
|
35
|
+
* rather than being guessed into a retryable class.
|
|
36
|
+
*/
|
|
37
|
+
export function classifyPiAiError(message) {
|
|
38
|
+
if (/\b(?:401|403)\b/.test(message))
|
|
39
|
+
return 'AUTH';
|
|
40
|
+
if (isQuotaExceededError(message))
|
|
41
|
+
return QUOTA_EXCEEDED_CODE;
|
|
42
|
+
if (/\b429\b|rate.?limit/i.test(message))
|
|
43
|
+
return 'RATE_LIMIT';
|
|
44
|
+
if (/\b413\b|payload too large|request body too large|length limit exceeded/i.test(message))
|
|
45
|
+
return 'INVALID_REQUEST';
|
|
46
|
+
if (/\b400\b|invalid.?request/i.test(message))
|
|
47
|
+
return 'INVALID_REQUEST';
|
|
48
|
+
if (/\b5\d\d\b/.test(message))
|
|
49
|
+
return 'SERVER';
|
|
50
|
+
if (/\btime(?:d)?\s*out\b|timeout/i.test(message))
|
|
51
|
+
return 'TIMEOUT';
|
|
52
|
+
if (/stream ended (?:before|without)\b/i.test(message))
|
|
53
|
+
return 'TRANSPORT';
|
|
54
|
+
if (/\b(?:network|connection|socket|fetch)\b|\bECONN[A-Z]+\b/i.test(message)
|
|
55
|
+
|| /\b(?:other side closed|HTTP2 request did not get a response|WebSocket closed unexpectedly)\b/i.test(message)
|
|
56
|
+
|| /\bterminated\b|premature close/i.test(message)) {
|
|
57
|
+
return 'TRANSPORT';
|
|
58
|
+
}
|
|
59
|
+
return 'PI_AI_ERROR';
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Map a terminal pi-ai message to the Harness finish reason.
|
|
63
|
+
* @param message - the assistant message carried by `done` or `error`.
|
|
64
|
+
* @param contextWindow - catalog capacity, for usage-based overflow detection.
|
|
65
|
+
* @returns the finish reason; a recognized overflow, a zero-content `stop`, and
|
|
66
|
+
* the non-terminal `pending`/`deferred` states all become errors.
|
|
67
|
+
*/
|
|
68
|
+
export function mapStopReason(message, contextWindow) {
|
|
69
|
+
const overflow = isContextOverflow(message, contextWindow)
|
|
70
|
+
|| (message.stopReason === 'error'
|
|
71
|
+
&& message.errorMessage !== undefined
|
|
72
|
+
&& isContextWindowExceededError(message.errorMessage));
|
|
73
|
+
if (overflow) {
|
|
74
|
+
return {
|
|
75
|
+
kind: 'error',
|
|
76
|
+
failure: {
|
|
77
|
+
message: message.errorMessage ?? `pi-ai detected context overflow for model "${message.model}"`,
|
|
78
|
+
code: CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
switch (message.stopReason) {
|
|
83
|
+
case 'stop':
|
|
84
|
+
if (message.content.length === 0) {
|
|
85
|
+
return {
|
|
86
|
+
kind: 'error',
|
|
87
|
+
failure: {
|
|
88
|
+
message: `model "${message.model}" returned a completed response with no content`,
|
|
89
|
+
code: EMPTY_RESPONSE_CODE,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { kind: 'stop' };
|
|
94
|
+
case 'length': return { kind: 'max-tokens' };
|
|
95
|
+
case 'toolUse': return { kind: 'tool-calls' };
|
|
96
|
+
case 'pending': return {
|
|
97
|
+
kind: 'error',
|
|
98
|
+
failure: { message: `pi-ai stream for model "${message.model}" ended pending`, code: 'PI_AI_ERROR' },
|
|
99
|
+
};
|
|
100
|
+
case 'deferred': return {
|
|
101
|
+
kind: 'error',
|
|
102
|
+
failure: { message: `pi-ai deferred response for model "${message.model}" is not supported`, code: 'PI_AI_ERROR' },
|
|
103
|
+
};
|
|
104
|
+
case 'aborted': return {
|
|
105
|
+
kind: 'aborted',
|
|
106
|
+
failure: { message: message.errorMessage ?? 'pi-ai stream aborted', code: 'ABORTED' },
|
|
107
|
+
};
|
|
108
|
+
case 'error': {
|
|
109
|
+
const text = message.errorMessage ?? 'pi-ai stream error';
|
|
110
|
+
return { kind: 'error', failure: { message: text, code: classifyPiAiError(text) } };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Translate one assistant turn's pi-ai events into Harness chunks.
|
|
116
|
+
* @param events - the pi-ai event stream for this turn.
|
|
117
|
+
* @param contextWindow - catalog capacity, for usage-based overflow detection.
|
|
118
|
+
* @param callerSignal - caller cancellation; an aborted caller turns an in-band
|
|
119
|
+
* terminal error into an aborted finish.
|
|
120
|
+
* @param requestedModel - request identity recorded in the replay envelope.
|
|
121
|
+
* @returns chunks ending in `usage` then `finish`.
|
|
122
|
+
* @throws {LlmError} `STREAM_CLOSED` when the source ends with no terminal event.
|
|
123
|
+
*/
|
|
124
|
+
export async function* toStreamChunks(events, contextWindow, callerSignal, requestedModel) {
|
|
125
|
+
const toolCalls = new Map();
|
|
126
|
+
for await (const event of events) {
|
|
127
|
+
switch (event.type) {
|
|
128
|
+
case 'start':
|
|
129
|
+
break;
|
|
130
|
+
case 'text_start':
|
|
131
|
+
yield { type: 'block-start', index: event.contentIndex, blockType: 'text' };
|
|
132
|
+
break;
|
|
133
|
+
case 'text_delta':
|
|
134
|
+
yield { type: 'text-delta', index: event.contentIndex, text: event.delta };
|
|
135
|
+
break;
|
|
136
|
+
case 'text_end':
|
|
137
|
+
yield { type: 'block-end', index: event.contentIndex, block: { type: 'text', text: event.content } };
|
|
138
|
+
break;
|
|
139
|
+
case 'thinking_start':
|
|
140
|
+
yield { type: 'block-start', index: event.contentIndex, blockType: 'reasoning' };
|
|
141
|
+
break;
|
|
142
|
+
case 'thinking_delta':
|
|
143
|
+
yield { type: 'reasoning-delta', index: event.contentIndex, text: event.delta };
|
|
144
|
+
break;
|
|
145
|
+
case 'thinking_end':
|
|
146
|
+
yield { type: 'block-end', index: event.contentIndex, block: { type: 'reasoning', text: event.content } };
|
|
147
|
+
break;
|
|
148
|
+
case 'toolcall_start': {
|
|
149
|
+
// The id and name live on the partial message at this index.
|
|
150
|
+
const partial = event.partial.content[event.contentIndex];
|
|
151
|
+
toolCalls.set(event.contentIndex, {
|
|
152
|
+
id: partial?.type === 'toolCall' ? partial.id : '',
|
|
153
|
+
name: partial?.type === 'toolCall' ? partial.name : '',
|
|
154
|
+
});
|
|
155
|
+
yield { type: 'block-start', index: event.contentIndex, blockType: 'tool-call' };
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case 'toolcall_delta': {
|
|
159
|
+
const known = toolCalls.get(event.contentIndex);
|
|
160
|
+
yield {
|
|
161
|
+
type: 'tool-call-delta',
|
|
162
|
+
index: event.contentIndex,
|
|
163
|
+
id: brandString(known?.id ?? ''),
|
|
164
|
+
...known !== undefined && known.name.length > 0 ? { name: known.name } : {},
|
|
165
|
+
argumentsDelta: event.delta,
|
|
166
|
+
};
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
case 'toolcall_end':
|
|
170
|
+
yield {
|
|
171
|
+
type: 'block-end',
|
|
172
|
+
index: event.contentIndex,
|
|
173
|
+
block: {
|
|
174
|
+
type: 'tool-call',
|
|
175
|
+
id: brandString(event.toolCall.id),
|
|
176
|
+
name: event.toolCall.name,
|
|
177
|
+
// pi-ai parses the arguments; the Harness vocabulary keeps the raw
|
|
178
|
+
// JSON string it stores and replays.
|
|
179
|
+
arguments: JSON.stringify(event.toolCall.arguments),
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
break;
|
|
183
|
+
case 'done':
|
|
184
|
+
yield { type: 'usage', usage: mapUsage(event.message.usage) };
|
|
185
|
+
yield {
|
|
186
|
+
type: 'finish',
|
|
187
|
+
reason: mapStopReason(event.message, contextWindow),
|
|
188
|
+
replayState: toPiReplayState(event.message, requestedModel),
|
|
189
|
+
};
|
|
190
|
+
return;
|
|
191
|
+
case 'error':
|
|
192
|
+
// pi-ai delivers failures in-band; the Harness protocol's other
|
|
193
|
+
// sanctioned error path is an error/aborted finish chunk.
|
|
194
|
+
yield { type: 'usage', usage: mapUsage(event.error.usage) };
|
|
195
|
+
yield {
|
|
196
|
+
type: 'finish',
|
|
197
|
+
reason: mapStopReason(callerSignal?.aborted ? { ...event.error, stopReason: 'aborted' } : event.error, contextWindow),
|
|
198
|
+
};
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
throw new LlmError('pi-ai event stream ended without a terminal event', 'STREAM_CLOSED');
|
|
203
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Go's published per-model quota.
|
|
3
|
+
*
|
|
4
|
+
* No API exposes this — the gateway's `/v1/models` answers only ids, and
|
|
5
|
+
* `/usage` answers three account-wide percentages — so the numbers are
|
|
6
|
+
* transcribed from the provider's own documentation.
|
|
7
|
+
*
|
|
8
|
+
* Source: https://opencode.ai/docs/zh-cn/go — the 「使用限制」 and
|
|
9
|
+
* 「预估请求数」 tables. Transcribed 2026-09-25.
|
|
10
|
+
*
|
|
11
|
+
* Two caveats are part of the data's meaning, not noise:
|
|
12
|
+
* - `monthlyUsd` is the hard monthly allowance; the request counts are the
|
|
13
|
+
* provider's own estimate for a typical request mix, not a hard ceiling.
|
|
14
|
+
* - DeepSeek V4.1 Flash carries a limited-time 4x allowance (until 2026-09-27)
|
|
15
|
+
* already reflected here; the archived numbers follow the provider page.
|
|
16
|
+
*
|
|
17
|
+
* @module @dan-ai-studio/dshopencodego/go-limits
|
|
18
|
+
*/
|
|
19
|
+
/** One model's published Go allowance. */
|
|
20
|
+
export interface GoQuota {
|
|
21
|
+
/** Monthly usage allowance in USD, or `unlimited` where none is published. */
|
|
22
|
+
readonly monthlyUsd: number | 'unlimited';
|
|
23
|
+
/** Provider's estimated requests per month for a typical mix, when listed. */
|
|
24
|
+
readonly monthlyRequests?: number | 'unlimited';
|
|
25
|
+
}
|
|
26
|
+
/** The transcribed table, keyed by model id. */
|
|
27
|
+
export declare const GO_QUOTAS: Readonly<Record<string, GoQuota>>;
|
|
28
|
+
/**
|
|
29
|
+
* The published quota for one model id.
|
|
30
|
+
* @param id - the gateway's model id.
|
|
31
|
+
* @returns the transcribed allowance, or undefined when the page lists none.
|
|
32
|
+
*/
|
|
33
|
+
export declare function goQuotaFor(id: string): GoQuota | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* Rank a quota for "most usable first" ordering: unlimited beats any finite
|
|
36
|
+
* request estimate, a larger estimate beats a smaller one, and a model the
|
|
37
|
+
* page does not quantify sorts last.
|
|
38
|
+
* @param quota - the allowance under comparison, when one exists.
|
|
39
|
+
* @returns a sortable rank.
|
|
40
|
+
*/
|
|
41
|
+
export declare function monthlyRequestsRank(quota: GoQuota | undefined): number;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Go's published per-model quota.
|
|
3
|
+
*
|
|
4
|
+
* No API exposes this — the gateway's `/v1/models` answers only ids, and
|
|
5
|
+
* `/usage` answers three account-wide percentages — so the numbers are
|
|
6
|
+
* transcribed from the provider's own documentation.
|
|
7
|
+
*
|
|
8
|
+
* Source: https://opencode.ai/docs/zh-cn/go — the 「使用限制」 and
|
|
9
|
+
* 「预估请求数」 tables. Transcribed 2026-09-25.
|
|
10
|
+
*
|
|
11
|
+
* Two caveats are part of the data's meaning, not noise:
|
|
12
|
+
* - `monthlyUsd` is the hard monthly allowance; the request counts are the
|
|
13
|
+
* provider's own estimate for a typical request mix, not a hard ceiling.
|
|
14
|
+
* - DeepSeek V4.1 Flash carries a limited-time 4x allowance (until 2026-09-27)
|
|
15
|
+
* already reflected here; the archived numbers follow the provider page.
|
|
16
|
+
*
|
|
17
|
+
* @module @dan-ai-studio/dshopencodego/go-limits
|
|
18
|
+
*/
|
|
19
|
+
/** The transcribed table, keyed by model id. */
|
|
20
|
+
export const GO_QUOTAS = {
|
|
21
|
+
'glm-5.3-flash': { monthlyUsd: 60, monthlyRequests: 31_580 },
|
|
22
|
+
'glm-5.3': { monthlyUsd: 15, monthlyRequests: 1_080 },
|
|
23
|
+
'glm-5.2': { monthlyUsd: 60, monthlyRequests: 4_300 },
|
|
24
|
+
'glm-5.1': { monthlyUsd: 60, monthlyRequests: 4_300 },
|
|
25
|
+
'kimi-k3': { monthlyUsd: 15, monthlyRequests: 490 },
|
|
26
|
+
'kimi-k2.7-code': { monthlyUsd: 60, monthlyRequests: 6_750 },
|
|
27
|
+
'kimi-k2.6': { monthlyUsd: 60, monthlyRequests: 5_750 },
|
|
28
|
+
'longcat-2.0': { monthlyUsd: 60, monthlyRequests: 57_200 },
|
|
29
|
+
'mimo-v2.6-flash': { monthlyUsd: 60, monthlyRequests: 150_400 },
|
|
30
|
+
'mimo-v2.6-pro': { monthlyUsd: 15, monthlyRequests: 16_300 },
|
|
31
|
+
'mimo-v2.5': { monthlyUsd: 60, monthlyRequests: 150_400 },
|
|
32
|
+
'mimo-v2.5-pro': { monthlyUsd: 15, monthlyRequests: 16_300 },
|
|
33
|
+
'minimax-m3': { monthlyUsd: 60, monthlyRequests: 16_000 },
|
|
34
|
+
'minimax-m2.7': { monthlyUsd: 60, monthlyRequests: 17_000 },
|
|
35
|
+
'minimax-m2.5': { monthlyUsd: 60 },
|
|
36
|
+
'muse-spark-1.3-contributor': { monthlyUsd: 60, monthlyRequests: 226_600 },
|
|
37
|
+
'muse-spark-1.2-contributor': { monthlyUsd: 60, monthlyRequests: 226_600 },
|
|
38
|
+
'qwen3.8-max': { monthlyUsd: 15, monthlyRequests: 810 },
|
|
39
|
+
'qwen3.8-flash': { monthlyUsd: 30, monthlyRequests: 27_000 },
|
|
40
|
+
'qwen3.7-max': { monthlyUsd: 30, monthlyRequests: 840 },
|
|
41
|
+
'qwen3.7-plus': { monthlyUsd: 60, monthlyRequests: 21_600 },
|
|
42
|
+
'qwen3.6-plus': { monthlyUsd: 60, monthlyRequests: 16_300 },
|
|
43
|
+
'deepseek-v4.1-flash': { monthlyUsd: 60, monthlyRequests: 130_000 },
|
|
44
|
+
'deepseek-v4-pro': { monthlyUsd: 15, monthlyRequests: 5_200 },
|
|
45
|
+
'deepseek-v4-flash': { monthlyUsd: 30, monthlyRequests: 65_000 },
|
|
46
|
+
'deepseek-v4-flash-vision-exp': { monthlyUsd: 15, monthlyRequests: 32_500 },
|
|
47
|
+
'hy4-preview': { monthlyUsd: 30, monthlyRequests: 6_770 },
|
|
48
|
+
'hy3': { monthlyUsd: 60, monthlyRequests: 21_500 },
|
|
49
|
+
'space-bunny-free': { monthlyUsd: 'unlimited', monthlyRequests: 'unlimited' },
|
|
50
|
+
'grok-4.7': { monthlyUsd: 15, monthlyRequests: 845 },
|
|
51
|
+
'grok-4.6': { monthlyUsd: 15, monthlyRequests: 845 },
|
|
52
|
+
'gpt-6-luna': { monthlyUsd: 15, monthlyRequests: 21_130 },
|
|
53
|
+
'gpt-5.6-luna': { monthlyUsd: 15, monthlyRequests: 10_250 },
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* The published quota for one model id.
|
|
57
|
+
* @param id - the gateway's model id.
|
|
58
|
+
* @returns the transcribed allowance, or undefined when the page lists none.
|
|
59
|
+
*/
|
|
60
|
+
export function goQuotaFor(id) {
|
|
61
|
+
return Object.hasOwn(GO_QUOTAS, id) ? GO_QUOTAS[id] : undefined;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Rank a quota for "most usable first" ordering: unlimited beats any finite
|
|
65
|
+
* request estimate, a larger estimate beats a smaller one, and a model the
|
|
66
|
+
* page does not quantify sorts last.
|
|
67
|
+
* @param quota - the allowance under comparison, when one exists.
|
|
68
|
+
* @returns a sortable rank.
|
|
69
|
+
*/
|
|
70
|
+
export function monthlyRequestsRank(quota) {
|
|
71
|
+
if (quota === undefined)
|
|
72
|
+
return -1;
|
|
73
|
+
const requests = quota.monthlyRequests;
|
|
74
|
+
if (requests === 'unlimited')
|
|
75
|
+
return Number.MAX_SAFE_INTEGER;
|
|
76
|
+
if (requests === undefined)
|
|
77
|
+
return quota.monthlyUsd === 'unlimited' ? Number.MAX_SAFE_INTEGER : -1;
|
|
78
|
+
return requests;
|
|
79
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `dshopencodego` plugin: one `opencode-go` route with a live catalog and
|
|
3
|
+
* the gateway's mandatory session header.
|
|
4
|
+
*
|
|
5
|
+
* The plugin exists because a generic pi-ai route cannot express two things the
|
|
6
|
+
* OpenCode Go gateway needs: a model list that rotates faster than any shipped
|
|
7
|
+
* catalog, and a per-conversation `x-opencode-session` routing header on every
|
|
8
|
+
* inference request.
|
|
9
|
+
*
|
|
10
|
+
* Route registration is gated on both configuration and credential: a route
|
|
11
|
+
* whose key is missing would otherwise sit in every model picker and read as a
|
|
12
|
+
* usable provider to first-run onboarding. The gate re-evaluates on every
|
|
13
|
+
* credential write and every loader update, and a route another adapter already
|
|
14
|
+
* owns is reported rather than crashing the mount.
|
|
15
|
+
*
|
|
16
|
+
* ```yaml
|
|
17
|
+
* - id: dshopencodego
|
|
18
|
+
* name: '@dan-ai-studio/dshopencodego'
|
|
19
|
+
* config:
|
|
20
|
+
* enabled: true # false withdraws the route only
|
|
21
|
+
* apiKeyEnv: OPENCODE_GO_API_KEY # default
|
|
22
|
+
* baseURL: https://opencode.ai/zen/go/v1 # default
|
|
23
|
+
* refreshMinutes: 60 # live catalog TTL
|
|
24
|
+
* modelProtocols: # last-resort protocol override
|
|
25
|
+
* some-model: openai-responses
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @module @dan-ai-studio/dshopencodego
|
|
29
|
+
*/
|
|
30
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
31
|
+
declare module '@deepseek-ai/cordis' {
|
|
32
|
+
interface Events {
|
|
33
|
+
'loader/volatile-update'(paths: readonly (readonly string[])[]): void;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export { OpencodeGoAdapter } from './adapter.ts';
|
|
37
|
+
export type { OpencodeGoAdapterOptions, OpencodeGoImageAccess } from './adapter.ts';
|
|
38
|
+
export { DEFAULT_BASE_URL, DISPLAY_NAME, PROVIDER_ID, OpencodeGoCatalog, discoverCatalogModels } from './catalog/index.ts';
|
|
39
|
+
export { Config, PlainConfig, assertBaseURL, DEFAULT_API_KEY_ENV } from './config.ts';
|
|
40
|
+
export type { OpencodeGoConfig } from './config.ts';
|
|
41
|
+
export { SESSION_HEADER, opencodeSessionValue, providerHeaders } from './session-header.ts';
|
|
42
|
+
export { isModelEnabled, sortModels } from './models.ts';
|
|
43
|
+
export type { ModelSummary } from './models.ts';
|
|
44
|
+
export declare const name = "dshopencodego";
|
|
45
|
+
export declare const inject: string[];
|
|
46
|
+
/**
|
|
47
|
+
* Register the route, its discovery, and their teardown for one mount.
|
|
48
|
+
*
|
|
49
|
+
* Configuration is read through a live source so a profile edit reaches the
|
|
50
|
+
* next request without a restart; the adapter re-reads it at every operation.
|
|
51
|
+
* @param ctx - the plugin's Cordis context.
|
|
52
|
+
* @param raw - the loader's live config, or a plain object in tests.
|
|
53
|
+
*/
|
|
54
|
+
export declare function apply(ctx: Context, raw?: unknown): void;
|