@galdor/provider-openai 0.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/dist/convert.d.ts +165 -0
- package/dist/convert.d.ts.map +1 -0
- package/dist/convert.js +302 -0
- package/dist/convert.js.map +1 -0
- package/dist/embed.d.ts +72 -0
- package/dist/embed.d.ts.map +1 -0
- package/dist/embed.js +126 -0
- package/dist/embed.js.map +1 -0
- package/dist/errors.d.ts +46 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +89 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +97 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +149 -0
- package/dist/index.js.map +1 -0
- package/dist/stream.d.ts +40 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +178 -0
- package/dist/stream.js.map +1 -0
- package/package.json +36 -0
- package/src/convert.ts +429 -0
- package/src/embed.test.ts +89 -0
- package/src/embed.ts +162 -0
- package/src/errors.ts +103 -0
- package/src/index.ts +198 -0
- package/src/openai.test.ts +184 -0
- package/src/stream.ts +245 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { collectStream, RateLimitError } from "@galdor/core/provider";
|
|
3
|
+
import { messageText } from "@galdor/core/schema";
|
|
4
|
+
import { newOpenAI } from "./index.ts";
|
|
5
|
+
|
|
6
|
+
let server: { stop(): void; url: string } | undefined;
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
server?.stop();
|
|
10
|
+
server = undefined;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function serve(handler: (req: Request) => Response | Promise<Response>): string {
|
|
14
|
+
const s = Bun.serve({ port: 0, fetch: handler });
|
|
15
|
+
server = { stop: () => s.stop(true), url: `http://localhost:${s.port}` };
|
|
16
|
+
return server.url;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("OpenAIProvider.generate", () => {
|
|
20
|
+
test("shapes the wire request and parses text + tool calls + usage", async () => {
|
|
21
|
+
let received: any;
|
|
22
|
+
let path = "";
|
|
23
|
+
const url = serve(async (req) => {
|
|
24
|
+
path = new URL(req.url).pathname;
|
|
25
|
+
received = await req.json();
|
|
26
|
+
return Response.json({
|
|
27
|
+
id: "chatcmpl-1",
|
|
28
|
+
object: "chat.completion",
|
|
29
|
+
model: "gpt-4o-mini",
|
|
30
|
+
choices: [
|
|
31
|
+
{
|
|
32
|
+
index: 0,
|
|
33
|
+
message: {
|
|
34
|
+
role: "assistant",
|
|
35
|
+
content: "the answer",
|
|
36
|
+
tool_calls: [{ id: "t1", type: "function", function: { name: "add", arguments: '{"a":1,"b":2}' } }],
|
|
37
|
+
},
|
|
38
|
+
finish_reason: "tool_calls",
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const p = newOpenAI({ apiKey: "sk-test", baseURL: url });
|
|
46
|
+
const resp = await p.generate({
|
|
47
|
+
model: "gpt-4o-mini",
|
|
48
|
+
messages: [
|
|
49
|
+
{ role: "system", content: [{ type: "text", text: "be terse" }] },
|
|
50
|
+
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// request shaping: system stays a message, model carried, content stringified
|
|
55
|
+
expect(path).toBe("/chat/completions");
|
|
56
|
+
expect(received.model).toBe("gpt-4o-mini");
|
|
57
|
+
expect(received.messages[0]).toEqual({ role: "system", content: "be terse" });
|
|
58
|
+
expect(received.messages[1]).toEqual({ role: "user", content: "hi" });
|
|
59
|
+
|
|
60
|
+
// response parsing
|
|
61
|
+
expect(messageText(resp.message)).toBe("the answer");
|
|
62
|
+
expect(resp.message.toolCalls?.[0]).toEqual({ id: "t1", name: "add", arguments: { a: 1, b: 2 } });
|
|
63
|
+
expect(resp.stopReason).toBe("tool_use");
|
|
64
|
+
expect(resp.usage.inputTokens).toBe(10);
|
|
65
|
+
expect(resp.usage.outputTokens).toBe(5);
|
|
66
|
+
expect(resp.model).toBe("gpt-4o-mini");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("omits `stop` when stopSequences is empty, includes it when non-empty", async () => {
|
|
70
|
+
let received: any;
|
|
71
|
+
const url = serve(async (req) => {
|
|
72
|
+
received = await req.json();
|
|
73
|
+
return Response.json({ model: "m", choices: [{ index: 0, message: { role: "assistant", content: "ok" } }] });
|
|
74
|
+
});
|
|
75
|
+
const p = newOpenAI({ apiKey: "sk-test", baseURL: url });
|
|
76
|
+
|
|
77
|
+
await p.generate({
|
|
78
|
+
model: "m",
|
|
79
|
+
messages: [{ role: "user", content: [{ type: "text", text: "x" }] }],
|
|
80
|
+
stopSequences: [],
|
|
81
|
+
});
|
|
82
|
+
expect("stop" in received).toBe(false);
|
|
83
|
+
|
|
84
|
+
await p.generate({
|
|
85
|
+
model: "m",
|
|
86
|
+
messages: [{ role: "user", content: [{ type: "text", text: "x" }] }],
|
|
87
|
+
stopSequences: ["END"],
|
|
88
|
+
});
|
|
89
|
+
expect(received.stop).toEqual(["END"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("maps a 429 to a typed RateLimitError with retryAfter", async () => {
|
|
93
|
+
const url = serve(
|
|
94
|
+
() =>
|
|
95
|
+
new Response(JSON.stringify({ error: { type: "rate_limit_error", message: "slow down" } }), {
|
|
96
|
+
status: 429,
|
|
97
|
+
headers: { "retry-after": "7" },
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
const p = newOpenAI({ apiKey: "sk-test", baseURL: url });
|
|
101
|
+
try {
|
|
102
|
+
await p.generate({ model: "m", messages: [{ role: "user", content: [{ type: "text", text: "x" }] }] });
|
|
103
|
+
throw new Error("should have thrown");
|
|
104
|
+
} catch (e) {
|
|
105
|
+
expect(e).toBeInstanceOf(RateLimitError);
|
|
106
|
+
expect((e as RateLimitError).retryAfter).toBe(7);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("OpenAIProvider.stream", () => {
|
|
112
|
+
test("parses an SSE sequence into events that collectStream reassembles", async () => {
|
|
113
|
+
const chunk = (o: unknown) => `data: ${JSON.stringify(o)}\n\n`;
|
|
114
|
+
const sse = [
|
|
115
|
+
chunk({ id: "1", model: "gpt-4o-mini", choices: [{ index: 0, delta: { role: "assistant", content: "" } }] }),
|
|
116
|
+
chunk({ id: "1", model: "gpt-4o-mini", choices: [{ index: 0, delta: { content: "hello " } }] }),
|
|
117
|
+
chunk({ id: "1", model: "gpt-4o-mini", choices: [{ index: 0, delta: { content: "world" } }] }),
|
|
118
|
+
chunk({ id: "1", model: "gpt-4o-mini", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }),
|
|
119
|
+
chunk({ id: "1", model: "gpt-4o-mini", choices: [], usage: { prompt_tokens: 3, completion_tokens: 2 } }),
|
|
120
|
+
"data: [DONE]\n\n",
|
|
121
|
+
].join("");
|
|
122
|
+
|
|
123
|
+
const url = serve(() => new Response(sse, { headers: { "content-type": "text/event-stream" } }));
|
|
124
|
+
const p = newOpenAI({ apiKey: "sk-test", baseURL: url });
|
|
125
|
+
const resp = await collectStream(
|
|
126
|
+
p.stream({ model: "gpt-4o-mini", messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] }),
|
|
127
|
+
);
|
|
128
|
+
expect(messageText(resp.message)).toBe("hello world");
|
|
129
|
+
expect(resp.stopReason).toBe("end_turn");
|
|
130
|
+
expect(resp.usage.inputTokens).toBe(3);
|
|
131
|
+
expect(resp.usage.outputTokens).toBe(2);
|
|
132
|
+
expect(resp.model).toBe("gpt-4o-mini");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("parses a CRLF-framed SSE stream (OpenAI-compatible backends)", async () => {
|
|
136
|
+
const chunk = (o: unknown) => `data: ${JSON.stringify(o)}\r\n\r\n`;
|
|
137
|
+
const sse = [
|
|
138
|
+
chunk({ id: "1", model: "gpt-4o-mini", choices: [{ index: 0, delta: { role: "assistant", content: "" } }] }),
|
|
139
|
+
chunk({ id: "1", model: "gpt-4o-mini", choices: [{ index: 0, delta: { content: "hello " } }] }),
|
|
140
|
+
chunk({ id: "1", model: "gpt-4o-mini", choices: [{ index: 0, delta: { content: "world" } }] }),
|
|
141
|
+
chunk({ id: "1", model: "gpt-4o-mini", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }),
|
|
142
|
+
chunk({ id: "1", model: "gpt-4o-mini", choices: [], usage: { prompt_tokens: 3, completion_tokens: 2 } }),
|
|
143
|
+
"data: [DONE]\r\n\r\n",
|
|
144
|
+
].join("");
|
|
145
|
+
|
|
146
|
+
const url = serve(() => new Response(sse, { headers: { "content-type": "text/event-stream" } }));
|
|
147
|
+
const p = newOpenAI({ apiKey: "sk-test", baseURL: url });
|
|
148
|
+
const resp = await collectStream(
|
|
149
|
+
p.stream({ model: "gpt-4o-mini", messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] }),
|
|
150
|
+
);
|
|
151
|
+
expect(messageText(resp.message)).toBe("hello world");
|
|
152
|
+
expect(resp.stopReason).toBe("end_turn");
|
|
153
|
+
expect(resp.usage.inputTokens).toBe(3);
|
|
154
|
+
expect(resp.usage.outputTokens).toBe(2);
|
|
155
|
+
expect(resp.model).toBe("gpt-4o-mini");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("captures a final chunk not terminated by a blank line", async () => {
|
|
159
|
+
const chunk = (o: unknown) => `data: ${JSON.stringify(o)}\n\n`;
|
|
160
|
+
// The backend closes the connection after the last data line, without a
|
|
161
|
+
// trailing blank line and without [DONE]: the finish/usage chunk must still
|
|
162
|
+
// be honored.
|
|
163
|
+
const sse =
|
|
164
|
+
chunk({ id: "1", model: "gpt-4o-mini", choices: [{ index: 0, delta: { role: "assistant", content: "hi" } }] }) +
|
|
165
|
+
chunk({ id: "1", model: "gpt-4o-mini", choices: [{ index: 0, delta: { content: " there" } }] }) +
|
|
166
|
+
`data: ${JSON.stringify({
|
|
167
|
+
id: "1",
|
|
168
|
+
model: "gpt-4o-mini",
|
|
169
|
+
choices: [{ index: 0, delta: {}, finish_reason: "length" }],
|
|
170
|
+
usage: { prompt_tokens: 7, completion_tokens: 4 },
|
|
171
|
+
})}`;
|
|
172
|
+
|
|
173
|
+
const url = serve(() => new Response(sse, { headers: { "content-type": "text/event-stream" } }));
|
|
174
|
+
const p = newOpenAI({ apiKey: "sk-test", baseURL: url });
|
|
175
|
+
const resp = await collectStream(
|
|
176
|
+
p.stream({ model: "gpt-4o-mini", messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }] }),
|
|
177
|
+
);
|
|
178
|
+
expect(messageText(resp.message)).toBe("hi there");
|
|
179
|
+
expect(resp.stopReason).toBe("max_tokens");
|
|
180
|
+
expect(resp.usage.inputTokens).toBe(7);
|
|
181
|
+
expect(resp.usage.outputTokens).toBe(4);
|
|
182
|
+
expect(resp.model).toBe("gpt-4o-mini");
|
|
183
|
+
});
|
|
184
|
+
});
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI streaming over Server-Sent Events.
|
|
3
|
+
*
|
|
4
|
+
* Decodes each `data: {...}` chunk of the /chat/completions stream into galdor
|
|
5
|
+
* provider {@link Event}s (MessageStart / ContentDelta / ToolCallDelta /
|
|
6
|
+
* MessageStop). The OpenAI stream carries no dedicated opening frame, so
|
|
7
|
+
* MessageStart is synthesized from the first chunk, and MessageStop is deferred
|
|
8
|
+
* to the end: with `stream_options.include_usage = true` the final usage chunk
|
|
9
|
+
* arrives after the `finish_reason` chunk. Some OpenAI-compatible backends close
|
|
10
|
+
* the connection rather than emitting `data: [DONE]`, so the terminal
|
|
11
|
+
* MessageStop is always synthesized from accumulated state, regardless of how
|
|
12
|
+
* the stream ends. Consume the generator with `for await`, or fold it into a
|
|
13
|
+
* single {@link Response} via `collectStream`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { APIError, type Event, EventType, fetchWithHeaderTimeout } from "@galdor/core/provider";
|
|
17
|
+
import { Role, type StopReason, thinkingPart, type Usage } from "@galdor/core/schema";
|
|
18
|
+
import { normalizeFinishReason, usageFromWire, type WireUsage } from "./convert.ts";
|
|
19
|
+
import { kindForType, normalizeHTTPError } from "./errors.ts";
|
|
20
|
+
|
|
21
|
+
const PROVIDER_NAME = "openai";
|
|
22
|
+
|
|
23
|
+
interface ChunkFuncCall {
|
|
24
|
+
name?: string;
|
|
25
|
+
arguments?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ChunkToolCall {
|
|
29
|
+
id?: string;
|
|
30
|
+
index?: number;
|
|
31
|
+
function?: ChunkFuncCall;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ChunkDelta {
|
|
35
|
+
role?: string;
|
|
36
|
+
content?: string;
|
|
37
|
+
reasoning_content?: string;
|
|
38
|
+
tool_calls?: ChunkToolCall[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ChunkChoice {
|
|
42
|
+
index?: number;
|
|
43
|
+
delta?: ChunkDelta;
|
|
44
|
+
finish_reason?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ChunkError {
|
|
48
|
+
type?: string;
|
|
49
|
+
code?: string;
|
|
50
|
+
message?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ChatChunk {
|
|
54
|
+
model?: string;
|
|
55
|
+
choices?: ChunkChoice[];
|
|
56
|
+
usage?: WireUsage;
|
|
57
|
+
error?: ChunkError;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function emptyUsage(): Usage {
|
|
61
|
+
return { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ToolState {
|
|
65
|
+
id: string;
|
|
66
|
+
name: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface StreamState {
|
|
70
|
+
started: boolean;
|
|
71
|
+
model: string;
|
|
72
|
+
usage: Usage;
|
|
73
|
+
stopReason: StopReason;
|
|
74
|
+
reasoning: string;
|
|
75
|
+
toolByIdx: Map<number, ToolState>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* POST a streaming /chat/completions request and yield galdor provider events.
|
|
80
|
+
*
|
|
81
|
+
* Synthesizes a MessageStart from the first chunk, forwards content and tool-call
|
|
82
|
+
* deltas as they arrive, accumulates reasoning and usage, and emits a terminal
|
|
83
|
+
* MessageStop once the upstream stream ends.
|
|
84
|
+
*
|
|
85
|
+
* @param url - The fully-qualified /chat/completions endpoint to POST to.
|
|
86
|
+
* @param headers - Request headers (auth, content-type, etc.); an SSE `Accept`
|
|
87
|
+
* header is added automatically.
|
|
88
|
+
* @param body - The request payload, serialized to JSON.
|
|
89
|
+
* @param signal - Optional abort signal to cancel the in-flight request.
|
|
90
|
+
* @returns An async generator of provider {@link Event}s ending in MessageStop.
|
|
91
|
+
* @throws {APIError} When the HTTP response is non-2xx, or when an in-stream
|
|
92
|
+
* error frame is received.
|
|
93
|
+
* @throws {Error} When a 2xx response unexpectedly carries no body.
|
|
94
|
+
* @example
|
|
95
|
+
* ```ts
|
|
96
|
+
* for await (const ev of streamChat(url, headers, wire, signal)) {
|
|
97
|
+
* if (ev.type === EventType.ContentDelta) process.stdout.write(ev.contentDelta);
|
|
98
|
+
* }
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export async function* streamChat(
|
|
102
|
+
url: string,
|
|
103
|
+
headers: Record<string, string>,
|
|
104
|
+
body: unknown,
|
|
105
|
+
signal: AbortSignal | undefined,
|
|
106
|
+
timeoutMs = 0,
|
|
107
|
+
): AsyncGenerator<Event> {
|
|
108
|
+
const res = await fetchWithHeaderTimeout(
|
|
109
|
+
url,
|
|
110
|
+
{ method: "POST", headers: { ...headers, accept: "text/event-stream" }, body: JSON.stringify(body) },
|
|
111
|
+
timeoutMs,
|
|
112
|
+
signal,
|
|
113
|
+
);
|
|
114
|
+
if (Math.floor(res.status / 100) !== 2) throw await normalizeHTTPError(res);
|
|
115
|
+
if (!res.body) throw new Error("openai: streaming response had no body");
|
|
116
|
+
|
|
117
|
+
const state: StreamState = {
|
|
118
|
+
started: false,
|
|
119
|
+
model: "",
|
|
120
|
+
usage: emptyUsage(),
|
|
121
|
+
stopReason: "end_turn",
|
|
122
|
+
reasoning: "",
|
|
123
|
+
toolByIdx: new Map(),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const decoder = new TextDecoder();
|
|
127
|
+
let buffer = "";
|
|
128
|
+
|
|
129
|
+
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
|
|
130
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
131
|
+
// SSE events are separated by a blank line. Accept both LF and CRLF framing
|
|
132
|
+
// so OpenAI-compatible backends that emit \r\n boundaries parse cleanly.
|
|
133
|
+
let m: RegExpExecArray | null;
|
|
134
|
+
while ((m = FRAME_BOUNDARY.exec(buffer)) !== null) {
|
|
135
|
+
const rawEvent = buffer.slice(0, m.index);
|
|
136
|
+
buffer = buffer.slice(m.index + m[0].length);
|
|
137
|
+
FRAME_BOUNDARY.lastIndex = 0;
|
|
138
|
+
const payload = parseDataLine(rawEvent);
|
|
139
|
+
if (payload === undefined) continue;
|
|
140
|
+
yield* handleChunk(payload, state);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Some backends close the connection without a blank-line-terminated final
|
|
145
|
+
// frame; if the leftover buffer still holds a data line, process it so the
|
|
146
|
+
// closing usage/finish chunk is not dropped.
|
|
147
|
+
const tail = parseDataLine(buffer);
|
|
148
|
+
if (tail !== undefined) yield* handleChunk(tail, state);
|
|
149
|
+
|
|
150
|
+
yield terminalStop(state);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Matches an SSE blank-line frame boundary under either LF or CRLF framing. */
|
|
154
|
+
const FRAME_BOUNDARY = /\r?\n\r?\n/g;
|
|
155
|
+
|
|
156
|
+
/** Extract and JSON-parse the `data:` payload of one SSE event block. */
|
|
157
|
+
function parseDataLine(rawEvent: string): ChatChunk | undefined {
|
|
158
|
+
const dataParts: string[] = [];
|
|
159
|
+
for (const raw of rawEvent.split("\n")) {
|
|
160
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw; // strip CR under CRLF framing
|
|
161
|
+
if (line.startsWith(":")) continue; // comment
|
|
162
|
+
if (line.startsWith("data:")) dataParts.push(line.slice(5).trimStart());
|
|
163
|
+
}
|
|
164
|
+
if (dataParts.length === 0) return undefined;
|
|
165
|
+
const payload = dataParts.join("\n");
|
|
166
|
+
if (payload === "" || payload === "[DONE]") return undefined;
|
|
167
|
+
try {
|
|
168
|
+
return JSON.parse(payload) as ChatChunk;
|
|
169
|
+
} catch {
|
|
170
|
+
// Skip lines that fail to parse — be permissive about transport hiccups.
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function terminalStop(state: StreamState): Event {
|
|
176
|
+
const msg =
|
|
177
|
+
state.reasoning !== ""
|
|
178
|
+
? { role: Role.Assistant, content: [thinkingPart(state.reasoning)] }
|
|
179
|
+
: undefined;
|
|
180
|
+
return {
|
|
181
|
+
type: EventType.MessageStop,
|
|
182
|
+
stopReason: state.stopReason,
|
|
183
|
+
usage: state.usage,
|
|
184
|
+
model: state.model,
|
|
185
|
+
...(msg ? { message: msg } : {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function* handleChunk(c: ChatChunk, state: StreamState): Generator<Event> {
|
|
190
|
+
// Surface an in-stream error frame instead of silently ending the stream.
|
|
191
|
+
if (c.error) {
|
|
192
|
+
const kind = kindForType(c.error.type, c.error.code) ?? "server";
|
|
193
|
+
throw new APIError({ kind, provider: PROVIDER_NAME, statusCode: 0, message: c.error.message ?? "stream error" });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (c.model) state.model = c.model;
|
|
197
|
+
if (c.usage) state.usage = usageFromWire(c.usage);
|
|
198
|
+
|
|
199
|
+
// First chunk: synthesize MessageStart, since the stream has no start frame.
|
|
200
|
+
if (!state.started && (state.model !== "" || (c.choices?.length ?? 0) > 0)) {
|
|
201
|
+
state.started = true;
|
|
202
|
+
yield { type: EventType.MessageStart, model: state.model };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const ch = c.choices?.[0];
|
|
206
|
+
if (!ch) return;
|
|
207
|
+
|
|
208
|
+
if (ch.delta?.reasoning_content) {
|
|
209
|
+
// Accumulate reasoning; do not forward it on the live stream.
|
|
210
|
+
state.reasoning += ch.delta.reasoning_content;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (ch.delta?.content) {
|
|
214
|
+
yield { type: EventType.ContentDelta, contentDelta: ch.delta.content };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const td of ch.delta?.tool_calls ?? []) {
|
|
218
|
+
const ts = touchToolState(td, state);
|
|
219
|
+
yield {
|
|
220
|
+
type: EventType.ToolCallDelta,
|
|
221
|
+
toolCallDelta: { id: ts.id, name: td.function?.name ?? "", argumentsDelta: td.function?.arguments ?? "" },
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (ch.finish_reason) state.stopReason = normalizeFinishReason(ch.finish_reason);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Ensure a ToolState exists for `td.index` (defaulting to 0) and fold any new
|
|
230
|
+
* id or name from this delta into it. Some OpenAI-compatible backends omit
|
|
231
|
+
* `tool_call` ids, so a stable id is synthesized from the index to keep the call
|
|
232
|
+
* from being dropped downstream (collectStream discards id-less tool deltas).
|
|
233
|
+
*/
|
|
234
|
+
function touchToolState(td: ChunkToolCall, state: StreamState): ToolState {
|
|
235
|
+
const idx = td.index ?? 0;
|
|
236
|
+
let ts = state.toolByIdx.get(idx);
|
|
237
|
+
if (!ts) {
|
|
238
|
+
ts = { id: "", name: "" };
|
|
239
|
+
state.toolByIdx.set(idx, ts);
|
|
240
|
+
}
|
|
241
|
+
if (td.id) ts.id = td.id;
|
|
242
|
+
if (td.function?.name) ts.name = td.function.name;
|
|
243
|
+
if (ts.id === "") ts.id = `call_${idx}`;
|
|
244
|
+
return ts;
|
|
245
|
+
}
|