@cyrilmarin/dsh-lemonade 0.2.1

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.
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Host proxy for the Lemonade-specific API (https://lemonade-server.ai/docs/api/lemonade/),
3
+ * mounted by src/index.ts on the dsh web server under the /dsh-lemonade/api
4
+ * prefix route (ctx.webServer.register). The browser client half calls these
5
+ * routes same-origin; the host resolves the base URL from the llm-lemonade
6
+ * settings section and the optional API key through the credentials seam, so
7
+ * the key never reaches the browser.
8
+ *
9
+ * @module dsh-lemonade-provider/server-api
10
+ */
11
+ import type { IncomingMessage, ServerResponse } from 'node:http';
12
+ import type { CredentialRef } from '@deepseek-ai/dsh-credentials';
13
+ import { attributionHeaders } from '@deepseek-ai/dsh-llm';
14
+ import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout';
15
+
16
+ /** Route prefix registered on ctx.webServer. */
17
+ export const API_ROUTE = '/dsh-lemonade/api';
18
+ /** Maximum accepted request body and proxied response body in bytes. */
19
+ export const MAX_BODY_BYTES = 1_000_000;
20
+ /** Fetch timeout for proxied Lemonade calls. */
21
+ export const API_TIMEOUT_MS = 10_000;
22
+ const TIMEOUT_CODE = 'LEMONADE_API_TIMEOUT';
23
+
24
+ /** Host-side connection facts the proxy resolves per request. */
25
+ export interface LemonadeApiConfig {
26
+ baseURL(): string;
27
+ requireAuth(): boolean;
28
+ /** Credential reference for the regular API key (LEMONADE_API_KEY). */
29
+ apiKeyRef(): CredentialRef;
30
+ /** Credential reference for the admin API key (LEMONADE_ADMIN_API_KEY). */
31
+ adminApiKeyRef(): CredentialRef;
32
+ /**
33
+ * Resolve one credential reference to its current value (never throws).
34
+ * @param ref - the reference to resolve through the credentials seam / env.
35
+ * @returns the usable key, or undefined when unconfigured.
36
+ */
37
+ resolveKey(ref: CredentialRef): Promise<string | undefined>;
38
+ }
39
+
40
+ /** Successful wire result. */
41
+ export interface LemonadeWireOk {
42
+ ok: true;
43
+ value: unknown;
44
+ }
45
+ /** Failed wire result. */
46
+ export interface LemonadeWireError {
47
+ ok: false;
48
+ error: { message: string; code: string; status?: number };
49
+ }
50
+ export type LemonadeWireResult = LemonadeWireOk | LemonadeWireError;
51
+
52
+ const okResult = (value: unknown): LemonadeWireResult => ({ ok: true, value });
53
+ const errResult = (message: string, code: string, status?: number): LemonadeWireResult => ({
54
+ ok: false,
55
+ error: { message, code, ...(status === undefined ? {} : { status }) },
56
+ });
57
+
58
+ /** Map a Lemonade HTTP status to a stable harness-style code. */
59
+ export function mapLemonadeStatus(status: number): string {
60
+ if (status === 401 || status === 403) return 'AUTH';
61
+ if (status === 429) return 'RATE_LIMIT';
62
+ if (status === 409) return 'CONFLICT';
63
+ if (status === 400) return 'INVALID_REQUEST';
64
+ if (status >= 500) return 'SERVER';
65
+ return 'HTTP_' + status;
66
+ }
67
+
68
+ /** Client-input error normalized to a wire result. */
69
+ /** Ops that target internal/control endpoints, authenticated with the admin key. */
70
+ const ADMIN_OPS = new Set([
71
+ 'metrics',
72
+ 'internalTelemetryFlush',
73
+ 'internalAliases',
74
+ 'internalAliasesSet',
75
+ 'internalAliasesDelete',
76
+ ]);
77
+ const isAdminOp = (op: string): boolean => ADMIN_OPS.has(op);
78
+
79
+ class RequestError extends Error {
80
+ readonly code: string;
81
+ readonly status: number;
82
+ constructor(message: string, code: string, status: number) {
83
+ super(message);
84
+ this.code = code;
85
+ this.status = status;
86
+ }
87
+ }
88
+
89
+ function asRecord(value: unknown): Record<string, unknown> {
90
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
91
+ ? (value as Record<string, unknown>)
92
+ : {};
93
+ }
94
+
95
+ /** Build the stable Lemonade endpoint for one op. */
96
+ function resolveTarget(
97
+ op: string,
98
+ args: readonly string[],
99
+ query: URLSearchParams,
100
+ body: unknown,
101
+ ): { method: 'GET' | 'POST' | 'DELETE'; url: string; body?: unknown } {
102
+ const record = asRecord(body);
103
+ const pick = (keys: readonly string[]): Record<string, unknown> => {
104
+ const out: Record<string, unknown> = {};
105
+ for (const key of keys) if (record[key] !== undefined) out[key] = record[key];
106
+ return out;
107
+ };
108
+ const qs = (extra: Record<string, string | undefined>): string => {
109
+ const params = new URLSearchParams();
110
+ for (const [key, value] of Object.entries(extra)) {
111
+ if (value !== undefined && value.length > 0) params.set(key, value);
112
+ }
113
+ const text = params.toString();
114
+ return text.length > 0 ? '?' + text : '';
115
+ };
116
+ const str = (value: unknown): string | undefined => (typeof value === 'string' && value.length > 0 ? value : undefined);
117
+ /**
118
+ * The Lemonade model-management endpoints take the model id under
119
+ * `model_name` (the spec: load/unload/delete). This client historically sent
120
+ * `model`; accept both and forward the canonical `model_name`.
121
+ */
122
+ const modelName = (): string | undefined => {
123
+ const named = record.model_name;
124
+ if (typeof named === 'string' && named.length > 0) return named;
125
+ const alias = record.model;
126
+ return typeof alias === 'string' && alias.length > 0 ? alias : undefined;
127
+ };
128
+ switch (op) {
129
+ case 'health': return { method: 'GET', url: '/v1/health' };
130
+ case 'live': return { method: 'GET', url: '/live' };
131
+ case 'models':
132
+ return { method: 'GET', url: '/v1/models' + qs({ show_all: str(query.get('show_all') ?? record.show_all) }) };
133
+ case 'modelFiles': {
134
+ const id = args[0];
135
+ if (id === undefined || id.length === 0) throw new RequestError('model id required', 'INVALID_REQUEST', 400);
136
+ return { method: 'GET', url: '/v1/models/' + encodeURIComponent(id) + '/files' };
137
+ }
138
+ case 'load': {
139
+ const mn = modelName();
140
+ if (mn === undefined) throw new RequestError('model required', 'INVALID_REQUEST', 400);
141
+ return {
142
+ method: 'POST',
143
+ url: '/v1/load',
144
+ body: {
145
+ model_name: mn,
146
+ ...pick(['pinned', 'save_options', 'ctx_size', 'llamacpp_backend', 'llamacpp_args', 'whispercpp_backend', 'whispercpp_args', 'steps', 'cfg_scale', 'width', 'height', 'merge_args']),
147
+ },
148
+ };
149
+ }
150
+ case 'unload': {
151
+ const mn = modelName();
152
+ // model_name optional: omitted unloads every loaded model (spec).
153
+ return { method: 'POST', url: '/v1/unload', body: mn === undefined ? {} : { model_name: mn } };
154
+ }
155
+ case 'delete': {
156
+ const mn = modelName();
157
+ if (mn === undefined) throw new RequestError('model required', 'INVALID_REQUEST', 400);
158
+ return { method: 'POST', url: '/v1/delete', body: { model_name: mn } };
159
+ }
160
+ case 'checkUpdates': return { method: 'POST', url: '/v1/models/check-updates' };
161
+ case 'registrySearch':
162
+ return {
163
+ method: 'GET',
164
+ url: '/v1/registry/search' + qs({
165
+ query: str(query.get('query') ?? query.get('q') ?? record.query),
166
+ source: str(query.get('source') ?? record.source),
167
+ format: str(query.get('format') ?? record.format),
168
+ limit: str(query.get('limit') ?? (record.limit !== undefined ? String(record.limit) : undefined)),
169
+ }),
170
+ };
171
+ case 'pullVariants':
172
+ return {
173
+ method: 'GET',
174
+ url: '/v1/pull/variants' + qs({
175
+ checkpoint: str(query.get('checkpoint') ?? record.checkpoint),
176
+ }),
177
+ };
178
+ case 'pull':
179
+ // Spec: model_name (Yes, user.* namespace), recipe (Yes), checkpoint (Yes*),
180
+ // plus checkpoints dict, reasoning/vision/embedding/reranking, mmproj.
181
+ return {
182
+ method: 'POST',
183
+ url: '/v1/pull',
184
+ body: pick(['model_name', 'recipe', 'checkpoint', 'checkpoints', 'reasoning', 'vision', 'embedding', 'reranking', 'mmproj', 'stream', 'subscribe']),
185
+ };
186
+ case 'downloads': return { method: 'GET', url: '/v1/downloads' };
187
+ case 'downloadsControl': return { method: 'POST', url: '/v1/downloads/control', body: pick(['id', 'action']) };
188
+ case 'stats': return { method: 'GET', url: '/v1/stats' };
189
+ case 'systemStats': return { method: 'GET', url: '/v1/system-stats' };
190
+ case 'systemInfo': return { method: 'GET', url: '/v1/system-info' };
191
+ case 'cloudAuthSet': return { method: 'POST', url: '/v1/cloud/auth', body: pick(['provider', 'api_key']) };
192
+ case 'cloudAuthDelete': {
193
+ const provider = args[0];
194
+ if (provider === undefined || provider.length === 0) throw new RequestError('provider required', 'INVALID_REQUEST', 400);
195
+ return { method: 'DELETE', url: '/v1/cloud/auth/' + encodeURIComponent(provider) };
196
+ }
197
+ // Admin / internal endpoints (authenticated with the admin API key).
198
+ case 'metrics':
199
+ return { method: 'GET', url: '/metrics' };
200
+ case 'internalTelemetryFlush':
201
+ return { method: 'POST', url: '/internal/telemetry/flush' };
202
+ case 'internalAliases':
203
+ return { method: 'GET', url: '/internal/aliases' };
204
+ case 'internalAliasesSet':
205
+ return { method: 'POST', url: '/internal/aliases', body: pick(['alias', 'target', 'model']) };
206
+ case 'internalAliasesDelete': {
207
+ const alias = args[0];
208
+ if (alias === undefined || alias.length === 0) throw new RequestError('alias required', 'INVALID_REQUEST', 400);
209
+ return { method: 'DELETE', url: '/internal/aliases/' + encodeURIComponent(alias) };
210
+ }
211
+ default: throw new RequestError('unknown Lemonade api op: ' + op, 'NOT_FOUND', 404);
212
+ }
213
+ }
214
+
215
+ async function readResponseText(response: Response): Promise<{ text: string; tooLarge: boolean }> {
216
+ const buffer = await response.arrayBuffer();
217
+ if (buffer.byteLength > MAX_BODY_BYTES) return { text: '', tooLarge: true };
218
+ return { text: new TextDecoder().decode(buffer), tooLarge: false };
219
+ }
220
+
221
+ /**
222
+ * Dispatch one proxied Lemonade API call.
223
+ * @param cfg - connection facts (thunks resolved per call).
224
+ * @param method - HTTP method from the client (GET/POST/DELETE).
225
+ * @param op - first path segment after the route prefix.
226
+ * @param segments - remaining path segments after the route prefix.
227
+ * @param query - parsed query string.
228
+ * @param body - parsed request body (undefined when none).
229
+ * @param signal - optional caller cancellation.
230
+ */
231
+ export async function serveLemonadeApi(
232
+ cfg: LemonadeApiConfig,
233
+ method: string,
234
+ op: string,
235
+ args: readonly string[],
236
+ query: URLSearchParams,
237
+ body: unknown,
238
+ signal?: AbortSignal,
239
+ ): Promise<LemonadeWireResult> {
240
+ let target: { method: 'GET' | 'POST' | 'DELETE'; url: string; body?: unknown };
241
+ try {
242
+ target = resolveTarget(op, args, query, body);
243
+ } catch (error) {
244
+ if (error instanceof RequestError) return errResult(error.message, error.code, error.status);
245
+ throw error;
246
+ }
247
+ if (method !== target.method) {
248
+ return errResult(
249
+ 'method ' + method + ' not allowed for ' + op + ' (expected ' + target.method + ')',
250
+ 'METHOD_NOT_ALLOWED',
251
+ 405,
252
+ );
253
+ }
254
+ // Per-endpoint key selection: internal/control endpoints (/internal/*, /metrics)
255
+ // authenticate with the admin key (falling back to the regular key, which
256
+ // lemonade accepts for /metrics); regular endpoints use the regular key (the
257
+ // admin key is a superior credential and also works when it is the only one set).
258
+ const admin = isAdminOp(op);
259
+ const regularKey = await cfg.resolveKey(cfg.apiKeyRef());
260
+ const adminKey = await cfg.resolveKey(cfg.adminApiKeyRef());
261
+ const apiKey = admin ? (adminKey ?? regularKey) : (regularKey ?? adminKey);
262
+ if (!admin && apiKey === undefined && cfg.requireAuth()) {
263
+ return errResult(
264
+ 'llm-lemonade: the Lemonade server requires a key (requireAuth) and none is set; store LEMONADE_API_KEY or LEMONADE_ADMIN_API_KEY via Settings > Models > Lemonade',
265
+ 'MISSING_CREDENTIAL',
266
+ );
267
+ }
268
+ const configured = (cfg.baseURL() || '').replace(/\/+$/, '').replace(/\/v1$/i, '');
269
+ // /internal/*, /live and /metrics are ROOT-level (no /api, no /v1) per spec;
270
+ // everything else is served under the /api prefix.
271
+ const isRootPath = target.url.startsWith('/internal/') || target.url === '/live' || target.url === '/metrics';
272
+ const base = isRootPath ? configured.replace(/\/api$/i, '') : configured;
273
+ const url = base + target.url;
274
+ const headers: Record<string, string> = { accept: 'application/json', ...attributionHeaders() };
275
+ if (apiKey !== undefined) headers.authorization = 'Bearer ' + apiKey;
276
+ if (target.body !== undefined) headers['content-type'] = 'application/json';
277
+
278
+ const timer = deadline(signal, API_TIMEOUT_MS, TIMEOUT_CODE);
279
+ let response: Response;
280
+ try {
281
+ response = await fetch(url, {
282
+ method: target.method,
283
+ headers,
284
+ ...(target.body === undefined ? {} : { body: JSON.stringify(target.body) }),
285
+ signal: timer.signal,
286
+ });
287
+ } catch (error) {
288
+ if (timeoutOf(timer.signal, TIMEOUT_CODE) !== undefined) {
289
+ return errResult('Lemonade API timeout after ' + API_TIMEOUT_MS + 'ms', 'TIMEOUT');
290
+ }
291
+ if (signal !== undefined && signal.aborted) return errResult('Lemonade request aborted by caller', 'ABORTED');
292
+ return errResult('could not reach ' + url, 'TRANSPORT');
293
+ } finally {
294
+ timer[Symbol.dispose]();
295
+ }
296
+
297
+ const decoded = await readResponseText(response);
298
+ if (decoded.tooLarge) {
299
+ return errResult('Lemonade response exceeds ' + MAX_BODY_BYTES + ' bytes', 'PAYLOAD_TOO_LARGE', 413);
300
+ }
301
+ let value: unknown = null;
302
+ if (decoded.text.length > 0) {
303
+ if (op === 'metrics') {
304
+ // Prometheus text exposition format, not JSON.
305
+ value = decoded.text;
306
+ } else {
307
+ try {
308
+ value = JSON.parse(decoded.text);
309
+ } catch {
310
+ return errResult('Lemonade answered with non-JSON at ' + url, 'BAD_RESPONSE', 502);
311
+ }
312
+ }
313
+ }
314
+ if (!response.ok) {
315
+ let message = 'Lemonade API error (HTTP ' + response.status + ')';
316
+ if (value !== null && typeof value === 'object') {
317
+ const record = asRecord(value);
318
+ const nested = record.error !== null && typeof record.error === 'object' ? asRecord(record.error) : undefined;
319
+ if (nested && typeof nested.message === 'string' && nested.message.length > 0) message = nested.message;
320
+ else if (typeof record.message === 'string' && record.message.length > 0) message = record.message;
321
+ }
322
+ return errResult(message, mapLemonadeStatus(response.status), response.status);
323
+ }
324
+ // Lemonade write endpoints (load/unload/delete/pull) answer HTTP 200 with
325
+ // { status: 'error', message } on failure; surface it as a wire error.
326
+ if (value !== null && typeof value === 'object') {
327
+ const record = value as Record<string, unknown>;
328
+ if (record.status === 'error' && typeof record.message === 'string' && record.message.length > 0) {
329
+ return errResult(record.message, 'LEMONADE_ERROR', response.status);
330
+ }
331
+ }
332
+ return okResult(value);
333
+ }
334
+
335
+ async function readRequestBody(req: IncomingMessage): Promise<unknown> {
336
+ const chunks: Buffer[] = [];
337
+ let size = 0;
338
+ for await (const chunk of req) {
339
+ size += chunk.length;
340
+ if (size > MAX_BODY_BYTES) throw new RequestError('request body exceeds ' + MAX_BODY_BYTES + ' bytes', 'PAYLOAD_TOO_LARGE', 413);
341
+ chunks.push(chunk);
342
+ }
343
+ if (chunks.length === 0) return undefined;
344
+ const text = Buffer.concat(chunks).toString('utf8');
345
+ if (text.trim().length === 0) return undefined;
346
+ try {
347
+ return JSON.parse(text);
348
+ } catch {
349
+ throw new RequestError('request body is not valid JSON', 'INVALID_REQUEST', 400);
350
+ }
351
+ }
352
+
353
+ function writeJson(res: ServerResponse, status: number, value: unknown): void {
354
+ res.writeHead(status, {
355
+ 'content-type': 'application/json; charset=utf-8',
356
+ 'cache-control': 'no-store',
357
+ });
358
+ res.end(JSON.stringify(value));
359
+ }
360
+
361
+ /**
362
+ * Stream the Lemonade server logs (WS /logs/stream) as newline-delimited JSON
363
+ * to the browser. The spec: the log WebSocket shares the Realtime Audio port,
364
+ * discovered via /v1/health (websocket_port) — not the main HTTP port — then
365
+ * `ws://<host>:<port>/logs/stream`, subscribe with `{ type: 'logs.subscribe',
366
+ * after_seq: <int|null> }`, and the server answers `logs.snapshot` (up to
367
+ * 5000 retained entries) then `logs.entry` lines. Messages are relayed as-is
368
+ * (`{ type: 'logs.snapshot' | 'logs.entry' | 'error', ... }`); the response is
369
+ * held open and closed when the browser disconnects.
370
+ */
371
+
372
+ /**
373
+ * Build the node:http handler mounting the Lemonade-specific API proxy at the
374
+ * /dsh-lemonade/api prefix route (ctx.webServer.register). Never throws out:
375
+ * every outcome is normalized to a JSON wire result.
376
+ */
377
+ export function createLemonadeApiHandler(
378
+ cfg: LemonadeApiConfig,
379
+ ): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
380
+ return async (req, res) => {
381
+ let result: LemonadeWireResult;
382
+ try {
383
+ const url = new URL(req.url ?? '/', 'http://localhost');
384
+ const rest = url.pathname.startsWith(API_ROUTE) ? url.pathname.slice(API_ROUTE.length) : url.pathname;
385
+ const segments = rest.split('/').filter((part) => part.length > 0);
386
+ const op = segments[0] ?? '';
387
+ const args = segments.slice(1);
388
+ const body =
389
+ req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH'
390
+ ? await readRequestBody(req)
391
+ : undefined;
392
+ result = await serveLemonadeApi(cfg, req.method ?? 'GET', op, args, url.searchParams, body);
393
+ } catch (error) {
394
+ result =
395
+ error instanceof RequestError
396
+ ? errResult(error.message, error.code, error.status)
397
+ : errResult('Lemonade API proxy failed: ' + String((error as Error)?.message ?? error), 'SERVER', 500);
398
+ }
399
+ const status = result.ok ? 200 : (result.error.status ?? 500);
400
+ writeJson(res, status, result);
401
+ };
402
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Translate Lemonade's OpenAI-compatible SSE payloads into the harness
3
+ * `StreamChunk` protocol.
4
+ *
5
+ * One stateful block is kept per content, reasoning, or tool-call index; an
6
+ * empty initial delta does not open a block. Block-ends, usage, and finish are
7
+ * deferred to the terminal emission at the end of the payload stream, so no
8
+ * chunk ever follows `finish`.
9
+ *
10
+ * Lemonade's docs do not guarantee the `[DONE]` sentinel, so a clean EOF
11
+ * (the payload iterator simply ends) is treated as a normal completion, unlike
12
+ * providers that promise the sentinel. `[DONE]`, when present, is consumed
13
+ * and skipped.
14
+ *
15
+ * @module dsh-lemonade-provider/translate
16
+ */
17
+ import { CallId, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm';
18
+ import type { ContentBlock, FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm';
19
+ import { EventSourceParserStream } from 'eventsource-parser/stream';
20
+
21
+ /** Parse an SSE byte stream into its `data` payloads. */
22
+ export async function* parseSse(
23
+ stream: ReadableStream<Uint8Array>,
24
+ onComment?: (comment: string) => void,
25
+ ): AsyncGenerator<string> {
26
+ const events = stream
27
+ .pipeThrough(new TextDecoderStream())
28
+ .pipeThrough(new EventSourceParserStream({ onComment }));
29
+ for await (const { data } of events) {
30
+ yield data;
31
+ if (data === '[DONE]') return;
32
+ }
33
+ }
34
+
35
+ /** One in-progress harness block while assembling the terminal emission. */
36
+ interface OpenBlock {
37
+ index: number;
38
+ kind: 'text' | 'reasoning' | 'tool-call';
39
+ text: string;
40
+ callId?: string;
41
+ name?: string;
42
+ }
43
+
44
+ /**
45
+ * Map the wire `finish_reason` vocabulary to the harness FinishReason.
46
+ * Unrecognized values (content_filter, …) become a severity-typed error
47
+ * finish with the uppercased value as the code.
48
+ */
49
+ function mapFinishReason(reason: string): FinishReason {
50
+ switch (reason) {
51
+ case 'stop': return { kind: 'stop' };
52
+ case 'tool_calls': return { kind: 'tool-calls' };
53
+ case 'length': return { kind: 'max-tokens' };
54
+ default: return { kind: 'error', failure: { message: `model stopped: ${reason}`, code: reason.toUpperCase() } };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Map wire usage fields. OpenAI's `prompt_tokens` is a TOTAL that includes
60
+ * cache hits (`prompt_tokens_details.cached_tokens`); the harness TokenUsage
61
+ * convention is DISJOINT counts, so cache reads are subtracted out.
62
+ */
63
+ function mapUsage(usage: Record<string, unknown>): TokenUsage {
64
+ const details = usage['prompt_tokens_details'] as Record<string, unknown> | undefined;
65
+ const completionDetails = usage['completion_tokens_details'] as Record<string, unknown> | undefined;
66
+ const cacheRead = typeof details?.['cached_tokens'] === 'number' ? details['cached_tokens'] : undefined;
67
+ const reasoning = typeof completionDetails?.['reasoning_tokens'] === 'number' ? completionDetails['reasoning_tokens'] : undefined;
68
+ return {
69
+ inputTokens: (usage['prompt_tokens'] as number) - (cacheRead ?? 0),
70
+ outputTokens: usage['completion_tokens'] as number,
71
+ ...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}),
72
+ ...(reasoning !== undefined ? { reasoningTokens: reasoning } : {}),
73
+ };
74
+ }
75
+
76
+ /** Assemble the final ContentBlock for one open block. */
77
+ function closeBlock(block: OpenBlock): ContentBlock {
78
+ switch (block.kind) {
79
+ case 'text': return { type: 'text', text: block.text };
80
+ case 'reasoning': return { type: 'reasoning', text: block.text };
81
+ case 'tool-call': return {
82
+ type: 'tool-call',
83
+ id: CallId(block.callId ?? ''),
84
+ name: block.name ?? '',
85
+ arguments: block.text,
86
+ };
87
+ }
88
+ }
89
+
90
+ interface WireChunk {
91
+ choices?: {
92
+ delta?: {
93
+ content?: unknown;
94
+ reasoning_content?: unknown;
95
+ tool_calls?: {
96
+ index?: number;
97
+ id?: unknown;
98
+ function?: { name?: unknown; arguments?: unknown };
99
+ }[];
100
+ };
101
+ finish_reason?: unknown;
102
+ }[];
103
+ usage?: Record<string, unknown>;
104
+ }
105
+
106
+ /**
107
+ * Consume SSE data payloads (optionally ending with `[DONE]`) and yield
108
+ * harness StreamChunks. Malformed JSON payloads abort the stream with
109
+ * `MALFORMED_RESPONSE`. A `stop` (or absent) finish with no opened blocks is a
110
+ * degenerate provider completion and maps to an `EMPTY_RESPONSE` error finish.
111
+ */
112
+ export async function* translate(payloads: AsyncIterable<string>): AsyncGenerator<StreamChunk> {
113
+ let nextIndex = 0;
114
+ let textBlock: OpenBlock | undefined;
115
+ let reasoningBlock: OpenBlock | undefined;
116
+ const toolBlocks = new Map<number, OpenBlock>();
117
+ const order: OpenBlock[] = [];
118
+ let pendingFinish: FinishReason | undefined;
119
+ let pendingUsage: TokenUsage | undefined;
120
+
121
+ function open(kind: OpenBlock['kind']): OpenBlock {
122
+ const block: OpenBlock = { index: nextIndex++, kind, text: '' };
123
+ order.push(block);
124
+ return block;
125
+ }
126
+
127
+ for await (const payload of payloads) {
128
+ if (payload === '[DONE]') continue;
129
+ let chunk: WireChunk;
130
+ try {
131
+ chunk = JSON.parse(payload) as WireChunk;
132
+ } catch {
133
+ throw new LlmError(`malformed SSE payload: ${payload.slice(0, 120)}`, 'MALFORMED_RESPONSE');
134
+ }
135
+ for (const choice of chunk.choices ?? []) {
136
+ const delta = choice.delta ?? {};
137
+ const reasoning = delta.reasoning_content;
138
+ if (typeof reasoning === 'string' && reasoning.length > 0) {
139
+ if (!reasoningBlock) {
140
+ reasoningBlock = open('reasoning');
141
+ yield { type: 'block-start', index: reasoningBlock.index, blockType: 'reasoning' };
142
+ }
143
+ reasoningBlock.text += reasoning;
144
+ yield { type: 'reasoning-delta', index: reasoningBlock.index, text: reasoning };
145
+ }
146
+ const content = delta.content;
147
+ if (typeof content === 'string' && content.length > 0) {
148
+ if (!textBlock) {
149
+ textBlock = open('text');
150
+ yield { type: 'block-start', index: textBlock.index, blockType: 'text' };
151
+ }
152
+ textBlock.text += content;
153
+ yield { type: 'text-delta', index: textBlock.index, text: content };
154
+ }
155
+ for (const call of delta.tool_calls ?? []) {
156
+ const callIndex = call.index ?? toolBlocks.size;
157
+ let block = toolBlocks.get(callIndex);
158
+ if (!block) {
159
+ block = open('tool-call');
160
+ toolBlocks.set(callIndex, block);
161
+ yield { type: 'block-start', index: block.index, blockType: 'tool-call' };
162
+ }
163
+ if (typeof call.id === 'string') block.callId = call.id;
164
+ if (typeof call.function?.name === 'string') block.name = call.function.name;
165
+ const fragment = typeof call.function?.arguments === 'string' ? call.function.arguments : '';
166
+ block.text += fragment;
167
+ yield {
168
+ type: 'tool-call-delta',
169
+ index: block.index,
170
+ id: CallId(block.callId ?? ''),
171
+ ...(block.name !== undefined ? { name: block.name } : {}),
172
+ argumentsDelta: fragment,
173
+ };
174
+ }
175
+ if (typeof choice.finish_reason === 'string') pendingFinish = mapFinishReason(choice.finish_reason);
176
+ }
177
+ if (chunk.usage) pendingUsage = mapUsage(chunk.usage);
178
+ }
179
+
180
+ // Terminal emission: nothing follows the finish chunk.
181
+ for (const block of order) yield { type: 'block-end', index: block.index, block: closeBlock(block) };
182
+ if (pendingUsage) yield { type: 'usage', usage: pendingUsage };
183
+ const reason: FinishReason = pendingFinish ?? { kind: 'stop' };
184
+ yield {
185
+ type: 'finish',
186
+ reason: reason.kind === 'stop' && order.length === 0
187
+ ? { kind: 'error', failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE } }
188
+ : reason,
189
+ };
190
+ }