@sigil0/looking-glass 0.2.4 → 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.
@@ -1,14 +1,43 @@
1
- import OpenAI from "openai";
2
- import { redactSensitiveText } from "../security.js";
1
+ import { providerError, providerHttpError } from "../errors.js";
2
+ // Current llama.cpp grammar generation can emit invalid GBNF for nested string
3
+ // maxLength constraints at or above 2000. Keep the original schemas for local
4
+ // validation and only relax the cloned schema sent to LM Studio.
5
+ function omitLmStudioLongMaxLengths(value) {
6
+ if (Array.isArray(value)) {
7
+ for (const item of value)
8
+ omitLmStudioLongMaxLengths(item);
9
+ return;
10
+ }
11
+ if (!value || typeof value !== "object")
12
+ return;
13
+ const object = value;
14
+ for (const [key, child] of Object.entries(object)) {
15
+ if (key === "maxLength" && typeof child === "number" && child >= 2_000) {
16
+ delete object[key];
17
+ continue;
18
+ }
19
+ omitLmStudioLongMaxLengths(child);
20
+ }
21
+ }
22
+ function lmStudioTools(tools) {
23
+ return tools.map((tool) => {
24
+ const parameters = tool.parameters === null ? null : structuredClone(tool.parameters);
25
+ if (parameters !== null)
26
+ omitLmStudioLongMaxLengths(parameters);
27
+ return { ...tool, parameters };
28
+ });
29
+ }
3
30
  export function buildResponseParams(provider, request) {
31
+ if (provider === "openrouter")
32
+ return buildOpenRouterParams(request);
4
33
  const common = {
5
34
  model: request.model,
6
35
  instructions: request.instructions,
7
36
  input: provider === "lm-studio"
8
37
  ? request.input.filter((item) => item.type !== "reasoning")
9
38
  : request.input,
10
- tools: request.tools,
11
- parallel_tool_calls: true,
39
+ tools: provider === "lm-studio" ? lmStudioTools(request.tools) : request.tools,
40
+ ...(request.supportsParallelToolCalls ? { parallel_tool_calls: true } : {}),
12
41
  ...(request.supportsReasoning ? {
13
42
  reasoning: {
14
43
  // codex-lb advertises "ultra" before the public SDK type includes it.
@@ -38,14 +67,14 @@ function detailFrom(value) {
38
67
  const outer = value;
39
68
  return outer.error && typeof outer.error === "object" ? outer.error : outer;
40
69
  }
41
- async function* responseEvents(response) {
70
+ async function* responseEvents(response, context) {
42
71
  if (!response.body)
43
- throw new Error("Response stream had no body");
44
- const reader = response.body.getReader();
72
+ throw providerError({ code: "malformed_response", message: "response had no body" }, { ...(context ?? { provider: "provider", operation: "stream" }), protocol: true });
73
+ const streamReader = response.body.getReader();
45
74
  const decoder = new TextDecoder();
46
75
  let buffer = "";
47
76
  let data = [];
48
- const parse = () => {
77
+ const parseResponseEvent = () => {
49
78
  const payload = data.join("\n");
50
79
  data = [];
51
80
  if (!payload || payload === "[DONE]")
@@ -58,20 +87,20 @@ async function* responseEvents(response) {
58
87
  return event;
59
88
  }
60
89
  catch (error) {
61
- throw Object.assign(new Error(`Malformed response stream event: ${error instanceof Error ? error.message : String(error)}`), {
90
+ throw providerError(Object.assign(new Error(`Malformed response stream event: ${error instanceof Error ? error.message : String(error)}`), {
62
91
  code: "malformed_response_event",
63
- });
92
+ }), { ...(context ?? { provider: "provider", operation: "stream" }), protocol: true });
64
93
  }
65
94
  };
66
95
  while (true) {
67
- const chunk = await reader.read();
96
+ const chunk = context ? await readStreamChunk(streamReader, context) : await streamReader.read();
68
97
  buffer += decoder.decode(chunk.value ?? new Uint8Array(), { stream: !chunk.done });
69
98
  let newline = buffer.indexOf("\n");
70
99
  while (newline >= 0) {
71
100
  const line = buffer.slice(0, newline).replace(/\r$/, "");
72
101
  buffer = buffer.slice(newline + 1);
73
102
  if (line === "") {
74
- const event = parse();
103
+ const event = parseResponseEvent();
75
104
  if (event)
76
105
  yield event;
77
106
  }
@@ -85,7 +114,7 @@ async function* responseEvents(response) {
85
114
  }
86
115
  if (buffer.startsWith("data:"))
87
116
  data.push(buffer.slice(5).trimStart());
88
- const event = parse();
117
+ const event = parseResponseEvent();
89
118
  if (event)
90
119
  yield event;
91
120
  }
@@ -175,6 +204,42 @@ export function lmStudioModelInfo(raw) {
175
204
  priority: raw.loaded_instances?.length ? 0 : 1_000,
176
205
  };
177
206
  }
207
+ function numericPrice(value) {
208
+ if (typeof value === "number" && Number.isFinite(value))
209
+ return value;
210
+ if (typeof value !== "string" || value.trim() === "")
211
+ return null;
212
+ const parsed = Number(value);
213
+ return Number.isFinite(parsed) ? parsed : null;
214
+ }
215
+ export function openRouterModelInfo(raw) {
216
+ const supported = new Set(raw.supported_parameters ?? []);
217
+ const architecture = raw.architecture ?? {};
218
+ const pricing = raw.pricing ?? {};
219
+ const isFree = raw.id.toLowerCase().endsWith(":free")
220
+ || (numericPrice(pricing.prompt) === 0 && numericPrice(pricing.completion) === 0);
221
+ const supportsReasoning = supported.has("reasoning");
222
+ const supportsImages = (architecture.input_modalities ?? []).some((value) => /image/i.test(value));
223
+ const supportsTools = supported.has("tools") || supported.has("tool_choice")
224
+ || supported.has("parallel_tool_calls") || supported.has("function_calling");
225
+ return {
226
+ id: raw.id,
227
+ name: raw.name ?? raw.id,
228
+ description: raw.description ?? "",
229
+ contextWindow: raw.context_length ?? raw.top_provider?.context_length ?? 128_000,
230
+ maxOutputTokens: raw.max_completion_tokens ?? raw.top_provider?.max_completion_tokens
231
+ ?? raw.max_output_tokens ?? null,
232
+ reasoningEfforts: supportsReasoning ? ["low", "medium", "high"] : ["none"],
233
+ defaultReasoningEffort: supportsReasoning ? "medium" : "none",
234
+ defaultVerbosity: "low",
235
+ supportsReasoning,
236
+ supportsImages,
237
+ supportsParallelToolCalls: supportsTools && supported.has("parallel_tool_calls"),
238
+ supportsFast: false,
239
+ priority: isFree ? 0 : 1_000,
240
+ isFree,
241
+ };
242
+ }
178
243
  export function responseText(response) {
179
244
  if (response.output_text)
180
245
  return response.output_text;
@@ -229,40 +294,285 @@ function lmStudioModelsURL(baseURL) {
229
294
  url.pathname = `${url.pathname.replace(/\/?v1\/?$/, "")}/api/v1/models`.replace(/\/+/g, "/");
230
295
  return url.toString();
231
296
  }
297
+ function chatContent(value) {
298
+ if (typeof value === "string")
299
+ return value;
300
+ if (!Array.isArray(value))
301
+ return "";
302
+ const parts = value.flatMap((part) => {
303
+ if (!part || typeof part !== "object")
304
+ return [];
305
+ const item = part;
306
+ if ((item.type === "input_text" || item.type === "output_text" || item.type === "text")
307
+ && typeof item.text === "string")
308
+ return [{ type: "text", text: item.text }];
309
+ if (item.type === "input_image" && typeof item.image_url === "string") {
310
+ return [{ type: "image_url", image_url: { url: item.image_url } }];
311
+ }
312
+ return [];
313
+ });
314
+ return parts.length === 1 && typeof parts[0] === "string" ? parts[0] : parts;
315
+ }
316
+ export function openRouterMessages(instructions, input) {
317
+ const messages = [];
318
+ if (instructions.trim())
319
+ messages.push({ role: "system", content: instructions });
320
+ for (const raw of input) {
321
+ const item = raw;
322
+ if (item.type === "reasoning" || item.type === "compaction")
323
+ continue;
324
+ if (item.type === "function_call") {
325
+ const previous = messages.at(-1);
326
+ const call = {
327
+ id: typeof item.call_id === "string" ? item.call_id : String(item.id ?? "call_unknown"),
328
+ type: "function",
329
+ function: {
330
+ name: typeof item.name === "string" ? item.name : "tool",
331
+ arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments ?? {}),
332
+ },
333
+ };
334
+ if (previous?.role === "assistant") {
335
+ if (Array.isArray(previous.tool_calls))
336
+ previous.tool_calls.push(call);
337
+ else
338
+ previous.tool_calls = [call];
339
+ }
340
+ else
341
+ messages.push({ role: "assistant", content: null, tool_calls: [call] });
342
+ continue;
343
+ }
344
+ if (item.type === "function_call_output") {
345
+ messages.push({
346
+ role: "tool",
347
+ tool_call_id: typeof item.call_id === "string" ? item.call_id : String(item.id ?? "call_unknown"),
348
+ content: typeof item.output === "string" ? item.output : JSON.stringify(item.output ?? ""),
349
+ });
350
+ continue;
351
+ }
352
+ const role = item.role === "developer" ? "system"
353
+ : item.role === "assistant" ? "assistant" : item.role === "system" ? "system" : "user";
354
+ messages.push({ role, content: chatContent(item.content) });
355
+ }
356
+ return messages;
357
+ }
358
+ export function buildOpenRouterParams(request) {
359
+ const tools = request.tools.map((tool) => ({
360
+ type: "function",
361
+ function: {
362
+ name: tool.name,
363
+ description: tool.description,
364
+ parameters: tool.parameters,
365
+ ...(tool.strict === undefined ? {} : { strict: tool.strict }),
366
+ },
367
+ }));
368
+ return {
369
+ model: request.model,
370
+ messages: openRouterMessages(request.instructions, request.input),
371
+ ...(tools.length > 0
372
+ ? { tools, ...(request.supportsParallelToolCalls ? { parallel_tool_calls: true } : {}) }
373
+ : {}),
374
+ ...(request.supportsReasoning && request.reasoningEffort !== "none"
375
+ ? { reasoning: { effort: request.reasoningEffort } } : {}),
376
+ };
377
+ }
378
+ const MAX_HTTP_ERROR_BYTES = 64 * 1024;
379
+ async function boundedText(response, limit = MAX_HTTP_ERROR_BYTES) {
380
+ if (!response.body)
381
+ return "";
382
+ const reader = response.body.getReader();
383
+ const chunks = [];
384
+ let total = 0;
385
+ while (total < limit) {
386
+ const next = await reader.read();
387
+ if (next.done)
388
+ break;
389
+ const chunk = next.value ?? new Uint8Array();
390
+ const take = Math.min(chunk.byteLength, limit - total);
391
+ if (take > 0)
392
+ chunks.push(chunk.slice(0, take));
393
+ total += take;
394
+ if (take < chunk.byteLength) {
395
+ await reader.cancel();
396
+ break;
397
+ }
398
+ }
399
+ return new TextDecoder().decode(Buffer.concat(chunks));
400
+ }
401
+ async function boundedResponseText(response, context) {
402
+ try {
403
+ return await boundedText(response);
404
+ }
405
+ catch (error) {
406
+ throw providerError(error, context);
407
+ }
408
+ }
409
+ function parseJsonBody(text) {
410
+ if (!text.trim())
411
+ return "";
412
+ try {
413
+ return JSON.parse(text);
414
+ }
415
+ catch {
416
+ return text;
417
+ }
418
+ }
419
+ function normalizedUsage(value) {
420
+ if (!value || typeof value !== "object" || Array.isArray(value))
421
+ return undefined;
422
+ const usage = value;
423
+ const numberValue = (...keys) => {
424
+ for (const key of keys) {
425
+ const candidate = usage[key];
426
+ if (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0)
427
+ return candidate;
428
+ }
429
+ return 0;
430
+ };
431
+ const inputTokens = numberValue("input_tokens", "prompt_tokens");
432
+ const outputTokens = numberValue("output_tokens", "completion_tokens");
433
+ return {
434
+ input_tokens: inputTokens,
435
+ output_tokens: outputTokens,
436
+ total_tokens: numberValue("total_tokens") || inputTokens + outputTokens,
437
+ };
438
+ }
439
+ function requestContext(provider, operation, apiKey, signal, timeoutSignal) {
440
+ return {
441
+ provider,
442
+ operation,
443
+ configuredSecrets: [apiKey],
444
+ ...(signal ? { callerSignal: signal } : {}),
445
+ ...(timeoutSignal ? { timeoutSignal } : {}),
446
+ };
447
+ }
448
+ async function readJsonResponse(response, context) {
449
+ let text;
450
+ try {
451
+ text = response.ok ? await response.text() : await boundedText(response);
452
+ }
453
+ catch (error) {
454
+ throw providerError(error, context);
455
+ }
456
+ if (!response.ok)
457
+ throw providerHttpError(response.status, parseJsonBody(text), context);
458
+ try {
459
+ return JSON.parse(text);
460
+ }
461
+ catch (error) {
462
+ throw providerError(Object.assign(new Error("response was not valid JSON"), { cause: error }), {
463
+ ...context,
464
+ protocol: true,
465
+ });
466
+ }
467
+ }
468
+ function objectPayload(payload, context, message) {
469
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
470
+ throw providerError({ code: "malformed_response", message }, { ...context, protocol: true });
471
+ }
472
+ return payload;
473
+ }
474
+ async function readStreamChunk(reader, context) {
475
+ try {
476
+ return await reader.read();
477
+ }
478
+ catch (error) {
479
+ throw providerError(error, context);
480
+ }
481
+ }
482
+ async function* openRouterEvents(response, context) {
483
+ if (!response.body)
484
+ throw providerError({ code: "malformed_response", message: "response had no body" }, { ...context, protocol: true });
485
+ const streamReader = response.body.getReader();
486
+ const decoder = new TextDecoder();
487
+ let buffer = "";
488
+ let data = [];
489
+ let ended = false;
490
+ const emit = () => {
491
+ const payload = data.join("\n").trim();
492
+ data = [];
493
+ if (!payload)
494
+ return null;
495
+ if (payload === "[DONE]")
496
+ return { done: true };
497
+ try {
498
+ return JSON.parse(payload);
499
+ }
500
+ catch (error) {
501
+ throw providerError(Object.assign(new Error("malformed SSE event JSON"), {
502
+ cause: error,
503
+ code: "malformed_response_event",
504
+ }), { ...context, protocol: true });
505
+ }
506
+ };
507
+ try {
508
+ while (true) {
509
+ const chunk = await readStreamChunk(streamReader, context);
510
+ buffer += decoder.decode(chunk.value ?? new Uint8Array(), { stream: !chunk.done });
511
+ let newline = buffer.indexOf("\n");
512
+ while (newline >= 0) {
513
+ const line = buffer.slice(0, newline).replace(/\r$/, "");
514
+ buffer = buffer.slice(newline + 1);
515
+ if (line === "") {
516
+ const event = emit();
517
+ if (event) {
518
+ if (typeof event === "object" && event !== null && "done" in event)
519
+ ended = true;
520
+ yield event;
521
+ }
522
+ }
523
+ else if (line.startsWith("data:"))
524
+ data.push(line.slice(5).trimStart());
525
+ newline = buffer.indexOf("\n");
526
+ }
527
+ if (chunk.done)
528
+ break;
529
+ }
530
+ if (buffer.startsWith("data:"))
531
+ data.push(buffer.slice(5).trimStart());
532
+ const event = emit();
533
+ if (event) {
534
+ if (typeof event === "object" && event !== null && "done" in event)
535
+ ended = true;
536
+ yield event;
537
+ }
538
+ if (!ended)
539
+ throw providerError({ code: "stream_incomplete", message: "response stream ended without [DONE]" }, context);
540
+ }
541
+ finally {
542
+ try {
543
+ await streamReader.cancel();
544
+ }
545
+ catch {
546
+ // The stream may already be closed by the fetch implementation.
547
+ }
548
+ streamReader.releaseLock();
549
+ }
550
+ }
232
551
  export class CodexLbClient {
233
552
  config;
234
- client;
235
553
  apiKey;
236
554
  constructor(config) {
237
555
  this.config = config;
238
556
  this.apiKey = process.env[config.gateway.apiKeyEnv] || "local-looking-glass";
239
- this.client = new OpenAI({
240
- apiKey: this.apiKey,
241
- baseURL: config.gateway.baseURL,
242
- maxRetries: 0,
243
- timeout: config.gateway.timeoutMs,
244
- });
245
557
  }
246
558
  supportsResponseContinuity() {
247
559
  return this.config.gateway.provider === "codex-lb";
248
560
  }
249
- safeErrorMessage(message) {
250
- return redactSensitiveText(message, [this.apiKey]);
251
- }
252
561
  async models(signal) {
562
+ const provider = this.config.gateway.provider;
563
+ const timeout = AbortSignal.timeout(this.config.gateway.timeoutMs);
564
+ const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
565
+ const context = requestContext(provider, "models", this.apiKey, signal, timeout);
253
566
  if (this.config.gateway.provider === "lm-studio") {
254
567
  try {
255
- const timeout = AbortSignal.timeout(this.config.gateway.timeoutMs);
256
- const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
257
568
  const response = await fetch(lmStudioModelsURL(this.config.gateway.baseURL), {
258
569
  headers: { authorization: `Bearer ${this.apiKey}` },
259
570
  signal: requestSignal,
260
571
  });
261
- if (!response.ok)
262
- throw new Error(`LM Studio model metadata failed with HTTP ${response.status}`);
263
- const payload = await response.json();
264
- const models = (payload.models ?? [])
265
- .filter((model) => model.type === "llm")
572
+ const nativeContext = { ...context, operation: "models (native catalog)" };
573
+ const payload = objectPayload(await readJsonResponse(response, nativeContext), nativeContext, "model catalog returned an invalid payload");
574
+ const models = (Array.isArray(payload.models) ? payload.models : [])
575
+ .filter((model) => model && typeof model === "object" && model.type === "llm" && typeof model.key === "string")
266
576
  .map(lmStudioModelInfo)
267
577
  .sort((left, right) => left.priority - right.priority || left.name.localeCompare(right.name));
268
578
  if (models.length > 0)
@@ -274,47 +584,196 @@ export class CodexLbClient {
274
584
  // Older LM Studio versions and restricted tokens may only expose /v1/models.
275
585
  }
276
586
  }
277
- const page = await this.client.models.list(signal ? { signal } : undefined);
278
- return page.data
587
+ let response;
588
+ try {
589
+ response = await fetch(`${this.config.gateway.baseURL.replace(/\/$/, "")}/models`, {
590
+ headers: { authorization: `Bearer ${this.apiKey}` },
591
+ signal: requestSignal,
592
+ });
593
+ }
594
+ catch (error) {
595
+ throw providerError(error, context);
596
+ }
597
+ const payload = objectPayload(await readJsonResponse(response, context), context, "model catalog returned an invalid payload");
598
+ if (payload.data !== undefined && !Array.isArray(payload.data)) {
599
+ throw providerError({ code: "malformed_response", message: "model catalog data was not an array" }, { ...context, protocol: true });
600
+ }
601
+ const models = (payload.data ?? []).filter((model) => model && typeof model === "object" && typeof model.id === "string");
602
+ if (provider === "openrouter") {
603
+ return models
604
+ .map((model) => openRouterModelInfo(model))
605
+ .sort((left, right) => left.priority - right.priority || left.name.localeCompare(right.name));
606
+ }
607
+ return models
279
608
  .filter((model) => model.metadata?.supported_in_api !== false)
280
609
  .map(modelInfo)
281
610
  .sort((left, right) => left.priority - right.priority || left.name.localeCompare(right.name));
282
611
  }
283
- async stream(request, callbacks = {}) {
284
- const params = buildResponseParams(this.config.gateway.provider, request);
612
+ async openRouterStream(request, callbacks) {
285
613
  const timeout = AbortSignal.timeout(this.config.gateway.timeoutMs);
286
614
  const signal = request.signal ? AbortSignal.any([request.signal, timeout]) : timeout;
287
- const http = await fetch(`${this.config.gateway.baseURL}/responses`, {
288
- method: "POST",
289
- headers: {
290
- authorization: `Bearer ${this.apiKey}`,
291
- "content-type": "application/json",
292
- accept: "text/event-stream",
293
- },
294
- body: JSON.stringify({ ...params, stream: true }),
295
- signal,
296
- });
615
+ const context = requestContext("openrouter", "stream", this.apiKey, request.signal, timeout);
616
+ let http;
617
+ try {
618
+ http = await fetch(`${this.config.gateway.baseURL.replace(/\/$/, "")}/chat/completions`, {
619
+ method: "POST",
620
+ headers: {
621
+ authorization: `Bearer ${this.apiKey}`,
622
+ "content-type": "application/json",
623
+ accept: "text/event-stream",
624
+ },
625
+ body: JSON.stringify({ ...buildOpenRouterParams(request), stream: true, stream_options: { include_usage: true } }),
626
+ signal,
627
+ });
628
+ }
629
+ catch (error) {
630
+ throw providerError(error, context);
631
+ }
297
632
  if (!http.ok) {
298
- const text = await http.text();
299
- let payload = text;
300
- try {
301
- payload = JSON.parse(text);
302
- }
303
- catch {
304
- // Preserve non-JSON gateway errors as their response text.
633
+ const text = await boundedResponseText(http, context);
634
+ throw providerHttpError(http.status, parseJsonBody(text), context);
635
+ }
636
+ const outputText = [];
637
+ const reasoningText = [];
638
+ const toolCalls = new Map();
639
+ let id = "";
640
+ let model = request.model;
641
+ let usage;
642
+ callbacks.onEvent?.({ type: "response.created", response: { id: "", status: "in_progress", model, output: [] } });
643
+ for await (const raw of openRouterEvents(http, context)) {
644
+ if (!raw || typeof raw !== "object")
645
+ continue;
646
+ if (raw.done)
647
+ break;
648
+ const event = raw;
649
+ if (event.error !== undefined)
650
+ throw providerError(event.error, context);
651
+ if (typeof event.id === "string")
652
+ id ||= event.id;
653
+ if (typeof event.model === "string")
654
+ model = event.model;
655
+ if (event.usage && typeof event.usage === "object")
656
+ usage = event.usage;
657
+ const choices = Array.isArray(event.choices) ? event.choices : [];
658
+ for (const choice of choices) {
659
+ if (!choice || typeof choice !== "object")
660
+ continue;
661
+ const delta = choice.delta;
662
+ if (!delta || typeof delta !== "object")
663
+ continue;
664
+ const item = delta;
665
+ const text = typeof item.content === "string" ? item.content : "";
666
+ if (text) {
667
+ outputText.push(text);
668
+ callbacks.onTextDelta?.(text);
669
+ callbacks.onEvent?.({ type: "response.output_text.delta", delta: text });
670
+ }
671
+ const detailReasoning = Array.isArray(item.reasoning_details)
672
+ ? item.reasoning_details.map((detail) => detail && typeof detail === "object"
673
+ && typeof detail.text === "string"
674
+ ? detail.text : "").join("")
675
+ : "";
676
+ const reasoning = typeof item.reasoning === "string" ? item.reasoning
677
+ : typeof item.reasoning_content === "string" ? item.reasoning_content : detailReasoning;
678
+ if (reasoning) {
679
+ reasoningText.push(reasoning);
680
+ callbacks.onReasoningDelta?.(reasoning);
681
+ callbacks.onEvent?.({ type: "response.reasoning_summary_text.delta", delta: reasoning });
682
+ }
683
+ if (Array.isArray(item.tool_calls)) {
684
+ for (const tool of item.tool_calls) {
685
+ if (!tool || typeof tool !== "object")
686
+ continue;
687
+ const call = tool;
688
+ const index = typeof call.index === "number" ? call.index : toolCalls.size;
689
+ const fn = call.function && typeof call.function === "object" ? call.function : {};
690
+ const current = toolCalls.get(index) ?? { id: "", name: "", arguments: "" };
691
+ if (typeof call.id === "string")
692
+ current.id = call.id;
693
+ if (typeof fn.name === "string")
694
+ current.name += fn.name;
695
+ if (typeof fn.arguments === "string")
696
+ current.arguments += fn.arguments;
697
+ toolCalls.set(index, current);
698
+ if (typeof fn.arguments === "string")
699
+ callbacks.onEvent?.({
700
+ type: "response.function_call_arguments.delta", delta: fn.arguments,
701
+ });
702
+ }
703
+ }
305
704
  }
306
- const detail = detailFrom(payload);
307
- const message = this.safeErrorMessage(detail.message ?? (text || `Response failed with HTTP ${http.status}`));
308
- throw Object.assign(new Error(message), detail, {
309
- status: http.status,
705
+ }
706
+ id ||= `chatcmpl_${Date.now().toString(36)}`;
707
+ const output = [];
708
+ if (reasoningText.length > 0)
709
+ output.push({
710
+ id: `reasoning_${id}`, type: "reasoning", status: "completed", summary: [{ type: "summary_text", text: reasoningText.join("") }], content: [],
711
+ });
712
+ if (outputText.length > 0)
713
+ output.push({
714
+ id: `msg_${id}`, type: "message", role: "assistant", status: "completed",
715
+ content: [{ type: "output_text", text: outputText.join(""), annotations: [], logprobs: [] }],
716
+ });
717
+ const callOutputIndices = new Map();
718
+ for (const [index, call] of [...toolCalls.entries()].sort(([left], [right]) => left - right)) {
719
+ callOutputIndices.set(index, output.length);
720
+ output.push({ id: call.id || `call_${id}_${index}`, type: "function_call", call_id: call.id || `call_${id}_${index}`, name: call.name, arguments: call.arguments, status: "completed" });
721
+ }
722
+ if (output.length === 0)
723
+ throw providerError({ code: "malformed_response", message: "response contained no output" }, { ...context, protocol: true });
724
+ for (const [index, item] of output.entries())
725
+ callbacks.onEvent?.({ type: "response.output_item.done", output_index: index, item });
726
+ for (const [index, call] of [...toolCalls.entries()].sort(([left], [right]) => left - right)) {
727
+ callbacks.onEvent?.({
728
+ type: "response.function_call_arguments.done", output_index: callOutputIndices.get(index), arguments: call.arguments,
729
+ });
730
+ }
731
+ const response = {
732
+ id, object: "response", created: Math.floor(Date.now() / 1000), model, status: "completed", output,
733
+ output_text: outputText.join(""),
734
+ ...(usage ? { usage: {
735
+ input_tokens: typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0,
736
+ output_tokens: typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0,
737
+ total_tokens: typeof usage.total_tokens === "number" ? usage.total_tokens
738
+ : (typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0)
739
+ + (typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0),
740
+ } } : {}),
741
+ };
742
+ callbacks.onEvent?.({ type: "response.completed", response });
743
+ return response;
744
+ }
745
+ async stream(request, callbacks = {}) {
746
+ if (this.config.gateway.provider === "openrouter")
747
+ return this.openRouterStream(request, callbacks);
748
+ const params = buildResponseParams(this.config.gateway.provider, request);
749
+ const timeout = AbortSignal.timeout(this.config.gateway.timeoutMs);
750
+ const signal = request.signal ? AbortSignal.any([request.signal, timeout]) : timeout;
751
+ const context = requestContext(this.config.gateway.provider, "stream", this.apiKey, request.signal, timeout);
752
+ let http;
753
+ try {
754
+ http = await fetch(`${this.config.gateway.baseURL}/responses`, {
755
+ method: "POST",
756
+ headers: {
757
+ authorization: `Bearer ${this.apiKey}`,
758
+ "content-type": "application/json",
759
+ accept: "text/event-stream",
760
+ },
761
+ body: JSON.stringify({ ...params, stream: true }),
762
+ signal,
310
763
  });
311
764
  }
765
+ catch (error) {
766
+ throw providerError(error, context);
767
+ }
768
+ if (!http.ok) {
769
+ throw providerHttpError(http.status, parseJsonBody(await boundedResponseText(http, context)), context);
770
+ }
312
771
  let created = {};
313
772
  let terminal = null;
314
773
  let streamedError = null;
315
774
  let sawTerminal = false;
316
775
  const output = new Map();
317
- for await (const event of responseEvents(http)) {
776
+ for await (const event of responseEvents(http, context)) {
318
777
  callbacks.onEvent?.(event);
319
778
  if (event.type === "response.output_text.delta" && typeof event.delta === "string") {
320
779
  callbacks.onTextDelta?.(event.delta);
@@ -342,10 +801,9 @@ export class CodexLbClient {
342
801
  }
343
802
  if (!sawTerminal) {
344
803
  if (streamedError) {
345
- const detail = detailFrom(streamedError);
346
- throw Object.assign(new Error(this.safeErrorMessage(detail.message ?? "Response stream failed")), detail);
804
+ throw providerError(streamedError, context);
347
805
  }
348
- throw Object.assign(new Error("Response stream ended without a terminal event"), { code: "stream_incomplete" });
806
+ throw providerError({ code: "stream_incomplete", message: "response stream ended without a terminal event" }, context);
349
807
  }
350
808
  const combined = { ...created, ...(terminal ?? {}) };
351
809
  const terminalOutput = Array.isArray(combined.output) ? combined.output : [];
@@ -356,12 +814,15 @@ export class CodexLbClient {
356
814
  ? combined.status
357
815
  : streamedError ? "failed" : "completed";
358
816
  if (streamedError || status === "failed" || status === "incomplete") {
359
- const detail = detailFrom(streamedError ?? combined.error ?? combined.incomplete_details);
360
- throw Object.assign(new Error(this.safeErrorMessage(detail.message ?? `Response ${status}`)), detail, { responseStatus: status });
817
+ throw providerError(streamedError ?? combined.error ?? combined.incomplete_details ?? { message: `Response ${status}` }, {
818
+ ...context,
819
+ responseStatus: status,
820
+ });
361
821
  }
362
822
  if (typeof combined.id !== "string" || !combined.id || canonicalOutput.length === 0) {
363
- throw Object.assign(new Error("Response stream returned an invalid completed response"), {
364
- code: "malformed_response",
823
+ throw providerError({ code: "malformed_response", message: "response stream returned an invalid completed response" }, {
824
+ ...context,
825
+ protocol: true,
365
826
  });
366
827
  }
367
828
  const canonical = {
@@ -375,10 +836,68 @@ export class CodexLbClient {
375
836
  return this.config.gateway.provider === "lm-studio" ? redactLmStudioReasoning(canonical) : canonical;
376
837
  }
377
838
  async compact(request) {
839
+ if (this.config.gateway.provider === "openrouter") {
840
+ const transcript = compactTranscript(request.input);
841
+ if (!transcript)
842
+ throw providerError({ code: "malformed_response", message: "compaction received no semantic transcript" }, {
843
+ ...requestContext("openrouter", "compact", this.apiKey, request.signal), protocol: true,
844
+ });
845
+ const timeout = AbortSignal.timeout(this.config.gateway.timeoutMs);
846
+ const signal = request.signal ? AbortSignal.any([request.signal, timeout]) : timeout;
847
+ const context = requestContext("openrouter", "compact", this.apiKey, request.signal, timeout);
848
+ let http;
849
+ try {
850
+ http = await fetch(`${this.config.gateway.baseURL.replace(/\/$/, "")}/chat/completions`, {
851
+ method: "POST",
852
+ headers: { authorization: `Bearer ${this.apiKey}`, "content-type": "application/json" },
853
+ body: JSON.stringify({
854
+ model: request.model,
855
+ messages: openRouterMessages([
856
+ request.instructions,
857
+ "Create a dense, durable checkpoint of the supplied conversation.",
858
+ "Preserve user requirements, decisions, relevant facts, file paths, code changes, tool outcomes, unresolved work, and safety constraints.",
859
+ "Do not continue the task, call tools, or add commentary. Return only the checkpoint text.",
860
+ ].filter((part) => part.trim()).join(" "), [{ role: "user", content: [{ type: "input_text", text: `Conversation transcript:\n\n${transcript}` }] }]),
861
+ stream: false,
862
+ }),
863
+ signal,
864
+ });
865
+ }
866
+ catch (error) {
867
+ throw providerError(error, context);
868
+ }
869
+ const payload = objectPayload(await readJsonResponse(http, context), context, "compaction returned an invalid payload");
870
+ const choices = Array.isArray(payload.choices) ? payload.choices : [];
871
+ const first = choices[0] && typeof choices[0] === "object" ? choices[0] : {};
872
+ const message = first.message && typeof first.message === "object" ? first.message : {};
873
+ const summaryValue = typeof message.content === "string" ? message.content : chatContent(message.content);
874
+ const summary = typeof summaryValue === "string"
875
+ ? summaryValue.trim()
876
+ : Array.isArray(summaryValue)
877
+ ? summaryValue.map((part) => part && typeof part === "object" && typeof part.text === "string"
878
+ ? part.text : "").join("\n").trim()
879
+ : "";
880
+ if (!summary)
881
+ throw providerError({ code: "malformed_response", message: "compaction returned no checkpoint text" }, { ...context, protocol: true });
882
+ const id = typeof payload.id === "string" ? payload.id : `compact_${Date.now().toString(36)}`;
883
+ const usage = normalizedUsage(payload.usage);
884
+ return {
885
+ id: `compact_${id}`,
886
+ object: "response.compaction",
887
+ output: [{
888
+ id: `msg_compact_${id}`, type: "message", role: "user", status: "completed",
889
+ content: [{ type: "input_text", text: `Conversation checkpoint generated by Looking Glass:\n${summary}` }],
890
+ }],
891
+ ...(usage ? { usage } : {}),
892
+ };
893
+ }
378
894
  if (this.config.gateway.provider === "lm-studio") {
379
895
  const transcript = compactTranscript(request.input);
896
+ const compactContext = requestContext("lm-studio", "compact", this.apiKey, request.signal);
380
897
  if (!transcript)
381
- throw new Error("LM Studio compaction received no semantic transcript");
898
+ throw providerError({ code: "malformed_response", message: "compaction received no semantic transcript" }, {
899
+ ...compactContext, protocol: true,
900
+ });
382
901
  const profile = {
383
902
  model: request.model,
384
903
  instructions: [
@@ -397,14 +916,44 @@ export class CodexLbClient {
397
916
  promptCacheKey: request.promptCacheKey,
398
917
  reasoningEffort: "none",
399
918
  supportsReasoning: true,
919
+ supportsParallelToolCalls: false,
400
920
  verbosity: "high",
401
921
  fast: false,
402
922
  ...(request.signal ? { signal: request.signal } : {}),
403
923
  };
404
- const response = await this.client.responses.create(buildResponseParams("lm-studio", profile), request.signal ? { signal: request.signal } : undefined);
924
+ const timeout = AbortSignal.timeout(this.config.gateway.timeoutMs);
925
+ const signal = request.signal ? AbortSignal.any([request.signal, timeout]) : timeout;
926
+ const context = requestContext("lm-studio", "compact", this.apiKey, request.signal, timeout);
927
+ let http;
928
+ try {
929
+ http = await fetch(`${this.config.gateway.baseURL.replace(/\/$/, "")}/responses`, {
930
+ method: "POST",
931
+ headers: {
932
+ authorization: `Bearer ${this.apiKey}`,
933
+ "content-type": "application/json",
934
+ },
935
+ body: JSON.stringify(buildResponseParams("lm-studio", profile)),
936
+ signal,
937
+ });
938
+ }
939
+ catch (error) {
940
+ throw providerError(error, context);
941
+ }
942
+ const payload = await readJsonResponse(http, context);
943
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
944
+ throw providerError({ code: "malformed_response", message: "compaction returned an invalid payload" }, { ...context, protocol: true });
945
+ }
946
+ const response = payload;
947
+ if (!Array.isArray(response.output)) {
948
+ throw providerError({ code: "malformed_response", message: "compaction response did not contain output items" }, {
949
+ ...context, protocol: true,
950
+ });
951
+ }
405
952
  const summary = responseText(response).trim();
406
953
  if (!summary)
407
- throw new Error("LM Studio compaction returned no checkpoint text");
954
+ throw providerError({ code: "malformed_response", message: "compaction returned no checkpoint text" }, {
955
+ ...context, protocol: true,
956
+ });
408
957
  return {
409
958
  id: `compact_${response.id}`,
410
959
  object: "response.compaction",
@@ -430,29 +979,30 @@ export class CodexLbClient {
430
979
  };
431
980
  const timeout = AbortSignal.timeout(this.config.gateway.timeoutMs);
432
981
  const signal = request.signal ? AbortSignal.any([request.signal, timeout]) : timeout;
433
- const response = await fetch(`${this.config.gateway.baseURL}/responses/compact`, {
434
- method: "POST",
435
- headers: {
436
- authorization: `Bearer ${this.apiKey}`,
437
- "content-type": "application/json",
438
- },
439
- body: JSON.stringify(body),
440
- signal,
441
- });
442
- const payload = await response.json();
443
- if (!response.ok) {
444
- const detail = detailFrom(payload);
445
- throw Object.assign(new Error(this.safeErrorMessage(detail.message ?? `Compaction failed with HTTP ${response.status}`)), detail, {
446
- status: response.status,
982
+ const context = requestContext(this.config.gateway.provider, "compact", this.apiKey, request.signal, timeout);
983
+ let response;
984
+ try {
985
+ response = await fetch(`${this.config.gateway.baseURL}/responses/compact`, {
986
+ method: "POST",
987
+ headers: {
988
+ authorization: `Bearer ${this.apiKey}`,
989
+ "content-type": "application/json",
990
+ },
991
+ body: JSON.stringify(body),
992
+ signal,
447
993
  });
448
994
  }
995
+ catch (error) {
996
+ throw providerError(error, context);
997
+ }
998
+ const payload = await readJsonResponse(response, context);
449
999
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
450
- throw new Error("Compaction returned an invalid payload");
1000
+ throw providerError({ code: "malformed_response", message: "compaction returned an invalid payload" }, { ...context, protocol: true });
451
1001
  }
452
1002
  const output = payload.output;
453
1003
  if (!Array.isArray(output) || output.length === 0
454
1004
  || output.some((item) => !item || typeof item !== "object" || Array.isArray(item))) {
455
- throw new Error("Compaction response did not contain valid output items");
1005
+ throw providerError({ code: "malformed_response", message: "compaction response did not contain valid output items" }, { ...context, protocol: true });
456
1006
  }
457
1007
  return payload;
458
1008
  }