@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/stream.js ADDED
@@ -0,0 +1,178 @@
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
+ import { APIError, EventType, fetchWithHeaderTimeout } from "@galdor/core/provider";
16
+ import { Role, thinkingPart } from "@galdor/core/schema";
17
+ import { normalizeFinishReason, usageFromWire } from "./convert.js";
18
+ import { kindForType, normalizeHTTPError } from "./errors.js";
19
+ const PROVIDER_NAME = "openai";
20
+ function emptyUsage() {
21
+ return { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 };
22
+ }
23
+ /**
24
+ * POST a streaming /chat/completions request and yield galdor provider events.
25
+ *
26
+ * Synthesizes a MessageStart from the first chunk, forwards content and tool-call
27
+ * deltas as they arrive, accumulates reasoning and usage, and emits a terminal
28
+ * MessageStop once the upstream stream ends.
29
+ *
30
+ * @param url - The fully-qualified /chat/completions endpoint to POST to.
31
+ * @param headers - Request headers (auth, content-type, etc.); an SSE `Accept`
32
+ * header is added automatically.
33
+ * @param body - The request payload, serialized to JSON.
34
+ * @param signal - Optional abort signal to cancel the in-flight request.
35
+ * @returns An async generator of provider {@link Event}s ending in MessageStop.
36
+ * @throws {APIError} When the HTTP response is non-2xx, or when an in-stream
37
+ * error frame is received.
38
+ * @throws {Error} When a 2xx response unexpectedly carries no body.
39
+ * @example
40
+ * ```ts
41
+ * for await (const ev of streamChat(url, headers, wire, signal)) {
42
+ * if (ev.type === EventType.ContentDelta) process.stdout.write(ev.contentDelta);
43
+ * }
44
+ * ```
45
+ */
46
+ export async function* streamChat(url, headers, body, signal, timeoutMs = 0) {
47
+ const res = await fetchWithHeaderTimeout(url, { method: "POST", headers: { ...headers, accept: "text/event-stream" }, body: JSON.stringify(body) }, timeoutMs, signal);
48
+ if (Math.floor(res.status / 100) !== 2)
49
+ throw await normalizeHTTPError(res);
50
+ if (!res.body)
51
+ throw new Error("openai: streaming response had no body");
52
+ const state = {
53
+ started: false,
54
+ model: "",
55
+ usage: emptyUsage(),
56
+ stopReason: "end_turn",
57
+ reasoning: "",
58
+ toolByIdx: new Map(),
59
+ };
60
+ const decoder = new TextDecoder();
61
+ let buffer = "";
62
+ for await (const chunk of res.body) {
63
+ buffer += decoder.decode(chunk, { stream: true });
64
+ // SSE events are separated by a blank line. Accept both LF and CRLF framing
65
+ // so OpenAI-compatible backends that emit \r\n boundaries parse cleanly.
66
+ let m;
67
+ while ((m = FRAME_BOUNDARY.exec(buffer)) !== null) {
68
+ const rawEvent = buffer.slice(0, m.index);
69
+ buffer = buffer.slice(m.index + m[0].length);
70
+ FRAME_BOUNDARY.lastIndex = 0;
71
+ const payload = parseDataLine(rawEvent);
72
+ if (payload === undefined)
73
+ continue;
74
+ yield* handleChunk(payload, state);
75
+ }
76
+ }
77
+ // Some backends close the connection without a blank-line-terminated final
78
+ // frame; if the leftover buffer still holds a data line, process it so the
79
+ // closing usage/finish chunk is not dropped.
80
+ const tail = parseDataLine(buffer);
81
+ if (tail !== undefined)
82
+ yield* handleChunk(tail, state);
83
+ yield terminalStop(state);
84
+ }
85
+ /** Matches an SSE blank-line frame boundary under either LF or CRLF framing. */
86
+ const FRAME_BOUNDARY = /\r?\n\r?\n/g;
87
+ /** Extract and JSON-parse the `data:` payload of one SSE event block. */
88
+ function parseDataLine(rawEvent) {
89
+ const dataParts = [];
90
+ for (const raw of rawEvent.split("\n")) {
91
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw; // strip CR under CRLF framing
92
+ if (line.startsWith(":"))
93
+ continue; // comment
94
+ if (line.startsWith("data:"))
95
+ dataParts.push(line.slice(5).trimStart());
96
+ }
97
+ if (dataParts.length === 0)
98
+ return undefined;
99
+ const payload = dataParts.join("\n");
100
+ if (payload === "" || payload === "[DONE]")
101
+ return undefined;
102
+ try {
103
+ return JSON.parse(payload);
104
+ }
105
+ catch {
106
+ // Skip lines that fail to parse — be permissive about transport hiccups.
107
+ return undefined;
108
+ }
109
+ }
110
+ function terminalStop(state) {
111
+ const msg = state.reasoning !== ""
112
+ ? { role: Role.Assistant, content: [thinkingPart(state.reasoning)] }
113
+ : undefined;
114
+ return {
115
+ type: EventType.MessageStop,
116
+ stopReason: state.stopReason,
117
+ usage: state.usage,
118
+ model: state.model,
119
+ ...(msg ? { message: msg } : {}),
120
+ };
121
+ }
122
+ function* handleChunk(c, state) {
123
+ // Surface an in-stream error frame instead of silently ending the stream.
124
+ if (c.error) {
125
+ const kind = kindForType(c.error.type, c.error.code) ?? "server";
126
+ throw new APIError({ kind, provider: PROVIDER_NAME, statusCode: 0, message: c.error.message ?? "stream error" });
127
+ }
128
+ if (c.model)
129
+ state.model = c.model;
130
+ if (c.usage)
131
+ state.usage = usageFromWire(c.usage);
132
+ // First chunk: synthesize MessageStart, since the stream has no start frame.
133
+ if (!state.started && (state.model !== "" || (c.choices?.length ?? 0) > 0)) {
134
+ state.started = true;
135
+ yield { type: EventType.MessageStart, model: state.model };
136
+ }
137
+ const ch = c.choices?.[0];
138
+ if (!ch)
139
+ return;
140
+ if (ch.delta?.reasoning_content) {
141
+ // Accumulate reasoning; do not forward it on the live stream.
142
+ state.reasoning += ch.delta.reasoning_content;
143
+ }
144
+ if (ch.delta?.content) {
145
+ yield { type: EventType.ContentDelta, contentDelta: ch.delta.content };
146
+ }
147
+ for (const td of ch.delta?.tool_calls ?? []) {
148
+ const ts = touchToolState(td, state);
149
+ yield {
150
+ type: EventType.ToolCallDelta,
151
+ toolCallDelta: { id: ts.id, name: td.function?.name ?? "", argumentsDelta: td.function?.arguments ?? "" },
152
+ };
153
+ }
154
+ if (ch.finish_reason)
155
+ state.stopReason = normalizeFinishReason(ch.finish_reason);
156
+ }
157
+ /**
158
+ * Ensure a ToolState exists for `td.index` (defaulting to 0) and fold any new
159
+ * id or name from this delta into it. Some OpenAI-compatible backends omit
160
+ * `tool_call` ids, so a stable id is synthesized from the index to keep the call
161
+ * from being dropped downstream (collectStream discards id-less tool deltas).
162
+ */
163
+ function touchToolState(td, state) {
164
+ const idx = td.index ?? 0;
165
+ let ts = state.toolByIdx.get(idx);
166
+ if (!ts) {
167
+ ts = { id: "", name: "" };
168
+ state.toolByIdx.set(idx, ts);
169
+ }
170
+ if (td.id)
171
+ ts.id = td.id;
172
+ if (td.function?.name)
173
+ ts.name = td.function.name;
174
+ if (ts.id === "")
175
+ ts.id = `call_${idx}`;
176
+ return ts;
177
+ }
178
+ //# sourceMappingURL=stream.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stream.js","sourceRoot":"","sources":["../src/stream.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@galdor/provider-openai",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "description": "OpenAI (Chat Completions API) provider adapter for galdor-bun. Also targets OpenAI-compatible endpoints (Groq, Together, MiniMax, Mistral, DeepSeek, vLLM, Ollama, ...) via baseURL.",
6
+ "author": {
7
+ "name": "Yasser Rosas",
8
+ "email": "yassros16@gmail.com"
9
+ },
10
+ "license": "Apache-2.0",
11
+ "main": "./dist/index.js",
12
+ "module": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "sideEffects": false,
15
+ "files": [
16
+ "dist",
17
+ "src"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "bun": "./src/index.ts",
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ }
25
+ },
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.build.json"
28
+ },
29
+ "engines": {
30
+ "node": ">=22.5",
31
+ "bun": ">=1.3"
32
+ },
33
+ "dependencies": {
34
+ "@galdor/core": "0.3.0"
35
+ }
36
+ }
package/src/convert.ts ADDED
@@ -0,0 +1,429 @@
1
+ /**
2
+ * Conversion between galdor's shared schema and the OpenAI Chat Completions API
3
+ * wire shape.
4
+ *
5
+ * On the wire OpenAI carries the system prompt as a `role: system` message and
6
+ * tool results as `role: tool` messages keyed by `tool_call_id`. The wire types
7
+ * declared below model OpenAI's snake_case JSON exactly. The two entry points are
8
+ * {@link buildRequest} (galdor request to wire request) and
9
+ * {@link responseFromWire} (wire response to galdor response).
10
+ */
11
+
12
+ import { InvalidRequestError, type Request, type Response } from "@galdor/core/provider";
13
+ import {
14
+ ContentType,
15
+ type ContentPart,
16
+ type ImageContent,
17
+ type JSONValue,
18
+ type Message,
19
+ messageText,
20
+ Role,
21
+ type StopReason,
22
+ textPart,
23
+ thinkingPart,
24
+ type ToolCall,
25
+ } from "@galdor/core/schema";
26
+
27
+ /** Build a typed invalid-request error for a request that cannot be lowered to the wire. */
28
+ function invalidRequest(message: string): InvalidRequestError {
29
+ return new InvalidRequestError({ kind: "invalid_request", provider: "openai", statusCode: 0, message });
30
+ }
31
+
32
+ // ── Wire types (OpenAI JSON, snake_case) ─────────────────────────────────────
33
+
34
+ interface WireImageURL {
35
+ url: string;
36
+ detail?: string;
37
+ }
38
+
39
+ interface WireContentPart {
40
+ type: string;
41
+ text?: string;
42
+ image_url?: WireImageURL;
43
+ }
44
+
45
+ interface WireFuncCall {
46
+ name?: string;
47
+ arguments?: string;
48
+ }
49
+
50
+ interface WireToolCall {
51
+ id?: string;
52
+ type?: string;
53
+ function: WireFuncCall;
54
+ /** Only set on streaming deltas. */
55
+ index?: number;
56
+ }
57
+
58
+ interface WireMessage {
59
+ role: string;
60
+ content?: string | WireContentPart[];
61
+ name?: string;
62
+ tool_calls?: WireToolCall[];
63
+ tool_call_id?: string;
64
+ /** Reasoning from OpenAI-compatible models (e.g. DeepSeek-R1). Inbound only. */
65
+ reasoning_content?: string;
66
+ }
67
+
68
+ interface WireFuncDecl {
69
+ name: string;
70
+ description?: string;
71
+ parameters: unknown;
72
+ }
73
+
74
+ interface WireTool {
75
+ type: string;
76
+ function: WireFuncDecl;
77
+ }
78
+
79
+ interface WireJSONSchema {
80
+ name?: string;
81
+ strict?: boolean;
82
+ schema: unknown;
83
+ }
84
+
85
+ interface WireRespFormat {
86
+ type: string;
87
+ json_schema?: WireJSONSchema;
88
+ }
89
+
90
+ /** Serialized request body for the OpenAI /chat/completions endpoint. */
91
+ export interface ChatRequest {
92
+ model: string;
93
+ messages: WireMessage[];
94
+ max_tokens?: number;
95
+ max_completion_tokens?: number;
96
+ temperature?: number;
97
+ top_p?: number;
98
+ stop?: string[];
99
+ stream?: boolean;
100
+ stream_options?: { include_usage: boolean };
101
+ tools?: WireTool[];
102
+ tool_choice?: string;
103
+ response_format?: WireRespFormat;
104
+ reasoning_effort?: string;
105
+ user?: string;
106
+ }
107
+
108
+ interface WireTokenDetails {
109
+ cached_tokens?: number;
110
+ }
111
+
112
+ /** Token accounting block as returned by OpenAI, in its snake_case form. */
113
+ export interface WireUsage {
114
+ prompt_tokens?: number;
115
+ completion_tokens?: number;
116
+ total_tokens?: number;
117
+ prompt_tokens_details?: WireTokenDetails;
118
+ }
119
+
120
+ interface WireResponseMessage {
121
+ role?: string;
122
+ content?: string | WireContentPart[];
123
+ tool_calls?: WireToolCall[];
124
+ reasoning_content?: string;
125
+ }
126
+
127
+ interface WireChoice {
128
+ index?: number;
129
+ message: WireResponseMessage;
130
+ finish_reason?: string;
131
+ }
132
+
133
+ /** Decoded body of a non-streaming OpenAI /chat/completions response. */
134
+ export interface ChatResponse {
135
+ id?: string;
136
+ object?: string;
137
+ created?: number;
138
+ model?: string;
139
+ choices?: WireChoice[];
140
+ usage?: WireUsage;
141
+ }
142
+
143
+ // ── Request building ─────────────────────────────────────────────────────────
144
+
145
+ function toBase64(data: Uint8Array): string {
146
+ return Buffer.from(data).toString("base64");
147
+ }
148
+
149
+ /** Render an image part as OpenAI's image_url.url (direct URL or data: URL). */
150
+ function imageToURL(img: ImageContent): string {
151
+ if (img.url && img.url !== "") return img.url;
152
+ if (img.data && img.data.length > 0) {
153
+ if (!img.media) throw invalidRequest("openai: inline image missing media (MIME type)");
154
+ return `data:${img.media};base64,${toBase64(img.data)}`;
155
+ }
156
+ throw invalidRequest("openai: image part with no url or data");
157
+ }
158
+
159
+ function partsToWire(parts: ContentPart[]): WireContentPart[] {
160
+ const out: WireContentPart[] = [];
161
+ for (const p of parts) {
162
+ switch (p.type) {
163
+ case ContentType.Text:
164
+ out.push({ type: "text", text: p.text ?? "" });
165
+ break;
166
+ case ContentType.Image:
167
+ if (!p.image) throw invalidRequest("openai: image part with nil image");
168
+ out.push({ type: "image_url", image_url: { url: imageToURL(p.image) } });
169
+ break;
170
+ case ContentType.Thinking:
171
+ case ContentType.RedactedThinking:
172
+ // Reasoning is model output, not input: never echo it back.
173
+ continue;
174
+ default:
175
+ throw invalidRequest(`openai: unsupported content type ${p.type}`);
176
+ }
177
+ }
178
+ return out;
179
+ }
180
+
181
+ function roleToWire(r: Role): string {
182
+ switch (r) {
183
+ case Role.System:
184
+ return "system";
185
+ case Role.User:
186
+ return "user";
187
+ case Role.Assistant:
188
+ return "assistant";
189
+ case Role.Tool:
190
+ return "tool";
191
+ default:
192
+ throw invalidRequest(`openai: unknown role ${r}`);
193
+ }
194
+ }
195
+
196
+ function messageToWire(m: Message): WireMessage {
197
+ const wm: WireMessage = { role: roleToWire(m.role) };
198
+ if (m.name) wm.name = m.name;
199
+ if (m.toolCallId) wm.tool_call_id = m.toolCallId;
200
+
201
+ // Assistant tool calls.
202
+ const toolCalls: WireToolCall[] = [];
203
+ for (const tc of m.toolCalls ?? []) {
204
+ toolCalls.push({
205
+ id: tc.id,
206
+ type: "function",
207
+ function: { name: tc.name, arguments: stringifyArguments(tc.arguments) },
208
+ });
209
+ }
210
+ if (toolCalls.length > 0) wm.tool_calls = toolCalls;
211
+
212
+ // Content. Prefer the plain-string form when all parts are text; use the
213
+ // array form when any non-text part is present.
214
+ const allText = m.content.every((p) => p.type === ContentType.Text);
215
+ if (m.content.length === 0 && toolCalls.length > 0) {
216
+ // Assistant tool-call-only messages omit content.
217
+ } else if (allText) {
218
+ wm.content = messageText(m);
219
+ } else {
220
+ wm.content = partsToWire(m.content);
221
+ }
222
+
223
+ return wm;
224
+ }
225
+
226
+ function stringifyArguments(args: JSONValue): string {
227
+ if (args === undefined || args === null) return "{}";
228
+ if (typeof args === "string") return args;
229
+ return JSON.stringify(args);
230
+ }
231
+
232
+ function toolChoiceToWire(c: Request["toolChoice"]): string | undefined {
233
+ switch (c) {
234
+ case "auto":
235
+ return "auto";
236
+ case "none":
237
+ return "none";
238
+ case "required":
239
+ return "required";
240
+ default:
241
+ return undefined;
242
+ }
243
+ }
244
+
245
+ function responseFormatToWire(rf: Request["responseFormat"]): WireRespFormat | undefined {
246
+ if (!rf) return undefined;
247
+ switch (rf.type) {
248
+ case "json_object":
249
+ return { type: "json_object" };
250
+ case "json_schema":
251
+ return {
252
+ type: "json_schema",
253
+ json_schema: { ...(rf.name ? { name: rf.name } : {}), strict: true, schema: rf.schema },
254
+ };
255
+ default:
256
+ return undefined;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Translate a galdor {@link Request} into an OpenAI Chat Completions wire
262
+ * request.
263
+ *
264
+ * Maps messages, tools, tool choice and response format; toggles
265
+ * `stream`/`stream_options` for streaming; and applies o-series reasoning rules,
266
+ * where the effort level is set and `max_tokens` is moved to
267
+ * `max_completion_tokens` while `temperature` and `top_p` are dropped (those
268
+ * models reject them).
269
+ *
270
+ * @param req - The provider-neutral galdor request to translate.
271
+ * @param stream - When true, request a streamed response with usage included.
272
+ * @returns The wire-ready {@link ChatRequest}.
273
+ * @throws {Error} When `req.model` is empty, or a message contains an unknown
274
+ * role or unsupported content type.
275
+ * @example
276
+ * ```ts
277
+ * const wire = buildRequest({ model: "gpt-4o-mini", messages }, false);
278
+ * ```
279
+ */
280
+ export function buildRequest(req: Request, stream: boolean): ChatRequest {
281
+ if (req.model === "") throw invalidRequest("openai: model is required");
282
+
283
+ const out: ChatRequest = { model: req.model, messages: req.messages.map(messageToWire) };
284
+ if (req.maxTokens !== undefined) out.max_tokens = req.maxTokens;
285
+ if (req.temperature !== undefined) out.temperature = req.temperature;
286
+ if (req.topP !== undefined) out.top_p = req.topP;
287
+ // Only emit `stop` when non-empty; an empty array is a no-op some backends reject.
288
+ if (req.stopSequences && req.stopSequences.length > 0) out.stop = req.stopSequences;
289
+ if (stream) {
290
+ out.stream = true;
291
+ out.stream_options = { include_usage: true };
292
+ }
293
+
294
+ if (req.tools && req.tools.length > 0) {
295
+ out.tools = req.tools.map((t) => ({
296
+ type: "function",
297
+ function: { name: t.name, ...(t.description ? { description: t.description } : {}), parameters: t.schema },
298
+ }));
299
+ }
300
+
301
+ const tc = toolChoiceToWire(req.toolChoice);
302
+ if (tc) out.tool_choice = tc;
303
+
304
+ const rf = responseFormatToWire(req.responseFormat);
305
+ if (rf) out.response_format = rf;
306
+
307
+ if (req.reasoning?.enabled) {
308
+ // OpenAI is effort-based (o-series): map the effort level, defaulting to
309
+ // medium. Budget is ignored.
310
+ out.reasoning_effort = req.reasoning.effort ?? "medium";
311
+ // o-series reasoning models reject max_tokens (use max_completion_tokens)
312
+ // and reject temperature / top_p. Move and drop them so the request is
313
+ // accepted.
314
+ if (out.max_tokens !== undefined) out.max_completion_tokens = out.max_tokens;
315
+ delete out.max_tokens;
316
+ delete out.temperature;
317
+ delete out.top_p;
318
+ }
319
+
320
+ const uid = req.metadata?.user_id;
321
+ if (uid) out.user = uid;
322
+
323
+ return out;
324
+ }
325
+
326
+ // ── Response decoding ────────────────────────────────────────────────────────
327
+
328
+ /** Concatenate text from either content form (plain string or part array). */
329
+ function decodeContent(content: string | WireContentPart[] | undefined): string {
330
+ if (content === undefined) return "";
331
+ if (typeof content === "string") return content;
332
+ let out = "";
333
+ for (const p of content) {
334
+ if (p.type === "text" && p.text) out += p.text;
335
+ }
336
+ return out;
337
+ }
338
+
339
+ function parseArguments(raw: string | undefined): JSONValue {
340
+ if (!raw || raw === "") return {};
341
+ try {
342
+ return JSON.parse(raw) as JSONValue;
343
+ } catch {
344
+ return raw;
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Convert OpenAI's wire usage block into galdor's {@link Usage} shape.
350
+ *
351
+ * @param u - The wire usage object, or `undefined` when absent.
352
+ * @returns A usage record; missing fields default to 0. Cached prompt tokens are
353
+ * reported as cache reads; cache-creation tokens are always 0.
354
+ */
355
+ export function usageFromWire(u: WireUsage | undefined) {
356
+ return {
357
+ inputTokens: u?.prompt_tokens ?? 0,
358
+ outputTokens: u?.completion_tokens ?? 0,
359
+ cacheCreationTokens: 0,
360
+ cacheReadTokens: u?.prompt_tokens_details?.cached_tokens ?? 0,
361
+ };
362
+ }
363
+
364
+ /**
365
+ * Map an OpenAI `finish_reason` string to a galdor {@link StopReason}.
366
+ *
367
+ * @param s - The wire finish reason (e.g. `"stop"`, `"length"`, `"tool_calls"`).
368
+ * @returns The corresponding {@link StopReason}; an empty or absent value
369
+ * becomes `"end_turn"`, and an unrecognized value is passed through unchanged.
370
+ */
371
+ export function normalizeFinishReason(s: string | undefined): StopReason {
372
+ switch (s) {
373
+ case "stop":
374
+ return "end_turn";
375
+ case "length":
376
+ return "max_tokens";
377
+ case "tool_calls":
378
+ case "function_call":
379
+ return "tool_use";
380
+ case "content_filter":
381
+ return "refusal";
382
+ default:
383
+ return (s === undefined || s === "" ? "end_turn" : s) as StopReason;
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Collapse a non-streaming Chat Completions response into a galdor
389
+ * {@link Response}.
390
+ *
391
+ * Reads the first choice: surfaces any reasoning content as a thinking part,
392
+ * appends decoded text, and collects tool calls (parsing their JSON arguments).
393
+ *
394
+ * @param r - The decoded wire response.
395
+ * @param raw - Optional raw response bytes, attached as `providerRaw` when given.
396
+ * @returns The assembled galdor {@link Response} with message, stop reason,
397
+ * usage and model.
398
+ */
399
+ export function responseFromWire(r: ChatResponse, raw?: Uint8Array): Response {
400
+ const message: Message = { role: Role.Assistant, content: [] };
401
+ let stopReason: StopReason = "end_turn";
402
+
403
+ const choice = r.choices?.[0];
404
+ if (choice) {
405
+ stopReason = normalizeFinishReason(choice.finish_reason);
406
+
407
+ if (choice.message.reasoning_content) {
408
+ // Reasoning from an OpenAI-compatible model (e.g. DeepSeek). messageText
409
+ // skips it, so the answer stays clean.
410
+ message.content.push(thinkingPart(choice.message.reasoning_content));
411
+ }
412
+ const text = decodeContent(choice.message.content);
413
+ if (text !== "") message.content.push(textPart(text));
414
+
415
+ const toolCalls: ToolCall[] = [];
416
+ for (const t of choice.message.tool_calls ?? []) {
417
+ toolCalls.push({ id: t.id ?? "", name: t.function.name ?? "", arguments: parseArguments(t.function.arguments) });
418
+ }
419
+ if (toolCalls.length > 0) message.toolCalls = toolCalls;
420
+ }
421
+
422
+ return {
423
+ message,
424
+ stopReason,
425
+ usage: usageFromWire(r.usage),
426
+ model: r.model ?? "",
427
+ ...(raw ? { providerRaw: raw } : {}),
428
+ };
429
+ }