@sigil0/looking-glass 0.2.5 → 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,5 +1,4 @@
1
- import OpenAI from "openai";
2
- import { redactSensitiveText } from "../security.js";
1
+ import { providerError, providerHttpError } from "../errors.js";
3
2
  // Current llama.cpp grammar generation can emit invalid GBNF for nested string
4
3
  // maxLength constraints at or above 2000. Keep the original schemas for local
5
4
  // validation and only relax the cloned schema sent to LM Studio.
@@ -29,6 +28,8 @@ function lmStudioTools(tools) {
29
28
  });
30
29
  }
31
30
  export function buildResponseParams(provider, request) {
31
+ if (provider === "openrouter")
32
+ return buildOpenRouterParams(request);
32
33
  const common = {
33
34
  model: request.model,
34
35
  instructions: request.instructions,
@@ -36,7 +37,7 @@ export function buildResponseParams(provider, request) {
36
37
  ? request.input.filter((item) => item.type !== "reasoning")
37
38
  : request.input,
38
39
  tools: provider === "lm-studio" ? lmStudioTools(request.tools) : request.tools,
39
- parallel_tool_calls: true,
40
+ ...(request.supportsParallelToolCalls ? { parallel_tool_calls: true } : {}),
40
41
  ...(request.supportsReasoning ? {
41
42
  reasoning: {
42
43
  // codex-lb advertises "ultra" before the public SDK type includes it.
@@ -66,14 +67,14 @@ function detailFrom(value) {
66
67
  const outer = value;
67
68
  return outer.error && typeof outer.error === "object" ? outer.error : outer;
68
69
  }
69
- async function* responseEvents(response) {
70
+ async function* responseEvents(response, context) {
70
71
  if (!response.body)
71
- throw new Error("Response stream had no body");
72
- 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();
73
74
  const decoder = new TextDecoder();
74
75
  let buffer = "";
75
76
  let data = [];
76
- const parse = () => {
77
+ const parseResponseEvent = () => {
77
78
  const payload = data.join("\n");
78
79
  data = [];
79
80
  if (!payload || payload === "[DONE]")
@@ -86,20 +87,20 @@ async function* responseEvents(response) {
86
87
  return event;
87
88
  }
88
89
  catch (error) {
89
- 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)}`), {
90
91
  code: "malformed_response_event",
91
- });
92
+ }), { ...(context ?? { provider: "provider", operation: "stream" }), protocol: true });
92
93
  }
93
94
  };
94
95
  while (true) {
95
- const chunk = await reader.read();
96
+ const chunk = context ? await readStreamChunk(streamReader, context) : await streamReader.read();
96
97
  buffer += decoder.decode(chunk.value ?? new Uint8Array(), { stream: !chunk.done });
97
98
  let newline = buffer.indexOf("\n");
98
99
  while (newline >= 0) {
99
100
  const line = buffer.slice(0, newline).replace(/\r$/, "");
100
101
  buffer = buffer.slice(newline + 1);
101
102
  if (line === "") {
102
- const event = parse();
103
+ const event = parseResponseEvent();
103
104
  if (event)
104
105
  yield event;
105
106
  }
@@ -113,7 +114,7 @@ async function* responseEvents(response) {
113
114
  }
114
115
  if (buffer.startsWith("data:"))
115
116
  data.push(buffer.slice(5).trimStart());
116
- const event = parse();
117
+ const event = parseResponseEvent();
117
118
  if (event)
118
119
  yield event;
119
120
  }
@@ -203,6 +204,42 @@ export function lmStudioModelInfo(raw) {
203
204
  priority: raw.loaded_instances?.length ? 0 : 1_000,
204
205
  };
205
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
+ }
206
243
  export function responseText(response) {
207
244
  if (response.output_text)
208
245
  return response.output_text;
@@ -257,40 +294,285 @@ function lmStudioModelsURL(baseURL) {
257
294
  url.pathname = `${url.pathname.replace(/\/?v1\/?$/, "")}/api/v1/models`.replace(/\/+/g, "/");
258
295
  return url.toString();
259
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
+ }
260
551
  export class CodexLbClient {
261
552
  config;
262
- client;
263
553
  apiKey;
264
554
  constructor(config) {
265
555
  this.config = config;
266
556
  this.apiKey = process.env[config.gateway.apiKeyEnv] || "local-looking-glass";
267
- this.client = new OpenAI({
268
- apiKey: this.apiKey,
269
- baseURL: config.gateway.baseURL,
270
- maxRetries: 0,
271
- timeout: config.gateway.timeoutMs,
272
- });
273
557
  }
274
558
  supportsResponseContinuity() {
275
559
  return this.config.gateway.provider === "codex-lb";
276
560
  }
277
- safeErrorMessage(message) {
278
- return redactSensitiveText(message, [this.apiKey]);
279
- }
280
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);
281
566
  if (this.config.gateway.provider === "lm-studio") {
282
567
  try {
283
- const timeout = AbortSignal.timeout(this.config.gateway.timeoutMs);
284
- const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
285
568
  const response = await fetch(lmStudioModelsURL(this.config.gateway.baseURL), {
286
569
  headers: { authorization: `Bearer ${this.apiKey}` },
287
570
  signal: requestSignal,
288
571
  });
289
- if (!response.ok)
290
- throw new Error(`LM Studio model metadata failed with HTTP ${response.status}`);
291
- const payload = await response.json();
292
- const models = (payload.models ?? [])
293
- .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")
294
576
  .map(lmStudioModelInfo)
295
577
  .sort((left, right) => left.priority - right.priority || left.name.localeCompare(right.name));
296
578
  if (models.length > 0)
@@ -302,47 +584,196 @@ export class CodexLbClient {
302
584
  // Older LM Studio versions and restricted tokens may only expose /v1/models.
303
585
  }
304
586
  }
305
- const page = await this.client.models.list(signal ? { signal } : undefined);
306
- 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
307
608
  .filter((model) => model.metadata?.supported_in_api !== false)
308
609
  .map(modelInfo)
309
610
  .sort((left, right) => left.priority - right.priority || left.name.localeCompare(right.name));
310
611
  }
311
- async stream(request, callbacks = {}) {
312
- const params = buildResponseParams(this.config.gateway.provider, request);
612
+ async openRouterStream(request, callbacks) {
313
613
  const timeout = AbortSignal.timeout(this.config.gateway.timeoutMs);
314
614
  const signal = request.signal ? AbortSignal.any([request.signal, timeout]) : timeout;
315
- const http = await fetch(`${this.config.gateway.baseURL}/responses`, {
316
- method: "POST",
317
- headers: {
318
- authorization: `Bearer ${this.apiKey}`,
319
- "content-type": "application/json",
320
- accept: "text/event-stream",
321
- },
322
- body: JSON.stringify({ ...params, stream: true }),
323
- signal,
324
- });
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
+ }
325
632
  if (!http.ok) {
326
- const text = await http.text();
327
- let payload = text;
328
- try {
329
- payload = JSON.parse(text);
330
- }
331
- catch {
332
- // 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
+ }
333
704
  }
334
- const detail = detailFrom(payload);
335
- const message = this.safeErrorMessage(detail.message ?? (text || `Response failed with HTTP ${http.status}`));
336
- throw Object.assign(new Error(message), detail, {
337
- 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,
338
729
  });
339
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,
763
+ });
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
+ }
340
771
  let created = {};
341
772
  let terminal = null;
342
773
  let streamedError = null;
343
774
  let sawTerminal = false;
344
775
  const output = new Map();
345
- for await (const event of responseEvents(http)) {
776
+ for await (const event of responseEvents(http, context)) {
346
777
  callbacks.onEvent?.(event);
347
778
  if (event.type === "response.output_text.delta" && typeof event.delta === "string") {
348
779
  callbacks.onTextDelta?.(event.delta);
@@ -370,10 +801,9 @@ export class CodexLbClient {
370
801
  }
371
802
  if (!sawTerminal) {
372
803
  if (streamedError) {
373
- const detail = detailFrom(streamedError);
374
- throw Object.assign(new Error(this.safeErrorMessage(detail.message ?? "Response stream failed")), detail);
804
+ throw providerError(streamedError, context);
375
805
  }
376
- 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);
377
807
  }
378
808
  const combined = { ...created, ...(terminal ?? {}) };
379
809
  const terminalOutput = Array.isArray(combined.output) ? combined.output : [];
@@ -384,12 +814,15 @@ export class CodexLbClient {
384
814
  ? combined.status
385
815
  : streamedError ? "failed" : "completed";
386
816
  if (streamedError || status === "failed" || status === "incomplete") {
387
- const detail = detailFrom(streamedError ?? combined.error ?? combined.incomplete_details);
388
- 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
+ });
389
821
  }
390
822
  if (typeof combined.id !== "string" || !combined.id || canonicalOutput.length === 0) {
391
- throw Object.assign(new Error("Response stream returned an invalid completed response"), {
392
- code: "malformed_response",
823
+ throw providerError({ code: "malformed_response", message: "response stream returned an invalid completed response" }, {
824
+ ...context,
825
+ protocol: true,
393
826
  });
394
827
  }
395
828
  const canonical = {
@@ -403,10 +836,68 @@ export class CodexLbClient {
403
836
  return this.config.gateway.provider === "lm-studio" ? redactLmStudioReasoning(canonical) : canonical;
404
837
  }
405
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
+ }
406
894
  if (this.config.gateway.provider === "lm-studio") {
407
895
  const transcript = compactTranscript(request.input);
896
+ const compactContext = requestContext("lm-studio", "compact", this.apiKey, request.signal);
408
897
  if (!transcript)
409
- 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
+ });
410
901
  const profile = {
411
902
  model: request.model,
412
903
  instructions: [
@@ -425,14 +916,44 @@ export class CodexLbClient {
425
916
  promptCacheKey: request.promptCacheKey,
426
917
  reasoningEffort: "none",
427
918
  supportsReasoning: true,
919
+ supportsParallelToolCalls: false,
428
920
  verbosity: "high",
429
921
  fast: false,
430
922
  ...(request.signal ? { signal: request.signal } : {}),
431
923
  };
432
- 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
+ }
433
952
  const summary = responseText(response).trim();
434
953
  if (!summary)
435
- 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
+ });
436
957
  return {
437
958
  id: `compact_${response.id}`,
438
959
  object: "response.compaction",
@@ -458,29 +979,30 @@ export class CodexLbClient {
458
979
  };
459
980
  const timeout = AbortSignal.timeout(this.config.gateway.timeoutMs);
460
981
  const signal = request.signal ? AbortSignal.any([request.signal, timeout]) : timeout;
461
- const response = await fetch(`${this.config.gateway.baseURL}/responses/compact`, {
462
- method: "POST",
463
- headers: {
464
- authorization: `Bearer ${this.apiKey}`,
465
- "content-type": "application/json",
466
- },
467
- body: JSON.stringify(body),
468
- signal,
469
- });
470
- const payload = await response.json();
471
- if (!response.ok) {
472
- const detail = detailFrom(payload);
473
- throw Object.assign(new Error(this.safeErrorMessage(detail.message ?? `Compaction failed with HTTP ${response.status}`)), detail, {
474
- 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,
475
993
  });
476
994
  }
995
+ catch (error) {
996
+ throw providerError(error, context);
997
+ }
998
+ const payload = await readJsonResponse(response, context);
477
999
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
478
- throw new Error("Compaction returned an invalid payload");
1000
+ throw providerError({ code: "malformed_response", message: "compaction returned an invalid payload" }, { ...context, protocol: true });
479
1001
  }
480
1002
  const output = payload.output;
481
1003
  if (!Array.isArray(output) || output.length === 0
482
1004
  || output.some((item) => !item || typeof item !== "object" || Array.isArray(item))) {
483
- 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 });
484
1006
  }
485
1007
  return payload;
486
1008
  }