opencode-cmd-provider 1.2.2 → 1.3.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/CHANGELOG.md +46 -0
- package/dist/src/deals/catalog.js +2 -0
- package/dist/src/deals/plan-summary.d.ts +33 -1
- package/dist/src/deals/plan-summary.js +35 -19
- package/dist/src/env.d.ts +9 -0
- package/dist/src/env.js +11 -0
- package/dist/src/provider/command-code-model.d.ts +49 -0
- package/dist/src/provider/command-code-model.js +419 -171
- package/dist/src/provider/converters.d.ts +18 -0
- package/dist/src/provider/converters.js +464 -3
- package/dist/src/provider/redact.d.ts +11 -0
- package/dist/src/provider/redact.js +38 -0
- package/dist/src/provider/stream.d.ts +6 -0
- package/dist/src/provider/stream.js +467 -3
- package/package.json +2 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isRecord, stringValue, numberValue, recordOrEmpty } from "./converters.js";
|
|
2
|
-
import { commandCodeErrorMessage } from "./redact.js";
|
|
2
|
+
import { commandCodeErrorMessage, redactCommandCodeErrorText } from "./redact.js";
|
|
3
3
|
export function parseStreamEventLine(line) {
|
|
4
4
|
let trimmed = line.trim();
|
|
5
5
|
if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:"))
|
|
@@ -25,7 +25,7 @@ export function mapFinishReason(reason) {
|
|
|
25
25
|
raw === "max_output_tokens") {
|
|
26
26
|
return { unified: "length", raw };
|
|
27
27
|
}
|
|
28
|
-
if (raw === "stop")
|
|
28
|
+
if (raw === "stop" || raw === "end_turn" || raw === "stop_sequence")
|
|
29
29
|
return { unified: "stop", raw };
|
|
30
30
|
if (raw === "error")
|
|
31
31
|
return { unified: "error", raw };
|
|
@@ -111,9 +111,473 @@ export function ccEventToStreamPart(event) {
|
|
|
111
111
|
const message = commandCodeErrorMessage(event.error) ??
|
|
112
112
|
commandCodeErrorMessage(event.message) ??
|
|
113
113
|
"Command Code stream error";
|
|
114
|
-
throw new Error(message);
|
|
114
|
+
throw new Error(redactCommandCodeErrorText(message));
|
|
115
115
|
}
|
|
116
116
|
default:
|
|
117
117
|
return [];
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
|
+
function asRecord(value) {
|
|
121
|
+
return isRecord(value) ? value : undefined;
|
|
122
|
+
}
|
|
123
|
+
function extractUsageTokens(usage) {
|
|
124
|
+
const rec = asRecord(usage);
|
|
125
|
+
if (!rec)
|
|
126
|
+
return undefined;
|
|
127
|
+
// OpenAI: prompt_tokens / completion_tokens / total_tokens
|
|
128
|
+
// Anthropic: input_tokens / output_tokens / cache_read_input_tokens / cache_creation_input_tokens
|
|
129
|
+
// Generic: inputTokens / outputTokens / input_tokens etc.
|
|
130
|
+
const input = numberValue(rec.prompt_tokens) ??
|
|
131
|
+
numberValue(rec.input_tokens) ??
|
|
132
|
+
numberValue(rec.inputTokens) ??
|
|
133
|
+
numberValue(rec.promptTokens) ??
|
|
134
|
+
0;
|
|
135
|
+
const output = numberValue(rec.completion_tokens) ??
|
|
136
|
+
numberValue(rec.output_tokens) ??
|
|
137
|
+
numberValue(rec.outputTokens) ??
|
|
138
|
+
numberValue(rec.completionTokens) ??
|
|
139
|
+
0;
|
|
140
|
+
const cacheRead = numberValue(rec.cache_read_input_tokens) ??
|
|
141
|
+
numberValue(rec.cacheReadTokens) ??
|
|
142
|
+
numberValue(rec.cacheRead) ??
|
|
143
|
+
0;
|
|
144
|
+
const cacheWrite = numberValue(rec.cache_creation_input_tokens) ??
|
|
145
|
+
numberValue(rec.cacheWriteTokens) ??
|
|
146
|
+
numberValue(rec.cacheWrite) ??
|
|
147
|
+
0;
|
|
148
|
+
// If nothing meaningful, signal undefined so caller can fallback
|
|
149
|
+
if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) {
|
|
150
|
+
// Could be empty usage object — still return zeroed usage for finish
|
|
151
|
+
// but let caller decide if usage was present at all
|
|
152
|
+
const hasAnyKey = "prompt_tokens" in rec ||
|
|
153
|
+
"input_tokens" in rec ||
|
|
154
|
+
"inputTokens" in rec ||
|
|
155
|
+
"completion_tokens" in rec ||
|
|
156
|
+
"output_tokens" in rec ||
|
|
157
|
+
"outputTokens" in rec;
|
|
158
|
+
if (!hasAnyKey)
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
return { input, output, cacheRead, cacheWrite };
|
|
162
|
+
}
|
|
163
|
+
function usageToAiSdk(usage) {
|
|
164
|
+
const tokens = extractUsageTokens(usage);
|
|
165
|
+
if (!tokens)
|
|
166
|
+
return undefined;
|
|
167
|
+
const totalInput = tokens.input;
|
|
168
|
+
const noCache = Math.max(0, totalInput - tokens.cacheRead - tokens.cacheWrite);
|
|
169
|
+
return {
|
|
170
|
+
inputTokens: {
|
|
171
|
+
total: totalInput,
|
|
172
|
+
noCache,
|
|
173
|
+
cacheRead: tokens.cacheRead,
|
|
174
|
+
cacheWrite: tokens.cacheWrite,
|
|
175
|
+
},
|
|
176
|
+
outputTokens: { total: tokens.output, text: tokens.output, reasoning: 0 },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function firstChoice(event) {
|
|
180
|
+
const choices = event.choices;
|
|
181
|
+
if (Array.isArray(choices) && choices.length > 0 && isRecord(choices[0]))
|
|
182
|
+
return choices[0];
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
function deltaFromChoice(choice) {
|
|
186
|
+
const delta = choice.delta;
|
|
187
|
+
return isRecord(delta) ? delta : undefined;
|
|
188
|
+
}
|
|
189
|
+
export function openAIUsageToAiSdkUsage(event) {
|
|
190
|
+
// event may be the full chunk or just the usage object
|
|
191
|
+
const usage = event.usage ?? event;
|
|
192
|
+
return usageToAiSdk(usage);
|
|
193
|
+
}
|
|
194
|
+
export function anthropicUsageToAiSdkUsage(event) {
|
|
195
|
+
const usage = event.usage ?? event;
|
|
196
|
+
return usageToAiSdk(usage);
|
|
197
|
+
}
|
|
198
|
+
// --- Shared stream-part constructors ---
|
|
199
|
+
// Single construction surface for the parts both the stateless mappers and
|
|
200
|
+
// the per-stream stateful parsers emit (issue #55 kept tool-call completion
|
|
201
|
+
// stateful; everything else stays shared).
|
|
202
|
+
function zeroedUsage() {
|
|
203
|
+
return {
|
|
204
|
+
inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
|
|
205
|
+
outputTokens: { total: 0, text: 0, reasoning: 0 },
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function finishPart(finishReason, usage) {
|
|
209
|
+
return {
|
|
210
|
+
type: "finish",
|
|
211
|
+
finishReason: finishReason ?? { unified: "stop", raw: "stop" },
|
|
212
|
+
usage: usage ?? zeroedUsage(),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function textDeltaPart(id, delta) {
|
|
216
|
+
return { type: "text-delta", id: stringValue(id) ?? "text", delta };
|
|
217
|
+
}
|
|
218
|
+
function reasoningDeltaPart(id, delta) {
|
|
219
|
+
return { type: "reasoning-delta", id: stringValue(id) ?? "reasoning", delta };
|
|
220
|
+
}
|
|
221
|
+
// --- OpenAI Chat Completions streaming ---
|
|
222
|
+
export function openAIEventToStreamPart(event) {
|
|
223
|
+
if (!isRecord(event))
|
|
224
|
+
return [];
|
|
225
|
+
// Error handling — OpenAI errors have { error: { message, type, code } } or top-level error
|
|
226
|
+
if (event.error !== undefined) {
|
|
227
|
+
const message = commandCodeErrorMessage(event.error) ??
|
|
228
|
+
commandCodeErrorMessage(event) ??
|
|
229
|
+
"Provider stream error";
|
|
230
|
+
throw new Error(redactCommandCodeErrorText(message));
|
|
231
|
+
}
|
|
232
|
+
if (stringValue(event.type) === "error") {
|
|
233
|
+
const message = commandCodeErrorMessage(event.error) ??
|
|
234
|
+
commandCodeErrorMessage(event.message) ??
|
|
235
|
+
"Provider stream error";
|
|
236
|
+
throw new Error(redactCommandCodeErrorText(message));
|
|
237
|
+
}
|
|
238
|
+
// Extract usage if present (terminal chunk)
|
|
239
|
+
const rawUsage = event.usage;
|
|
240
|
+
const hasUsage = rawUsage !== undefined && rawUsage !== null;
|
|
241
|
+
const usage = hasUsage ? usageToAiSdk(rawUsage) : undefined;
|
|
242
|
+
// Determine finish reason
|
|
243
|
+
const choice = firstChoice(event);
|
|
244
|
+
const finishReasonRaw = stringValue(choice?.finish_reason) ??
|
|
245
|
+
stringValue(choice?.finishReason) ??
|
|
246
|
+
stringValue(event.finish_reason) ??
|
|
247
|
+
stringValue(event.finishReason);
|
|
248
|
+
const finishReason = finishReasonRaw ? mapFinishReason(finishReasonRaw) : undefined;
|
|
249
|
+
const parts = [];
|
|
250
|
+
// Text delta
|
|
251
|
+
const delta = choice ? deltaFromChoice(choice) : undefined;
|
|
252
|
+
if (delta) {
|
|
253
|
+
const content = stringValue(delta.content);
|
|
254
|
+
if (typeof content === "string" && content.length > 0) {
|
|
255
|
+
parts.push(textDeltaPart(event.id, content));
|
|
256
|
+
}
|
|
257
|
+
// Tool calls streaming — map to tool-input deltas
|
|
258
|
+
const toolCalls = delta.tool_calls ?? delta.toolCalls;
|
|
259
|
+
if (Array.isArray(toolCalls)) {
|
|
260
|
+
for (const tc of toolCalls) {
|
|
261
|
+
if (!isRecord(tc))
|
|
262
|
+
continue;
|
|
263
|
+
const id = stringValue(tc.id) ?? "";
|
|
264
|
+
const fn = isRecord(tc.function) ? tc.function : {};
|
|
265
|
+
const name = stringValue(fn.name) ?? stringValue(tc.name) ?? "";
|
|
266
|
+
const args = stringValue(fn.arguments) ?? "";
|
|
267
|
+
if (id || name || args) {
|
|
268
|
+
if (name)
|
|
269
|
+
parts.push({ type: "tool-input-start", id: id || name, toolName: name });
|
|
270
|
+
if (args)
|
|
271
|
+
parts.push({ type: "tool-input-delta", id: id || name, delta: args });
|
|
272
|
+
// Only emit end+call when we have a complete tool call (id + name + non-empty args that looks like JSON)
|
|
273
|
+
if (id && name && args) {
|
|
274
|
+
// Try to avoid emitting malformed fragments as complete calls
|
|
275
|
+
try {
|
|
276
|
+
JSON.parse(args);
|
|
277
|
+
parts.push({ type: "tool-input-end", id });
|
|
278
|
+
parts.push({ type: "tool-call", toolCallId: id, toolName: name, input: args });
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// Fragment — wait for final delta to emit call; just keep delta
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
// Reasoning delta (OpenAI reasoning)
|
|
288
|
+
const reasoning = stringValue(delta.reasoning) ?? stringValue(delta.reasoning_content);
|
|
289
|
+
if (typeof reasoning === "string" && reasoning.length > 0) {
|
|
290
|
+
parts.push(reasoningDeltaPart(event.id, reasoning));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// If this chunk carries finish reason or usage, emit finish
|
|
294
|
+
if (finishReason || hasUsage) {
|
|
295
|
+
const finalUsage = usage ?? (hasUsage ? zeroedUsage() : undefined);
|
|
296
|
+
// Only emit finish if we have usage or explicit finish reason indicating completion
|
|
297
|
+
if (finalUsage || finishReason) {
|
|
298
|
+
parts.push(finishPart(finishReason, finalUsage));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return parts;
|
|
302
|
+
}
|
|
303
|
+
// --- Anthropic Messages streaming ---
|
|
304
|
+
export function anthropicEventToStreamPart(event) {
|
|
305
|
+
if (!isRecord(event))
|
|
306
|
+
return [];
|
|
307
|
+
const type = stringValue(event.type);
|
|
308
|
+
if (type === "error" || event.error !== undefined) {
|
|
309
|
+
const message = commandCodeErrorMessage(event.error) ??
|
|
310
|
+
commandCodeErrorMessage(event.message) ??
|
|
311
|
+
"Provider stream error";
|
|
312
|
+
throw new Error(redactCommandCodeErrorText(message));
|
|
313
|
+
}
|
|
314
|
+
// Content delta: { type: "content_block_delta", delta: { type: "text_delta", text: "..." } }
|
|
315
|
+
if (type === "content_block_delta") {
|
|
316
|
+
const delta = asRecord(event.delta);
|
|
317
|
+
const text = stringValue(delta?.text);
|
|
318
|
+
if (typeof text === "string" && text.length > 0) {
|
|
319
|
+
const index = numberValue(event.index) ?? 0;
|
|
320
|
+
const id = `text-${index}`;
|
|
321
|
+
return [{ type: "text-delta", id, delta: text }];
|
|
322
|
+
}
|
|
323
|
+
// Tool input delta: { type: "input_json_delta", partial_json: "..." }
|
|
324
|
+
const partial = stringValue(delta?.partial_json);
|
|
325
|
+
if (typeof partial === "string" && partial.length > 0) {
|
|
326
|
+
const index = numberValue(event.index) ?? 0;
|
|
327
|
+
const id = `tool-${index}`;
|
|
328
|
+
// Emit as tool-input-delta; caller may have started tool
|
|
329
|
+
return [{ type: "tool-input-delta", id, delta: partial }];
|
|
330
|
+
}
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
if (type === "content_block_start") {
|
|
334
|
+
const block = asRecord(event.content_block);
|
|
335
|
+
const blockType = stringValue(block?.type);
|
|
336
|
+
const index = numberValue(event.index) ?? 0;
|
|
337
|
+
if (blockType === "text") {
|
|
338
|
+
const id = `text-${index}`;
|
|
339
|
+
return [{ type: "text-start", id }];
|
|
340
|
+
}
|
|
341
|
+
if (blockType === "tool_use") {
|
|
342
|
+
const id = stringValue(block?.id) ?? `tool-${index}`;
|
|
343
|
+
const name = stringValue(block?.name) ?? "";
|
|
344
|
+
return [{ type: "tool-input-start", id, toolName: name }];
|
|
345
|
+
}
|
|
346
|
+
return [];
|
|
347
|
+
}
|
|
348
|
+
if (type === "content_block_stop") {
|
|
349
|
+
// Stateless codec: the STOP event carries only `index`, not the block type.
|
|
350
|
+
// Anthropic streams either `text` or `tool_use` blocks; the AI SDK expects
|
|
351
|
+
// `text-end` for the former and `tool-input-end` for the latter. Emitting
|
|
352
|
+
// both is noisy (previous emitted two parts) but guarantees the right end
|
|
353
|
+
// is seen regardless of block type, and the consumer ignores the spurious
|
|
354
|
+
// one per AI SDK spec. Emit a single text-end here would break tool_use
|
|
355
|
+
// streams that rely on tool-input-end, so we keep the conservative pair
|
|
356
|
+
// with an explicit note referencing provider doc streaming (message_delta
|
|
357
|
+
// carries the final usage/finish; per-block ends are AI SDK concerns).
|
|
358
|
+
const index = numberValue(event.index) ?? 0;
|
|
359
|
+
const idText = `text-${index}`;
|
|
360
|
+
const idTool = `tool-${index}`;
|
|
361
|
+
return [
|
|
362
|
+
{ type: "text-end", id: idText },
|
|
363
|
+
{ type: "tool-input-end", id: idTool },
|
|
364
|
+
];
|
|
365
|
+
}
|
|
366
|
+
// Terminal message_delta: { type: "message_delta", delta: { stop_reason }, usage: { ... } }
|
|
367
|
+
if (type === "message_delta") {
|
|
368
|
+
const delta = asRecord(event.delta);
|
|
369
|
+
const stopReason = stringValue(delta?.stop_reason) ?? stringValue(delta?.stopReason) ?? "stop";
|
|
370
|
+
return [finishPart(mapFinishReason(stopReason), usageToAiSdk(event.usage))];
|
|
371
|
+
}
|
|
372
|
+
// Alternative terminal: { type: "message_stop" } without usage — emit generic finish
|
|
373
|
+
if (type === "message_stop") {
|
|
374
|
+
return [finishPart(undefined, undefined)];
|
|
375
|
+
}
|
|
376
|
+
// Ping/heartbeat or other known non-content types
|
|
377
|
+
if (type === "ping" || type === "message_start")
|
|
378
|
+
return [];
|
|
379
|
+
// Fallback: check for usage at top level without type (some providers send final usage as top-level)
|
|
380
|
+
if (event.usage !== undefined) {
|
|
381
|
+
const usage = usageToAiSdk(event.usage);
|
|
382
|
+
if (usage) {
|
|
383
|
+
return [
|
|
384
|
+
finishPart(mapFinishReason(stringValue(event.finish_reason) ?? "stop"), usage),
|
|
385
|
+
];
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return [];
|
|
389
|
+
}
|
|
390
|
+
export function createOpenAIStreamParser() {
|
|
391
|
+
const toolBuffers = new Map();
|
|
392
|
+
let nextIndex = 0;
|
|
393
|
+
// OpenAI streams finish_reason on the last content chunk, then the real
|
|
394
|
+
// usage on a separate trailing usage-only chunk (choices:[]). Remember the
|
|
395
|
+
// finish_reason here so the usage-only chunk's finish keeps the real reason
|
|
396
|
+
// (e.g. "length") instead of defaulting to "stop".
|
|
397
|
+
let lastFinishReason;
|
|
398
|
+
return (event) => {
|
|
399
|
+
if (!isRecord(event))
|
|
400
|
+
return [];
|
|
401
|
+
// Error events flow through the stateless mapper (redacted throw).
|
|
402
|
+
if (event.error !== undefined || stringValue(event.type) === "error") {
|
|
403
|
+
return openAIEventToStreamPart(event);
|
|
404
|
+
}
|
|
405
|
+
const parts = [];
|
|
406
|
+
const choice = firstChoice(event);
|
|
407
|
+
const delta = choice ? deltaFromChoice(choice) : undefined;
|
|
408
|
+
if (delta) {
|
|
409
|
+
const content = stringValue(delta.content);
|
|
410
|
+
if (typeof content === "string" && content.length > 0) {
|
|
411
|
+
parts.push(textDeltaPart(event.id, content));
|
|
412
|
+
}
|
|
413
|
+
const reasoning = stringValue(delta.reasoning) ?? stringValue(delta.reasoning_content);
|
|
414
|
+
if (typeof reasoning === "string" && reasoning.length > 0) {
|
|
415
|
+
parts.push(reasoningDeltaPart(event.id, reasoning));
|
|
416
|
+
}
|
|
417
|
+
const toolCalls = delta.tool_calls ?? delta.toolCalls;
|
|
418
|
+
if (Array.isArray(toolCalls)) {
|
|
419
|
+
for (const tc of toolCalls) {
|
|
420
|
+
if (!isRecord(tc))
|
|
421
|
+
continue;
|
|
422
|
+
const fn = isRecord(tc.function) ? tc.function : {};
|
|
423
|
+
const name = stringValue(fn.name) ?? stringValue(tc.name) ?? "";
|
|
424
|
+
const args = stringValue(fn.arguments) ?? "";
|
|
425
|
+
const id = stringValue(tc.id) ?? "";
|
|
426
|
+
let index = numberValue(tc.index);
|
|
427
|
+
if (index === undefined) {
|
|
428
|
+
// Fragments usually carry `index`; when absent, continue the most
|
|
429
|
+
// recent tool call (OpenAI includes id+name only on the first chunk).
|
|
430
|
+
index = toolBuffers.size > 0 ? Math.max(...toolBuffers.keys()) : nextIndex++;
|
|
431
|
+
}
|
|
432
|
+
let buffer = toolBuffers.get(index);
|
|
433
|
+
if (!buffer) {
|
|
434
|
+
buffer = { id: id || `tool-${index}`, name, input: "", started: false, emitted: false };
|
|
435
|
+
toolBuffers.set(index, buffer);
|
|
436
|
+
}
|
|
437
|
+
if (id)
|
|
438
|
+
buffer.id = id;
|
|
439
|
+
if (name)
|
|
440
|
+
buffer.name = name;
|
|
441
|
+
if (!buffer.started && buffer.name) {
|
|
442
|
+
buffer.started = true;
|
|
443
|
+
parts.push({ type: "tool-input-start", id: buffer.id, toolName: buffer.name });
|
|
444
|
+
}
|
|
445
|
+
if (args && !buffer.emitted) {
|
|
446
|
+
buffer.input += args;
|
|
447
|
+
parts.push({ type: "tool-input-delta", id: buffer.id, delta: args });
|
|
448
|
+
// Complete as soon as the accumulated arguments parse as JSON; the
|
|
449
|
+
// finish chunk below flushes anything that never completes.
|
|
450
|
+
try {
|
|
451
|
+
JSON.parse(buffer.input);
|
|
452
|
+
buffer.emitted = true;
|
|
453
|
+
parts.push({ type: "tool-input-end", id: buffer.id });
|
|
454
|
+
parts.push({
|
|
455
|
+
type: "tool-call",
|
|
456
|
+
toolCallId: buffer.id,
|
|
457
|
+
toolName: buffer.name,
|
|
458
|
+
input: buffer.input,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
// fragment — keep accumulating
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const rawUsage = event.usage;
|
|
469
|
+
const hasUsage = rawUsage !== undefined && rawUsage !== null;
|
|
470
|
+
const finishReasonRaw = stringValue(choice?.finish_reason) ??
|
|
471
|
+
stringValue(choice?.finishReason) ??
|
|
472
|
+
stringValue(event.finish_reason) ??
|
|
473
|
+
stringValue(event.finishReason);
|
|
474
|
+
const finishReason = finishReasonRaw ? mapFinishReason(finishReasonRaw) : undefined;
|
|
475
|
+
if (finishReason)
|
|
476
|
+
lastFinishReason = finishReason;
|
|
477
|
+
if (lastFinishReason || hasUsage) {
|
|
478
|
+
// Flush any tool call whose terminal args chunk never arrived.
|
|
479
|
+
for (const [index, buffer] of toolBuffers) {
|
|
480
|
+
if (buffer.started && !buffer.emitted) {
|
|
481
|
+
parts.push({ type: "tool-input-end", id: buffer.id });
|
|
482
|
+
parts.push({
|
|
483
|
+
type: "tool-call",
|
|
484
|
+
toolCallId: buffer.id,
|
|
485
|
+
toolName: buffer.name,
|
|
486
|
+
input: buffer.input,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
toolBuffers.delete(index);
|
|
490
|
+
}
|
|
491
|
+
// The usage-only trailing chunk carries no finish_reason; reuse the one
|
|
492
|
+
// captured from the finish_reason chunk so the real reason survives.
|
|
493
|
+
const reason = finishReason ?? lastFinishReason;
|
|
494
|
+
parts.push(finishPart(reason, hasUsage ? usageToAiSdk(rawUsage) : undefined));
|
|
495
|
+
}
|
|
496
|
+
return parts;
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
export function createAnthropicStreamParser() {
|
|
500
|
+
const toolBlocks = new Map();
|
|
501
|
+
return (event) => {
|
|
502
|
+
if (!isRecord(event))
|
|
503
|
+
return [];
|
|
504
|
+
const type = stringValue(event.type);
|
|
505
|
+
if (type === "content_block_start") {
|
|
506
|
+
const block = asRecord(event.content_block);
|
|
507
|
+
const index = numberValue(event.index) ?? 0;
|
|
508
|
+
const blockType = stringValue(block?.type);
|
|
509
|
+
if (blockType === "tool_use") {
|
|
510
|
+
const id = stringValue(block?.id) ?? `tool-${index}`;
|
|
511
|
+
const name = stringValue(block?.name) ?? "";
|
|
512
|
+
toolBlocks.set(index, { id, name, input: "", started: true, emitted: false });
|
|
513
|
+
return [{ type: "tool-input-start", id, toolName: name }];
|
|
514
|
+
}
|
|
515
|
+
if (blockType === "text")
|
|
516
|
+
return [{ type: "text-start", id: `text-${index}` }];
|
|
517
|
+
return [];
|
|
518
|
+
}
|
|
519
|
+
if (type === "content_block_delta") {
|
|
520
|
+
const delta = asRecord(event.delta);
|
|
521
|
+
const index = numberValue(event.index) ?? 0;
|
|
522
|
+
const text = stringValue(delta?.text);
|
|
523
|
+
if (typeof text === "string" && text.length > 0) {
|
|
524
|
+
return [{ type: "text-delta", id: `text-${index}`, delta: text }];
|
|
525
|
+
}
|
|
526
|
+
const partial = stringValue(delta?.partial_json);
|
|
527
|
+
if (typeof partial === "string" && partial.length > 0) {
|
|
528
|
+
const block = toolBlocks.get(index);
|
|
529
|
+
if (block) {
|
|
530
|
+
if (block.emitted)
|
|
531
|
+
return [];
|
|
532
|
+
block.input += partial;
|
|
533
|
+
const out = [
|
|
534
|
+
{ type: "tool-input-delta", id: block.id, delta: partial },
|
|
535
|
+
];
|
|
536
|
+
// Complete early when a single delta already carries valid JSON;
|
|
537
|
+
// content_block_stop below flushes multi-delta accumulation.
|
|
538
|
+
try {
|
|
539
|
+
JSON.parse(block.input);
|
|
540
|
+
block.emitted = true;
|
|
541
|
+
out.push({ type: "tool-input-end", id: block.id });
|
|
542
|
+
out.push({
|
|
543
|
+
type: "tool-call",
|
|
544
|
+
toolCallId: block.id,
|
|
545
|
+
toolName: block.name,
|
|
546
|
+
input: block.input,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
// fragment — keep accumulating until stop
|
|
551
|
+
}
|
|
552
|
+
return out;
|
|
553
|
+
}
|
|
554
|
+
return [{ type: "tool-input-delta", id: `tool-${index}`, delta: partial }];
|
|
555
|
+
}
|
|
556
|
+
return [];
|
|
557
|
+
}
|
|
558
|
+
if (type === "content_block_stop") {
|
|
559
|
+
const index = numberValue(event.index) ?? 0;
|
|
560
|
+
const block = toolBlocks.get(index);
|
|
561
|
+
if (block) {
|
|
562
|
+
toolBlocks.delete(index);
|
|
563
|
+
if (block.emitted)
|
|
564
|
+
return [];
|
|
565
|
+
return [
|
|
566
|
+
{ type: "tool-input-end", id: block.id },
|
|
567
|
+
{
|
|
568
|
+
type: "tool-call",
|
|
569
|
+
toolCallId: block.id,
|
|
570
|
+
toolName: block.name,
|
|
571
|
+
input: block.input,
|
|
572
|
+
},
|
|
573
|
+
];
|
|
574
|
+
}
|
|
575
|
+
return [{ type: "text-end", id: `text-${index}` }];
|
|
576
|
+
}
|
|
577
|
+
// Everything else (message_delta, message_stop, ping, error, …) shares the
|
|
578
|
+
// stateless mapper's handling.
|
|
579
|
+
return anthropicEventToStreamPart(event);
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
// Canonical stream entry points: openAIEventToStreamPart / anthropicEventToStreamPart
|
|
583
|
+
// and the per-stream stateful parsers createOpenAIStreamParser / createAnthropicStreamParser.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-cmd-provider",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Command Code provider + plugin for opencode",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"refresh:deals": "node scripts/refresh-deals.mjs",
|
|
23
23
|
"refresh": "npm run refresh:snapshot && npm run refresh:deals -- --fixtures",
|
|
24
24
|
"test": "npm run typecheck && npm run test:unit && npm run test:integration && npm run test:contract && npm run format:check",
|
|
25
|
-
"test:unit": "tsx tests/env.test.ts && tsx tests/auth-key.test.ts && tsx tests/converters.test.ts && tsx tests/stream.test.ts && tsx tests/redact.test.ts && tsx tests/cost.test.ts && tsx tests/retry.test.ts && tsx tests/reasoning.test.ts && tsx tests/modalities.test.ts && tsx tests/snapshot.test.ts && tsx tests/refresh-snapshot.test.ts && tsx tests/oauth.test.ts && tsx tests/parse-facts.test.ts && tsx tests/parse-modalities.test.ts && tsx tests/catalog-metadata.test.ts && tsx tests/plugin-models.test.ts && tsx tests/vendor.test.ts && tsx tests/deals-enrichment.test.ts && tsx tests/plan-summary.test.ts && tsx tests/html-tables.test.ts && tsx tests/parse-docs.test.ts && tsx tests/refresh-deals.test.ts && tsx tests/tui-deals-panel.test.ts && tsx tests/plugin-install.test.ts",
|
|
25
|
+
"test:unit": "tsx tests/env.test.ts && tsx tests/auth-key.test.ts && tsx tests/converters.test.ts && tsx tests/stream.test.ts && tsx tests/provider-codecs.test.ts && tsx tests/provider-transport.test.ts && tsx tests/provider-parity.test.ts && tsx tests/provider-upgrade-fallback.test.ts && tsx tests/provider-zdr.test.ts && tsx tests/redact.test.ts && tsx tests/cost.test.ts && tsx tests/retry.test.ts && tsx tests/reasoning.test.ts && tsx tests/modalities.test.ts && tsx tests/snapshot.test.ts && tsx tests/refresh-snapshot.test.ts && tsx tests/oauth.test.ts && tsx tests/parse-facts.test.ts && tsx tests/parse-modalities.test.ts && tsx tests/catalog-metadata.test.ts && tsx tests/plugin-models.test.ts && tsx tests/vendor.test.ts && tsx tests/deals-enrichment.test.ts && tsx tests/deals-coverage.test.ts && tsx tests/plan-summary.test.ts && tsx tests/html-tables.test.ts && tsx tests/parse-docs.test.ts && tsx tests/refresh-deals.test.ts && tsx tests/tui-deals-panel.test.ts && tsx tests/plugin-install.test.ts",
|
|
26
26
|
"test:integration": "tsx tests/integration-do-stream.test.ts && tsx tests/integration-do-generate.test.ts",
|
|
27
27
|
"test:contract": "tsx tests/contract.test.ts",
|
|
28
28
|
"test:e2e": "node tests/e2e-opencode.mjs",
|