tina4-nodejs 3.13.111 → 3.13.113
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/CLAUDE.md +2 -2
- package/package.json +1 -1
- package/packages/cli/dist/bin.js +697 -198
- package/packages/core/dist/index.js +700 -198
- package/packages/core/src/aiClient.ts +368 -48
- package/packages/core/src/api.ts +269 -0
- package/packages/core/src/devAdmin.ts +181 -145
- package/packages/core/src/index.ts +3 -3
- package/packages/core/src/server.ts +6 -0
- package/packages/orm/dist/index.js +697 -198
- package/types/core/src/aiClient.d.ts +74 -5
- package/types/core/src/api.d.ts +102 -0
- package/types/core/src/devAdmin.d.ts +1 -0
- package/types/core/src/index.d.ts +3 -3
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
/** Zero-dependency app-facing AI client (ADR-0053). */
|
|
1
|
+
/** Zero-dependency app-facing AI client (ADR-0053 + ADR-0060). */
|
|
2
2
|
import http, { type IncomingMessage } from "node:http";
|
|
3
3
|
import https from "node:https";
|
|
4
|
+
import { parseLineStream, parseSseStream, type SseEvent } from "./api.js";
|
|
4
5
|
|
|
5
6
|
export class AiError extends Error {}
|
|
6
7
|
export class AiConfigError extends AiError {}
|
|
@@ -17,7 +18,38 @@ export interface ChatResponse {
|
|
|
17
18
|
finishReason: string | null;
|
|
18
19
|
raw: Record<string, unknown>;
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A multimodal content part. `text` carries plain UTF-8 prose; `image`
|
|
24
|
+
* carries a `data:<media_type>;base64,<payload>` URI or an https:// URL
|
|
25
|
+
* (the client translates to each provider's shape). ADR-0060.
|
|
26
|
+
*/
|
|
27
|
+
export type ContentPart =
|
|
28
|
+
| { type: "text"; text: string }
|
|
29
|
+
| { type: "image"; source: string };
|
|
30
|
+
|
|
31
|
+
/** The value a caller may pass for `message.content`. ADR-0060. */
|
|
32
|
+
export type AiMessageContent = string | ContentPart[];
|
|
33
|
+
|
|
34
|
+
export interface AiMessage { role: "system" | "user" | "assistant"; content: AiMessageContent }
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One event yielded by `Ai.chat(stream: true)`. The four variants
|
|
38
|
+
* discriminated by `type`. Text deltas arrive per chunk (typewriter UX);
|
|
39
|
+
* `tool_call` fires once per call, aggregated from provider fragments;
|
|
40
|
+
* `done` fires exactly once after all deltas; `error` replaces `done` on
|
|
41
|
+
* mid-stream failure. ADR-0060.
|
|
42
|
+
*/
|
|
43
|
+
export type AiEvent =
|
|
44
|
+
| { type: "text_delta"; text: string }
|
|
45
|
+
| { type: "tool_call"; id: string; name: string; args: Record<string, unknown> }
|
|
46
|
+
| {
|
|
47
|
+
type: "done";
|
|
48
|
+
finishReason: string;
|
|
49
|
+
usage?: { promptTokens: number; completionTokens: number; totalTokens: number };
|
|
50
|
+
}
|
|
51
|
+
| { type: "error"; message: string; code?: string };
|
|
52
|
+
|
|
21
53
|
export interface AiChatOptions {
|
|
22
54
|
model?: string;
|
|
23
55
|
temperature?: number;
|
|
@@ -40,9 +72,9 @@ interface Config {
|
|
|
40
72
|
interface OpenResponse { response: IncomingMessage; cleanup: () => void }
|
|
41
73
|
|
|
42
74
|
export class Ai {
|
|
43
|
-
static chat(messages: AiMessage[], options: AiChatOptions & { stream: true }): AsyncGenerator<
|
|
75
|
+
static chat(messages: AiMessage[], options: AiChatOptions & { stream: true }): AsyncGenerator<AiEvent>;
|
|
44
76
|
static chat(messages: AiMessage[], options?: AiChatOptions & { stream?: false }): Promise<ChatResponse>;
|
|
45
|
-
static chat(messages: AiMessage[], options: AiChatOptions = {}): Promise<ChatResponse> | AsyncGenerator<
|
|
77
|
+
static chat(messages: AiMessage[], options: AiChatOptions = {}): Promise<ChatResponse> | AsyncGenerator<AiEvent> {
|
|
46
78
|
this.validateMessages(messages);
|
|
47
79
|
const config = this.config("chat", options);
|
|
48
80
|
const body = this.chatBody(config, messages, options);
|
|
@@ -74,10 +106,52 @@ export class Ai {
|
|
|
74
106
|
}
|
|
75
107
|
}
|
|
76
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Validate role + content shape. Content may be a string OR a non-empty
|
|
111
|
+
* list of {type:'text'|'image', ...} parts (ADR-0060). Malformed parts
|
|
112
|
+
* fail fast with AiConfigError, never reaching the wire.
|
|
113
|
+
*/
|
|
77
114
|
private static validateMessages(messages: AiMessage[]): void {
|
|
78
|
-
if (!Array.isArray(messages) || messages.length === 0
|
|
115
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
79
116
|
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
80
117
|
}
|
|
118
|
+
for (const message of messages) {
|
|
119
|
+
if (!message || !["system", "user", "assistant"].includes(message.role)) {
|
|
120
|
+
throw new AiConfigError("AI messages must contain supported roles and string content");
|
|
121
|
+
}
|
|
122
|
+
this.validateContent(message.content);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private static validateContent(content: unknown): void {
|
|
127
|
+
if (typeof content === "string") return;
|
|
128
|
+
if (!Array.isArray(content) || content.length === 0) {
|
|
129
|
+
throw new AiConfigError("AI message content must be a string or a non-empty list of parts");
|
|
130
|
+
}
|
|
131
|
+
for (const part of content) {
|
|
132
|
+
if (!part || typeof part !== "object" || Array.isArray(part)) {
|
|
133
|
+
throw new AiConfigError("AI content part must be an object with type and text/source");
|
|
134
|
+
}
|
|
135
|
+
const record = part as Record<string, unknown>;
|
|
136
|
+
const partType = record.type;
|
|
137
|
+
if (partType === "text") {
|
|
138
|
+
if (typeof record.text !== "string") {
|
|
139
|
+
throw new AiConfigError("AI text content part requires a string 'text' field");
|
|
140
|
+
}
|
|
141
|
+
} else if (partType === "image") {
|
|
142
|
+
if (typeof record.source !== "string" || record.source.length === 0) {
|
|
143
|
+
throw new AiConfigError("AI image content part requires a non-empty string 'source' field");
|
|
144
|
+
}
|
|
145
|
+
if (!record.source.startsWith("data:") && !record.source.startsWith("https://")) {
|
|
146
|
+
throw new AiConfigError("AI image source must be a data: URI or an https:// URL");
|
|
147
|
+
}
|
|
148
|
+
if (record.source.startsWith("data:") && !/^data:[^;,\s]+;base64,[A-Za-z0-9+/=]+$/.test(record.source)) {
|
|
149
|
+
throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
throw new AiConfigError(`AI content part has unknown type '${String(partType)}'`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
81
155
|
}
|
|
82
156
|
|
|
83
157
|
private static number(name: string, fallback: number, minimum: number): number {
|
|
@@ -123,19 +197,64 @@ export class Ai {
|
|
|
123
197
|
return headers;
|
|
124
198
|
}
|
|
125
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Build the provider-specific request body from a Tina4-shaped message
|
|
202
|
+
* list. Multimodal parts are translated per provider (ADR-0060):
|
|
203
|
+
* - OpenAI/local: {type:'image_url', image_url:{url}}
|
|
204
|
+
* - Anthropic: {type:'image', source:{type:'base64'|'url', ...}}
|
|
205
|
+
* String content is preserved verbatim in the OpenAI/local shape and
|
|
206
|
+
* likewise for Anthropic (both accept a bare string).
|
|
207
|
+
*/
|
|
126
208
|
private static chatBody(config: Config, messages: AiMessage[], options: AiChatOptions): Record<string, unknown> {
|
|
127
|
-
const
|
|
209
|
+
const translate = (list: AiMessage[]): Array<Record<string, unknown>> =>
|
|
210
|
+
list.map((message) => ({ role: message.role, content: this.translateContent(message.content, config.provider) }));
|
|
211
|
+
const body: Record<string, unknown> = { model: config.model, messages: translate(messages), stream: options.stream ?? false };
|
|
128
212
|
if (options.temperature !== undefined) body.temperature = options.temperature;
|
|
129
213
|
if (options.maxTokens !== undefined) body.max_tokens = options.maxTokens;
|
|
130
214
|
if (config.provider === "anthropic") {
|
|
131
|
-
const
|
|
132
|
-
|
|
215
|
+
const systemParts = messages
|
|
216
|
+
.filter((message) => message.role === "system")
|
|
217
|
+
.map((message) => (typeof message.content === "string" ? message.content : this.contentToPlainText(message.content)));
|
|
218
|
+
body.messages = translate(messages.filter((message) => message.role !== "system"));
|
|
133
219
|
body.max_tokens = options.maxTokens ?? 1024;
|
|
134
|
-
if (
|
|
220
|
+
if (systemParts.length) body.system = systemParts.join("\n\n");
|
|
135
221
|
}
|
|
136
222
|
return body;
|
|
137
223
|
}
|
|
138
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Translate one message content value into the provider's on-wire shape.
|
|
227
|
+
* A plain string is passed through (both providers accept a string
|
|
228
|
+
* content). A parts array becomes provider-native content blocks.
|
|
229
|
+
*/
|
|
230
|
+
private static translateContent(content: AiMessageContent, provider: Config["provider"]): unknown {
|
|
231
|
+
if (typeof content === "string") return content;
|
|
232
|
+
if (provider === "anthropic") {
|
|
233
|
+
return content.map((part) => {
|
|
234
|
+
if (part.type === "text") return { type: "text", text: part.text };
|
|
235
|
+
if (part.source.startsWith("data:")) {
|
|
236
|
+
const parsed = this.parseDataUri(part.source);
|
|
237
|
+
return { type: "image", source: { type: "base64", media_type: parsed.mediaType, data: parsed.data } };
|
|
238
|
+
}
|
|
239
|
+
return { type: "image", source: { type: "url", url: part.source } };
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return content.map((part) => {
|
|
243
|
+
if (part.type === "text") return { type: "text", text: part.text };
|
|
244
|
+
return { type: "image_url", image_url: { url: part.source } };
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private static parseDataUri(source: string): { mediaType: string; data: string } {
|
|
249
|
+
const match = /^data:([^;,\s]+);base64,([A-Za-z0-9+/=]+)$/.exec(source);
|
|
250
|
+
if (!match) throw new AiConfigError("AI image data URI must be data:<media_type>;base64,<payload>");
|
|
251
|
+
return { mediaType: match[1], data: match[2] };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private static contentToPlainText(parts: ContentPart[]): string {
|
|
255
|
+
return parts.filter((part): part is { type: "text"; text: string } => part.type === "text").map((part) => part.text).join("\n\n");
|
|
256
|
+
}
|
|
257
|
+
|
|
139
258
|
private static open(config: Config, deadline: number, headers: Record<string, string>, body: Record<string, unknown>): Promise<OpenResponse> {
|
|
140
259
|
const remainingMs = deadline - performance.now();
|
|
141
260
|
if (remainingMs <= 0) return Promise.reject(new AiTimeoutError("AI total request timeout expired"));
|
|
@@ -226,36 +345,13 @@ export class Ai {
|
|
|
226
345
|
return this.normalizeChat(config.provider, await this.requestJson(config, headers, body));
|
|
227
346
|
}
|
|
228
347
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (text !== undefined && text !== null && typeof text !== "string") throw new AiParseError("AI provider returned malformed stream data");
|
|
237
|
-
return { completed: false, text: text as string | undefined };
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
private static async *streamData(response: IncomingMessage): AsyncGenerator<string> {
|
|
241
|
-
let buffer = "";
|
|
242
|
-
for await (const chunk of response) {
|
|
243
|
-
buffer += Buffer.from(chunk).toString("utf8");
|
|
244
|
-
let newline: number;
|
|
245
|
-
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
246
|
-
const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1);
|
|
247
|
-
if (line.startsWith("data:")) yield line.slice(5).trim();
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
private static streamError(error: unknown): AiError {
|
|
253
|
-
if (error instanceof AiError) return error;
|
|
254
|
-
if (error instanceof Error && error.name === "AbortError") return new AiTimeoutError("AI total request timeout expired");
|
|
255
|
-
return new AiHTTPError(`AI transport failed (${error instanceof Error ? error.name : "Error"})`);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
private static async *streamRequest(config: Config, headers: Record<string, string>, body: Record<string, unknown>): AsyncGenerator<string> {
|
|
348
|
+
/**
|
|
349
|
+
* Stream the response through the shared {@link parseSseStream} framer
|
|
350
|
+
* (ADR-0060 rule 5). Translates each SSE data payload into 0..N
|
|
351
|
+
* {@link AiEvent}s: text_delta per chunk, tool_call aggregated per
|
|
352
|
+
* index / block, exactly one done (or error) at the end.
|
|
353
|
+
*/
|
|
354
|
+
private static async *streamRequest(config: Config, headers: Record<string, string>, body: Record<string, unknown>): AsyncGenerator<AiEvent> {
|
|
259
355
|
const deadline = performance.now() + config.totalTimeout * 1000;
|
|
260
356
|
let yielded = false;
|
|
261
357
|
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
|
|
@@ -268,21 +364,245 @@ export class Ai {
|
|
|
268
364
|
if ((status === 429 || status >= 500) && attempt < config.maxRetries) { await this.retryDelay(opened.response.headers, deadline); opened.cleanup(); opened = null; continue; }
|
|
269
365
|
throw new AiHTTPError(`AI provider returned HTTP ${status}`, status);
|
|
270
366
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
367
|
+
const response = opened.response;
|
|
368
|
+
const chunks = this.responseChunks(response);
|
|
369
|
+
const events = parseSseStream(parseLineStream(chunks));
|
|
370
|
+
const aggregator = new AggregateState(config.provider);
|
|
371
|
+
let done = false;
|
|
372
|
+
try {
|
|
373
|
+
for await (const sseEvent of events) {
|
|
374
|
+
for (const emitted of aggregator.consume(sseEvent)) {
|
|
375
|
+
yielded = true;
|
|
376
|
+
yield emitted;
|
|
377
|
+
if (emitted.type === "done" || emitted.type === "error") { done = true; break; }
|
|
378
|
+
}
|
|
379
|
+
if (done) break;
|
|
380
|
+
}
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (yielded) {
|
|
383
|
+
yielded = true;
|
|
384
|
+
yield { type: "error", message: error instanceof AiParseError ? "AI provider returned malformed stream data" : `AI transport failed (${error instanceof Error ? error.name : "Error"})` };
|
|
385
|
+
opened.cleanup(); opened = null;
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
throw error;
|
|
277
389
|
}
|
|
278
390
|
opened.cleanup(); opened = null;
|
|
279
|
-
if (
|
|
280
|
-
|
|
391
|
+
if (done) return;
|
|
392
|
+
// Stream ended without a terminator — treat as mid-stream failure.
|
|
393
|
+
if (yielded) { yield { type: "error", message: "AI provider stream ended before completion" }; return; }
|
|
394
|
+
throw new AiParseError("AI provider stream ended before completion");
|
|
281
395
|
} catch (error) {
|
|
282
396
|
opened?.cleanup();
|
|
283
397
|
const failure = this.streamError(error);
|
|
284
|
-
if (
|
|
398
|
+
if (yielded) {
|
|
399
|
+
yield { type: "error", message: failure.message };
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (failure instanceof AiParseError || (failure instanceof AiHTTPError && failure.status !== null) || attempt >= config.maxRetries) throw failure;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private static async *responseChunks(response: IncomingMessage): AsyncGenerator<Uint8Array> {
|
|
408
|
+
for await (const chunk of response) {
|
|
409
|
+
yield chunk as Uint8Array;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
private static streamError(error: unknown): AiError {
|
|
414
|
+
if (error instanceof AiError) return error;
|
|
415
|
+
if (error instanceof Error && error.name === "AbortError") return new AiTimeoutError("AI total request timeout expired");
|
|
416
|
+
return new AiHTTPError(`AI transport failed (${error instanceof Error ? error.name : "Error"})`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Per-stream aggregation state. Encapsulates the buffering rules for
|
|
422
|
+
* OpenAI-style `tool_calls[i].function.arguments` fragments and
|
|
423
|
+
* Anthropic-style `content_block_delta` + `input_json_delta` fragments.
|
|
424
|
+
* ADR-0060 "tool_call aggregated" invariant.
|
|
425
|
+
*/
|
|
426
|
+
class AggregateState {
|
|
427
|
+
private toolBuffers = new Map<string, { id: string; name: string; args: string }>();
|
|
428
|
+
private lastFinishReason: string | null = null;
|
|
429
|
+
private lastUsage: { promptTokens: number; completionTokens: number; totalTokens: number } | null = null;
|
|
430
|
+
private doneEmitted = false;
|
|
431
|
+
|
|
432
|
+
constructor(private readonly provider: Config["provider"]) {}
|
|
433
|
+
|
|
434
|
+
*consume(event: SseEvent): Iterable<AiEvent> {
|
|
435
|
+
const data = event.data;
|
|
436
|
+
if (data === "[DONE]") {
|
|
437
|
+
if (this.doneEmitted) return;
|
|
438
|
+
yield* this.flushRemainingToolCalls();
|
|
439
|
+
this.doneEmitted = true;
|
|
440
|
+
yield {
|
|
441
|
+
type: "done",
|
|
442
|
+
finishReason: this.lastFinishReason ?? "stop",
|
|
443
|
+
...(this.lastUsage ? { usage: this.lastUsage } : {}),
|
|
444
|
+
};
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
let payload: Record<string, unknown>;
|
|
448
|
+
try {
|
|
449
|
+
payload = JSON.parse(data) as Record<string, unknown>;
|
|
450
|
+
} catch {
|
|
451
|
+
throw new AiParseError("AI provider returned malformed stream data");
|
|
452
|
+
}
|
|
453
|
+
if (this.provider === "anthropic") {
|
|
454
|
+
yield* this.consumeAnthropic(payload);
|
|
455
|
+
} else {
|
|
456
|
+
yield* this.consumeOpenAi(payload);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private *consumeOpenAi(payload: Record<string, unknown>): Iterable<AiEvent> {
|
|
461
|
+
const choices = payload.choices as Array<Record<string, unknown>> | undefined;
|
|
462
|
+
if (!Array.isArray(choices) || choices.length === 0) return;
|
|
463
|
+
const choice = choices[0];
|
|
464
|
+
const delta = (choice.delta ?? {}) as Record<string, unknown>;
|
|
465
|
+
const content = delta.content;
|
|
466
|
+
if (typeof content === "string" && content.length > 0) {
|
|
467
|
+
yield { type: "text_delta", text: content };
|
|
468
|
+
}
|
|
469
|
+
const toolCalls = delta.tool_calls as Array<Record<string, unknown>> | undefined;
|
|
470
|
+
if (Array.isArray(toolCalls)) {
|
|
471
|
+
for (const call of toolCalls) {
|
|
472
|
+
const index = typeof call.index === "number" ? String(call.index) : String(this.toolBuffers.size);
|
|
473
|
+
const idFromCall = typeof call.id === "string" ? call.id : "";
|
|
474
|
+
const fn = (call.function ?? {}) as Record<string, unknown>;
|
|
475
|
+
const nameFromCall = typeof fn.name === "string" ? fn.name : "";
|
|
476
|
+
const argsFragment = typeof fn.arguments === "string" ? fn.arguments : "";
|
|
477
|
+
const existing = this.toolBuffers.get(index) ?? { id: "", name: "", args: "" };
|
|
478
|
+
if (idFromCall) existing.id = idFromCall;
|
|
479
|
+
if (nameFromCall) existing.name = nameFromCall;
|
|
480
|
+
existing.args += argsFragment;
|
|
481
|
+
this.toolBuffers.set(index, existing);
|
|
482
|
+
if (existing.name && existing.args) {
|
|
483
|
+
try {
|
|
484
|
+
const parsed = JSON.parse(existing.args) as unknown;
|
|
485
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
486
|
+
this.toolBuffers.delete(index);
|
|
487
|
+
yield { type: "tool_call", id: existing.id || `call_${index}`, name: existing.name, args: parsed as Record<string, unknown> };
|
|
488
|
+
}
|
|
489
|
+
} catch {
|
|
490
|
+
/* args not complete yet — keep buffering */
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) {
|
|
496
|
+
this.lastFinishReason = choice.finish_reason;
|
|
497
|
+
}
|
|
498
|
+
const usage = payload.usage as Record<string, unknown> | undefined;
|
|
499
|
+
if (usage && typeof usage === "object") {
|
|
500
|
+
const promptTokens = Number(usage.prompt_tokens ?? 0);
|
|
501
|
+
const completionTokens = Number(usage.completion_tokens ?? 0);
|
|
502
|
+
const totalTokens = Number(usage.total_tokens ?? promptTokens + completionTokens);
|
|
503
|
+
if (Number.isFinite(promptTokens) && Number.isFinite(completionTokens)) {
|
|
504
|
+
this.lastUsage = { promptTokens, completionTokens, totalTokens };
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private *consumeAnthropic(payload: Record<string, unknown>): Iterable<AiEvent> {
|
|
510
|
+
const type = payload.type;
|
|
511
|
+
if (type === "content_block_start") {
|
|
512
|
+
const block = (payload.content_block ?? {}) as Record<string, unknown>;
|
|
513
|
+
if (block.type === "tool_use") {
|
|
514
|
+
const index = String(payload.index ?? this.toolBuffers.size);
|
|
515
|
+
const id = typeof block.id === "string" ? block.id : `call_${index}`;
|
|
516
|
+
const name = typeof block.name === "string" ? block.name : "";
|
|
517
|
+
this.toolBuffers.set(index, { id, name, args: "" });
|
|
518
|
+
}
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (type === "content_block_delta") {
|
|
522
|
+
const index = String(payload.index ?? 0);
|
|
523
|
+
const delta = (payload.delta ?? {}) as Record<string, unknown>;
|
|
524
|
+
if (delta.type === "text_delta" && typeof delta.text === "string" && delta.text.length > 0) {
|
|
525
|
+
yield { type: "text_delta", text: delta.text };
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
|
|
529
|
+
const existing = this.toolBuffers.get(index);
|
|
530
|
+
if (existing) existing.args += delta.partial_json;
|
|
531
|
+
}
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (type === "content_block_stop") {
|
|
535
|
+
const index = String(payload.index ?? 0);
|
|
536
|
+
const existing = this.toolBuffers.get(index);
|
|
537
|
+
if (existing && existing.name) {
|
|
538
|
+
this.toolBuffers.delete(index);
|
|
539
|
+
try {
|
|
540
|
+
const parsed = existing.args ? JSON.parse(existing.args) : {};
|
|
541
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
542
|
+
yield { type: "tool_call", id: existing.id, name: existing.name, args: parsed as Record<string, unknown> };
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
throw new Error();
|
|
546
|
+
} catch {
|
|
547
|
+
throw new AiParseError("AI provider returned malformed tool-call JSON");
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (type === "message_delta") {
|
|
553
|
+
const delta = (payload.delta ?? {}) as Record<string, unknown>;
|
|
554
|
+
if (typeof delta.stop_reason === "string" && delta.stop_reason.length > 0) {
|
|
555
|
+
this.lastFinishReason = delta.stop_reason;
|
|
556
|
+
}
|
|
557
|
+
const usage = (payload.usage ?? {}) as Record<string, unknown>;
|
|
558
|
+
if (usage.output_tokens !== undefined || usage.input_tokens !== undefined) {
|
|
559
|
+
const promptTokens = Number(usage.input_tokens ?? this.lastUsage?.promptTokens ?? 0);
|
|
560
|
+
const completionTokens = Number(usage.output_tokens ?? this.lastUsage?.completionTokens ?? 0);
|
|
561
|
+
this.lastUsage = { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens };
|
|
562
|
+
}
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (type === "message_stop") {
|
|
566
|
+
if (this.doneEmitted) return;
|
|
567
|
+
this.doneEmitted = true;
|
|
568
|
+
yield {
|
|
569
|
+
type: "done",
|
|
570
|
+
finishReason: this.lastFinishReason ?? "end_turn",
|
|
571
|
+
...(this.lastUsage ? { usage: this.lastUsage } : {}),
|
|
572
|
+
};
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (type === "message_start") {
|
|
576
|
+
const message = (payload.message ?? {}) as Record<string, unknown>;
|
|
577
|
+
const usage = (message.usage ?? {}) as Record<string, unknown>;
|
|
578
|
+
if (usage.input_tokens !== undefined || usage.output_tokens !== undefined) {
|
|
579
|
+
const promptTokens = Number(usage.input_tokens ?? 0);
|
|
580
|
+
const completionTokens = Number(usage.output_tokens ?? 0);
|
|
581
|
+
this.lastUsage = { promptTokens, completionTokens, totalTokens: promptTokens + completionTokens };
|
|
582
|
+
}
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (type === "error") {
|
|
586
|
+
const err = (payload.error ?? {}) as Record<string, unknown>;
|
|
587
|
+
throw new AiParseError(typeof err.message === "string" ? err.message : "AI provider signalled a stream error");
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private *flushRemainingToolCalls(): Iterable<AiEvent> {
|
|
592
|
+
for (const [index, buffered] of this.toolBuffers) {
|
|
593
|
+
if (buffered.name && buffered.args) {
|
|
594
|
+
try {
|
|
595
|
+
const parsed = JSON.parse(buffered.args) as unknown;
|
|
596
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
597
|
+
yield { type: "tool_call", id: buffered.id || `call_${index}`, name: buffered.name, args: parsed as Record<string, unknown> };
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
} catch {
|
|
601
|
+
/* fall through to error */
|
|
602
|
+
}
|
|
603
|
+
throw new AiParseError("AI provider returned malformed tool-call JSON");
|
|
285
604
|
}
|
|
286
605
|
}
|
|
606
|
+
this.toolBuffers.clear();
|
|
287
607
|
}
|
|
288
608
|
}
|