dsh-local-ai 0.1.4 → 0.2.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.es.md +10 -0
  3. package/README.hi.md +10 -0
  4. package/README.md +12 -0
  5. package/README.pt.md +10 -0
  6. package/README.zh.md +10 -0
  7. package/cordis.patch.yml +14 -2
  8. package/lib/index.js +644 -64
  9. package/lib/types/config.d.ts +40 -0
  10. package/lib/types/config.d.ts.map +1 -1
  11. package/lib/types/config.js +92 -24
  12. package/lib/types/config.js.map +1 -1
  13. package/lib/types/index.d.ts +12 -9
  14. package/lib/types/index.d.ts.map +1 -1
  15. package/lib/types/index.js +22 -9
  16. package/lib/types/index.js.map +1 -1
  17. package/lib/types/openai-adapter.d.ts +37 -0
  18. package/lib/types/openai-adapter.d.ts.map +1 -0
  19. package/lib/types/openai-adapter.js +181 -0
  20. package/lib/types/openai-adapter.js.map +1 -0
  21. package/lib/types/openai-serialize.d.ts +61 -0
  22. package/lib/types/openai-serialize.d.ts.map +1 -0
  23. package/lib/types/openai-serialize.js +115 -0
  24. package/lib/types/openai-serialize.js.map +1 -0
  25. package/lib/types/openai-translate.d.ts +53 -0
  26. package/lib/types/openai-translate.d.ts.map +1 -0
  27. package/lib/types/openai-translate.js +168 -0
  28. package/lib/types/openai-translate.js.map +1 -0
  29. package/lib/types/openai.d.ts +30 -0
  30. package/lib/types/openai.d.ts.map +1 -0
  31. package/lib/types/openai.js +109 -0
  32. package/lib/types/openai.js.map +1 -0
  33. package/lib/types/route.d.ts +19 -8
  34. package/lib/types/route.d.ts.map +1 -1
  35. package/lib/types/route.js +26 -10
  36. package/lib/types/route.js.map +1 -1
  37. package/lib/types/version.d.ts +1 -1
  38. package/lib/types/version.js +1 -1
  39. package/package.json +1 -1
  40. package/src/config.ts +127 -19
  41. package/src/index.ts +24 -10
  42. package/src/openai-adapter.ts +145 -0
  43. package/src/openai-serialize.ts +147 -0
  44. package/src/openai-translate.ts +208 -0
  45. package/src/openai.ts +121 -0
  46. package/src/route.ts +28 -11
  47. package/src/version.ts +1 -1
package/lib/index.js CHANGED
@@ -31,8 +31,23 @@ const Config = z.object({
31
31
  maxTokens: z.number(),
32
32
  temperature: z.number()
33
33
  })).default([]),
34
+ backends: z.array(z.object({
35
+ name: z.string().required(),
36
+ baseURL: z.string().required(),
37
+ apiKey: z.string(),
38
+ models: z.array(z.object({
39
+ name: z.string().required(),
40
+ model: z.string(),
41
+ contextWindow: z.number(),
42
+ maxTokens: z.number(),
43
+ temperature: z.number()
44
+ })).default([]),
45
+ maxTokens: z.number(),
46
+ temperature: z.number()
47
+ })).default([]),
34
48
  route: z.array(z.object({
35
49
  model: z.string().required(),
50
+ provider: z.string(),
36
51
  purpose: z.union(["compaction", "session-title"]),
37
52
  keywords: z.array(z.string()).default([]),
38
53
  always: z.boolean().default(false)
@@ -86,30 +101,33 @@ function resolveConfig(config = {}) {
86
101
  const temperature = config.temperature;
87
102
  if (temperature !== void 0) assertFiniteRange("temperature", temperature, 0, 2);
88
103
  const vision = config.vision ?? true;
89
- const seenNames = /* @__PURE__ */ new Set();
90
- const models = (config.models ?? []).map((mapping, index) => {
91
- if (typeof mapping.name !== "string" || mapping.name.trim().length === 0) throw new TypeError(`models[${index}].name must be a non-empty string`);
92
- const name = mapping.name.trim();
93
- if (seenNames.has(name)) throw new TypeError(`models[${index}]: duplicate model name ${JSON.stringify(name)}`);
94
- seenNames.add(name);
95
- const model = (mapping.model ?? name).trim();
96
- if (model.length === 0) throw new TypeError(`models[${index}].model must be a non-empty string`);
97
- const contextWindow = mapping.contextWindow;
98
- if (contextWindow !== void 0) assertPositiveInt(`models[${index}].contextWindow`, contextWindow);
99
- const modelMaxTokens = mapping.maxTokens;
100
- if (modelMaxTokens !== void 0) assertPositiveInt(`models[${index}].maxTokens`, modelMaxTokens);
101
- const modelTemperature = mapping.temperature;
102
- if (modelTemperature !== void 0) assertFiniteRange(`models[${index}].temperature`, modelTemperature, 0, 2);
104
+ const models = resolveMappings(config.models, "models");
105
+ const backends = (config.backends ?? []).map((backend, index) => {
106
+ if (typeof backend.name !== "string" || backend.name.trim().length === 0) throw new TypeError(`backends[${index}].name must be a non-empty string`);
107
+ const name = backend.name.trim();
108
+ if (name === "ollama") throw new TypeError(`backends[${index}].name must not be the reserved provider id ${JSON.stringify(DEFAULT_PROVIDER)}`);
109
+ if (typeof backend.baseURL !== "string" || backend.baseURL.trim().length === 0) throw new TypeError(`backends[${index}].baseURL must be a non-empty string`);
110
+ const backendBaseURL = normalizeBaseUrl(`backends[${index}].baseURL`, backend.baseURL);
111
+ const backendMaxTokens = backend.maxTokens ?? maxTokens;
112
+ assertPositiveInt(`backends[${index}].maxTokens`, backendMaxTokens);
113
+ const backendTemperature = backend.temperature;
114
+ if (backendTemperature !== void 0) assertFiniteRange(`backends[${index}].temperature`, backendTemperature, 0, 2);
103
115
  return {
104
116
  name,
105
- model,
106
- ...contextWindow === void 0 ? {} : { contextWindow },
107
- ...modelMaxTokens === void 0 ? {} : { maxTokens: modelMaxTokens },
108
- ...modelTemperature === void 0 ? {} : { temperature: modelTemperature }
117
+ providerId: openaiProviderId(name),
118
+ baseURL: backendBaseURL,
119
+ apiKey: backend.apiKey ?? "",
120
+ defaultContextWindow,
121
+ maxTokens: backendMaxTokens,
122
+ ...backendTemperature === void 0 ? {} : { temperature: backendTemperature },
123
+ requestTimeoutMs,
124
+ models: resolveMappings(backend.models, `backends[${index}].models`)
109
125
  };
110
126
  });
111
127
  const route = (config.route ?? []).map((rule, index) => {
112
128
  if (typeof rule.model !== "string" || rule.model.trim().length === 0) throw new TypeError(`route[${index}].model must be a non-empty string`);
129
+ const provider = (rule.provider ?? "ollama").trim();
130
+ if (provider.length === 0) throw new TypeError(`route[${index}].provider must be a non-empty string`);
113
131
  const keywords = (rule.keywords ?? []).map((keyword, keywordIndex) => {
114
132
  if (typeof keyword !== "string" || keyword.trim().length === 0) throw new TypeError(`route[${index}].keywords[${keywordIndex}] must be a non-empty string`);
115
133
  return keyword;
@@ -117,6 +135,7 @@ function resolveConfig(config = {}) {
117
135
  if (rule.always !== true && rule.purpose === void 0 && keywords.length === 0) throw new TypeError(`route[${index}] must declare a purpose, at least one keyword, or always: true`);
118
136
  return {
119
137
  model: rule.model.trim(),
138
+ provider,
120
139
  ...rule.purpose === void 0 ? {} : { purpose: rule.purpose },
121
140
  keywords,
122
141
  always: rule.always ?? false
@@ -131,9 +150,49 @@ function resolveConfig(config = {}) {
131
150
  ...temperature === void 0 ? {} : { temperature },
132
151
  vision,
133
152
  models,
153
+ backends,
134
154
  route
135
155
  };
136
156
  }
157
+ /** The provider id routing rules target when `provider` is omitted. */
158
+ const DEFAULT_PROVIDER = "ollama";
159
+ /** Prefix for OpenAI-compatible backend provider ids. */
160
+ const OPENAI_PROVIDER_PREFIX = "openai:";
161
+ /** Build the harness provider id for one OpenAI-compatible backend. */
162
+ function openaiProviderId(name) {
163
+ return `${OPENAI_PROVIDER_PREFIX}${name}`;
164
+ }
165
+ /**
166
+ * Validate and normalize one model-mapping list (top-level or per-backend).
167
+ * Duplicate harness-visible names throw; the wire id defaults to the name.
168
+ * @param mappings - the raw mappings.
169
+ * @param label - the config path prefix for error messages.
170
+ * @returns the resolved mappings.
171
+ */
172
+ function resolveMappings(mappings, label) {
173
+ const seenNames = /* @__PURE__ */ new Set();
174
+ return (mappings ?? []).map((mapping, index) => {
175
+ if (typeof mapping.name !== "string" || mapping.name.trim().length === 0) throw new TypeError(`${label}[${index}].name must be a non-empty string`);
176
+ const name = mapping.name.trim();
177
+ if (seenNames.has(name)) throw new TypeError(`${label}[${index}]: duplicate model name ${JSON.stringify(name)}`);
178
+ seenNames.add(name);
179
+ const model = (mapping.model ?? name).trim();
180
+ if (model.length === 0) throw new TypeError(`${label}[${index}].model must be a non-empty string`);
181
+ const contextWindow = mapping.contextWindow;
182
+ if (contextWindow !== void 0) assertPositiveInt(`${label}[${index}].contextWindow`, contextWindow);
183
+ const modelMaxTokens = mapping.maxTokens;
184
+ if (modelMaxTokens !== void 0) assertPositiveInt(`${label}[${index}].maxTokens`, modelMaxTokens);
185
+ const modelTemperature = mapping.temperature;
186
+ if (modelTemperature !== void 0) assertFiniteRange(`${label}[${index}].temperature`, modelTemperature, 0, 2);
187
+ return {
188
+ name,
189
+ model,
190
+ ...contextWindow === void 0 ? {} : { contextWindow },
191
+ ...modelMaxTokens === void 0 ? {} : { maxTokens: modelMaxTokens },
192
+ ...modelTemperature === void 0 ? {} : { temperature: modelTemperature }
193
+ };
194
+ });
195
+ }
137
196
  //#endregion
138
197
  //#region src/sanitize.ts
139
198
  /**
@@ -261,7 +320,7 @@ function httpErrorCode(status) {
261
320
  return `HTTP_${status}`;
262
321
  }
263
322
  /** Throw a normalized LlmError from a non-2xx response, using the body's `error`. */
264
- async function throwHttpError(response, context) {
323
+ async function throwHttpError$1(response, context) {
265
324
  let message = `Ollama API error (HTTP ${response.status}) from ${sanitizeEndpoint(context)}`;
266
325
  try {
267
326
  const body = await response.json();
@@ -279,7 +338,7 @@ async function requestJson(baseURL, path, fetchImpl, signal) {
279
338
  },
280
339
  ...signal === void 0 ? {} : { signal }
281
340
  });
282
- if (!response.ok) await throwHttpError(response, endpointUrl(baseURL, path));
341
+ if (!response.ok) await throwHttpError$1(response, endpointUrl(baseURL, path));
283
342
  return response.json();
284
343
  }
285
344
  /** Send a POST request and parse the JSON response. */
@@ -294,7 +353,7 @@ async function postJson(baseURL, path, body, fetchImpl, signal) {
294
353
  body: JSON.stringify(body),
295
354
  ...signal === void 0 ? {} : { signal }
296
355
  });
297
- if (!response.ok) await throwHttpError(response, endpointUrl(baseURL, path));
356
+ if (!response.ok) await throwHttpError$1(response, endpointUrl(baseURL, path));
298
357
  return response.json();
299
358
  }
300
359
  /** Send a DELETE request and parse the JSON response. */
@@ -309,7 +368,7 @@ async function deleteJson(baseURL, path, body, fetchImpl, signal) {
309
368
  body: JSON.stringify(body),
310
369
  ...signal === void 0 ? {} : { signal }
311
370
  });
312
- if (!response.ok) await throwHttpError(response, endpointUrl(baseURL, path));
371
+ if (!response.ok) await throwHttpError$1(response, endpointUrl(baseURL, path));
313
372
  return response.json();
314
373
  }
315
374
  /**
@@ -327,11 +386,11 @@ async function postStream(baseURL, path, body, fetchImpl, signal) {
327
386
  body: JSON.stringify(body),
328
387
  ...signal === void 0 ? {} : { signal }
329
388
  });
330
- if (!response.ok) await throwHttpError(response, endpointUrl(baseURL, path));
389
+ if (!response.ok) await throwHttpError$1(response, endpointUrl(baseURL, path));
331
390
  return response;
332
391
  }
333
392
  /** List installed models from `/api/tags`. */
334
- async function listModels(baseURL, fetchImpl, signal) {
393
+ async function listModels$1(baseURL, fetchImpl, signal) {
335
394
  return (await requestJson(baseURL, "/api/tags", fetchImpl, signal)).models ?? [];
336
395
  }
337
396
  /** List currently-loaded (running) models from `/api/ps`. */
@@ -427,7 +486,7 @@ function contextLengthOf(show) {
427
486
  * @module dsh-local-ai/serialize
428
487
  */
429
488
  /** Join the text blocks of a message (used for user/tool-result content). */
430
- function flattenText(blocks) {
489
+ function flattenText$1(blocks) {
431
490
  return blocks.filter((block) => block.type === "text").map((block) => block.text).join("");
432
491
  }
433
492
  /**
@@ -436,7 +495,7 @@ function flattenText(blocks) {
436
495
  * image (non-user roles, nested tool results, or a text-only route without
437
496
  * payloads) still fails loud instead of silently dropping the image.
438
497
  */
439
- function assertImagesSupported(message, imagesByRef) {
498
+ function assertImagesSupported$1(message, imagesByRef) {
440
499
  for (const block of message.content) if (block.type === "tool-result" && contentHasImage(block.content)) throw new LlmError("The Ollama adapter does not support tool-result image content.", "UNSUPPORTED_CONTENT");
441
500
  const hasTopLevelImage = message.content.some((block) => block.type === "image");
442
501
  if (message.role !== "user" && hasTopLevelImage) throw new LlmError(`The Ollama adapter does not support image content on ${message.role} messages.`, "UNSUPPORTED_CONTENT");
@@ -472,8 +531,8 @@ function parseToolArguments(raw) {
472
531
  }
473
532
  }
474
533
  /** Serialize one assistant message (text + tool calls). */
475
- function serializeAssistant(message) {
476
- const text = flattenText(message.content);
534
+ function serializeAssistant$1(message) {
535
+ const text = flattenText$1(message.content);
477
536
  const toolCalls = message.content.filter((block) => block.type === "tool-call").map((block) => ({ function: {
478
537
  name: block.name,
479
538
  arguments: parseToolArguments(block.arguments)
@@ -497,7 +556,7 @@ function toolNameOf(callId, namesByCallId) {
497
556
  * @param messages - the harness conversation, in order.
498
557
  * @returns the wire messages; order preserved, each tool result expanded into its own entry.
499
558
  */
500
- function serializeMessages(messages, imagesByRef) {
559
+ function serializeMessages$1(messages, imagesByRef) {
501
560
  const namesByCallId = /* @__PURE__ */ new Map();
502
561
  for (const message of messages) {
503
562
  if (message.role !== "assistant") continue;
@@ -505,21 +564,21 @@ function serializeMessages(messages, imagesByRef) {
505
564
  }
506
565
  const wire = [];
507
566
  for (const message of messages) {
508
- assertImagesSupported(message, imagesByRef);
567
+ assertImagesSupported$1(message, imagesByRef);
509
568
  const images = imagesOf(message, imagesByRef);
510
569
  if (message.role === "system") {
511
570
  wire.push({
512
571
  role: "system",
513
- content: flattenText(message.content)
572
+ content: flattenText$1(message.content)
514
573
  });
515
574
  continue;
516
575
  }
517
576
  if (message.role === "assistant") {
518
- wire.push(serializeAssistant(message));
577
+ wire.push(serializeAssistant$1(message));
519
578
  continue;
520
579
  }
521
580
  const toolResults = message.content.filter((block) => block.type === "tool-result");
522
- const text = flattenText(message.content);
581
+ const text = flattenText$1(message.content);
523
582
  if (text.length > 0 || toolResults.length === 0 || images.length > 0) wire.push({
524
583
  role: "user",
525
584
  content: text,
@@ -529,7 +588,7 @@ function serializeMessages(messages, imagesByRef) {
529
588
  const name = toolNameOf(String(result.toolCallId), namesByCallId);
530
589
  wire.push({
531
590
  role: "tool",
532
- content: flattenText(result.content) || "(no output)",
591
+ content: flattenText$1(result.content) || "(no output)",
533
592
  ...name === void 0 ? {} : { tool_name: name }
534
593
  });
535
594
  }
@@ -545,7 +604,7 @@ function serializeMessages(messages, imagesByRef) {
545
604
  * @param resolved - the resolved plugin config.
546
605
  * @returns the `/api/chat` request body.
547
606
  */
548
- function serializeRequest(options, resolved, imagesByRef) {
607
+ function serializeRequest$1(options, resolved, imagesByRef) {
549
608
  const mapping = resolved.models.find((entry) => entry.name === options.model);
550
609
  const model = mapping?.model ?? options.model;
551
610
  const messages = [];
@@ -553,7 +612,7 @@ function serializeRequest(options, resolved, imagesByRef) {
553
612
  role: "system",
554
613
  content: options.system
555
614
  });
556
- messages.push(...serializeMessages(options.messages, imagesByRef));
615
+ messages.push(...serializeMessages$1(options.messages, imagesByRef));
557
616
  const temperature = options.temperature ?? mapping?.temperature ?? resolved.temperature;
558
617
  const ollamaOptions = {};
559
618
  if (temperature !== void 0) ollamaOptions.temperature = temperature;
@@ -602,7 +661,7 @@ function mapFinishReason(reason) {
602
661
  }
603
662
  }
604
663
  /** Assemble the final ContentBlock for one open block. */
605
- function closeBlock(block) {
664
+ function closeBlock$1(block) {
606
665
  switch (block.kind) {
607
666
  case "text": return {
608
667
  type: "text",
@@ -629,7 +688,7 @@ function closeBlock(block) {
629
688
  * @param block - the freshly opened tool-call block.
630
689
  * @returns the minted id.
631
690
  */
632
- function mintCallId(block) {
691
+ function mintCallId$1(block) {
633
692
  return `ollama-${Date.now().toString(36)}-${block.index}-${Math.random().toString(36).slice(2, 8)}`;
634
693
  }
635
694
  /**
@@ -753,7 +812,7 @@ async function* translate(lines) {
753
812
  let block = toolBlocks.get(callIndex);
754
813
  if (!block) {
755
814
  block = open("tool-call");
756
- block.callId = mintCallId(block);
815
+ block.callId = mintCallId$1(block);
757
816
  toolBlocks.set(callIndex, block);
758
817
  toolArguments.set(callIndex, "");
759
818
  yield {
@@ -768,7 +827,7 @@ async function* translate(lines) {
768
827
  if (cumulative !== previous) {
769
828
  if (!isMonotonicExtension(previous, cumulative)) {
770
829
  block = open("tool-call");
771
- block.callId = mintCallId(block);
830
+ block.callId = mintCallId$1(block);
772
831
  if (call?.function?.name !== void 0) block.name = call.function.name;
773
832
  toolBlocks.set(callIndex, block);
774
833
  toolArguments.set(callIndex, "");
@@ -795,7 +854,7 @@ async function* translate(lines) {
795
854
  for (const block of order) yield {
796
855
  type: "block-end",
797
856
  index: block.index,
798
- block: closeBlock(block)
857
+ block: closeBlock$1(block)
799
858
  };
800
859
  const promptCount = chunk.prompt_eval_count;
801
860
  const evalCount = chunk.eval_count;
@@ -892,7 +951,7 @@ function _usingCtx() {
892
951
  * @module dsh-local-ai/adapter
893
952
  */
894
953
  /** Idle-timeout abort code stamped onto a stalled stream's timeout reason. */
895
- const STREAM_IDLE_TIMEOUT_CODE = "LLM_STREAM_IDLE_TIMEOUT";
954
+ const STREAM_IDLE_TIMEOUT_CODE$1 = "LLM_STREAM_IDLE_TIMEOUT";
896
955
  /**
897
956
  * Deterministic request-image policy: aspect-preserving projection at ≤ 4 MP
898
957
  * and a 10 MiB encoded-byte cap per image (a protocol default, not a tunable —
@@ -948,7 +1007,7 @@ var OllamaAdapter = class extends LlmAdapter {
948
1007
  }
949
1008
  async listModels(provider) {
950
1009
  const resolved = this.options.config();
951
- const models = await listModels(resolved.baseURL, this.fetchImpl()).catch(() => []);
1010
+ const models = await listModels$1(resolved.baseURL, this.fetchImpl()).catch(() => []);
952
1011
  const list = models.length > 0 ? models : resolved.models.map((entry) => ({
953
1012
  name: entry.model,
954
1013
  model: entry.model,
@@ -973,11 +1032,11 @@ var OllamaAdapter = class extends LlmAdapter {
973
1032
  }
974
1033
  async *stream(options) {
975
1034
  try {
976
- var _usingCtx$1 = _usingCtx();
1035
+ var _usingCtx$2 = _usingCtx();
977
1036
  const resolved = this.options.config();
978
1037
  const consumer = new AbortController();
979
1038
  const upstream = options.signal === void 0 ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]);
980
- const watchdog = _usingCtx$1.u(idleWatchdog(upstream, resolved.requestTimeoutMs, STREAM_IDLE_TIMEOUT_CODE));
1039
+ const watchdog = _usingCtx$2.u(idleWatchdog(upstream, resolved.requestTimeoutMs, STREAM_IDLE_TIMEOUT_CODE$1));
981
1040
  const iterator = this.request(options, resolved, watchdog.signal)[Symbol.asyncIterator]();
982
1041
  let exhausted = false;
983
1042
  try {
@@ -990,7 +1049,7 @@ var OllamaAdapter = class extends LlmAdapter {
990
1049
  yield result.value;
991
1050
  }
992
1051
  } catch (error) {
993
- if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== void 0) throw new LlmError(`Ollama stream idle timeout after ${resolved.requestTimeoutMs}ms`, "TIMEOUT", { cause: error });
1052
+ if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE$1) !== void 0) throw new LlmError(`Ollama stream idle timeout after ${resolved.requestTimeoutMs}ms`, "TIMEOUT", { cause: error });
994
1053
  if (options.signal?.aborted) throw new LlmError("Ollama request aborted by caller", "ABORTED", { cause: error });
995
1054
  if (error instanceof LlmError) throw error;
996
1055
  throw new LlmError(`Ollama API stream from ${sanitizeEndpoint(resolved.baseURL)} failed`, "TRANSPORT", { cause: error });
@@ -1001,13 +1060,13 @@ var OllamaAdapter = class extends LlmAdapter {
1001
1060
  } catch (_abortedTransportTeardown) {}
1002
1061
  }
1003
1062
  } catch (_) {
1004
- _usingCtx$1.e = _;
1063
+ _usingCtx$2.e = _;
1005
1064
  } finally {
1006
- _usingCtx$1.d();
1065
+ _usingCtx$2.d();
1007
1066
  }
1008
1067
  }
1009
1068
  async *request(options, resolved, signal) {
1010
- const body = serializeRequest(options, resolved, await this.prepareImages(options, resolved, signal));
1069
+ const body = serializeRequest$1(options, resolved, await this.prepareImages(options, resolved, signal));
1011
1070
  let response;
1012
1071
  try {
1013
1072
  response = await postStream(resolved.baseURL, "/api/chat", body, this.fetchImpl(), signal);
@@ -1041,16 +1100,518 @@ var OllamaAdapter = class extends LlmAdapter {
1041
1100
  }
1042
1101
  };
1043
1102
  //#endregion
1103
+ //#region src/openai.ts
1104
+ /**
1105
+ * OpenAI-compatible HTTP client (zero runtime dependencies — plain `fetch`).
1106
+ * Serves the OpenAI wire protocol spoken by LM Studio, vLLM, and llama.cpp
1107
+ * `--server`: model listing via `GET /v1/models` and streaming chat via
1108
+ * `POST /v1/chat/completions` (SSE). The base URL already carries the `/v1`
1109
+ * prefix (e.g. `http://127.0.0.1:1234/v1` for LM Studio). Every request
1110
+ * carries the harness attribution headers, an optional bearer key, and honors
1111
+ * the caller's abort signal; non-2xx responses fail with a normalized
1112
+ * `LlmError`. The fetch implementation is injectable for tests.
1113
+ * @module dsh-local-ai/openai
1114
+ */
1115
+ /** Build the request headers, adding a bearer key only when one is configured. */
1116
+ function requestHeaders(apiKey, accept) {
1117
+ const headers = {
1118
+ "content-type": "application/json",
1119
+ accept,
1120
+ ...attributionHeaders()
1121
+ };
1122
+ if (apiKey.length > 0) headers.authorization = `Bearer ${apiKey}`;
1123
+ return headers;
1124
+ }
1125
+ /** Throw a normalized LlmError from a non-2xx response, reading the OpenAI error body. */
1126
+ async function throwHttpError(response, baseURL) {
1127
+ let message = `OpenAI-compatible API error (HTTP ${response.status}) from ${sanitizeEndpoint(baseURL)}`;
1128
+ try {
1129
+ const body = await response.json();
1130
+ if (typeof body.error === "string" && body.error.length > 0) message = body.error;
1131
+ else if (body.error !== null && typeof body.error === "object") {
1132
+ const detail = body.error.message;
1133
+ if (typeof detail === "string" && detail.length > 0) message = detail;
1134
+ }
1135
+ } catch {}
1136
+ const code = response.status === 404 ? "NOT_FOUND" : response.status === 400 ? "INVALID_REQUEST" : response.status === 401 || response.status === 403 ? `HTTP_${response.status}` : response.status >= 500 ? "SERVER" : `HTTP_${response.status}`;
1137
+ throw new LlmError(message, code, { status: response.status });
1138
+ }
1139
+ /** List installed models from `GET /v1/models`. */
1140
+ async function listModels(baseURL, apiKey, fetchImpl, signal) {
1141
+ const response = await fetchImpl(`${baseURL}/models`, {
1142
+ method: "GET",
1143
+ headers: requestHeaders(apiKey, "application/json"),
1144
+ ...signal === void 0 ? {} : { signal }
1145
+ });
1146
+ if (!response.ok) await throwHttpError(response, baseURL);
1147
+ return (await response.json()).data ?? [];
1148
+ }
1149
+ /** Send a streaming `POST /v1/chat/completions` and return the raw 2xx response. */
1150
+ async function postChatCompletions(baseURL, apiKey, body, fetchImpl, signal) {
1151
+ const response = await fetchImpl(`${baseURL}/chat/completions`, {
1152
+ method: "POST",
1153
+ headers: requestHeaders(apiKey, "text/event-stream"),
1154
+ body: JSON.stringify(body),
1155
+ ...signal === void 0 ? {} : { signal }
1156
+ });
1157
+ if (!response.ok) await throwHttpError(response, baseURL);
1158
+ return response;
1159
+ }
1160
+ /**
1161
+ * Decode a `ReadableStream<Uint8Array>` into SSE `data:` payload lines. Blank
1162
+ * lines and non-`data:` comments are skipped; the `data:` prefix is stripped;
1163
+ * the final line is yielded even without a trailing newline. The `[DONE]`
1164
+ * sentinel is yielded verbatim so the translator can close the stream.
1165
+ * @param body - the response body stream.
1166
+ * @returns `data:` payload strings in delivery order (including `[DONE]`).
1167
+ */
1168
+ async function* readSseLines(body) {
1169
+ const reader = body.getReader();
1170
+ const decoder = new TextDecoder();
1171
+ let buffer = "";
1172
+ try {
1173
+ while (true) {
1174
+ const { done, value } = await reader.read();
1175
+ if (done) break;
1176
+ buffer += decoder.decode(value, { stream: true });
1177
+ let newline = buffer.indexOf("\n");
1178
+ while (newline >= 0) {
1179
+ const line = buffer.slice(0, newline).replace(/\r$/u, "");
1180
+ buffer = buffer.slice(newline + 1);
1181
+ newline = buffer.indexOf("\n");
1182
+ if (!line.startsWith("data:")) continue;
1183
+ yield line.slice(5).trimStart();
1184
+ }
1185
+ }
1186
+ } finally {
1187
+ reader.releaseLock();
1188
+ }
1189
+ buffer += decoder.decode();
1190
+ if (buffer.startsWith("data:")) yield buffer.slice(5).trimStart();
1191
+ }
1192
+ //#endregion
1193
+ //#region src/openai-serialize.ts
1194
+ /**
1195
+ * Serialize harness messages and requests into the OpenAI `/v1/chat/completions`
1196
+ * wire vocabulary (spoken by LM Studio, vLLM, and llama.cpp). User text is
1197
+ * joined; assistant text becomes `content` (or `null` when the message carries
1198
+ * tool calls) and tool calls become `tool_calls` with their raw JSON argument
1199
+ * string; tool results become separate `{role: 'tool'}` messages keyed by
1200
+ * `tool_call_id`. Image content is rejected loudly — the OpenAI-compatible
1201
+ * adapter is text-only (multimodal backends are out of scope for this route).
1202
+ * Unknown declaration-merged block types retain the documented extension
1203
+ * fallback (ignored for content, retained as text where text is expected).
1204
+ * @module dsh-local-ai/openai-serialize
1205
+ */
1206
+ /** Join the text blocks of a message (used for user/tool-result content). */
1207
+ function flattenText(blocks) {
1208
+ return blocks.filter((block) => block.type === "text").map((block) => block.text).join("");
1209
+ }
1210
+ /** Reject image content the OpenAI-compatible wire cannot carry. */
1211
+ function assertImagesSupported(message) {
1212
+ for (const block of message.content) if (block.type === "image" || block.type === "tool-result" && contentHasImage(block.content)) throw new LlmError("The OpenAI-compatible adapter does not support image content for this backend.", "UNSUPPORTED_CONTENT");
1213
+ }
1214
+ /** Serialize one assistant message (text + tool calls). */
1215
+ function serializeAssistant(message) {
1216
+ const text = flattenText(message.content);
1217
+ const toolCalls = message.content.filter((block) => block.type === "tool-call").map((block) => ({
1218
+ id: String(block.id),
1219
+ type: "function",
1220
+ function: {
1221
+ name: block.name,
1222
+ arguments: block.arguments
1223
+ }
1224
+ }));
1225
+ return {
1226
+ role: "assistant",
1227
+ content: toolCalls.length > 0 ? text.length > 0 ? text : null : text,
1228
+ ...toolCalls.length > 0 ? { tool_calls: toolCalls } : {}
1229
+ };
1230
+ }
1231
+ /**
1232
+ * Serialize the conversation. `tool-result` blocks become standalone
1233
+ * `{role: 'tool'}` messages (the harness puts each tool result in its own
1234
+ * user-role message, so a mixed user message contributes its text first and
1235
+ * its tool results as separate wire messages after).
1236
+ * @param messages - the harness conversation, in order.
1237
+ * @returns the wire messages; order preserved, each tool result expanded into its own entry.
1238
+ */
1239
+ function serializeMessages(messages) {
1240
+ const wire = [];
1241
+ for (const message of messages) {
1242
+ assertImagesSupported(message);
1243
+ if (message.role === "system") {
1244
+ wire.push({
1245
+ role: "system",
1246
+ content: flattenText(message.content)
1247
+ });
1248
+ continue;
1249
+ }
1250
+ if (message.role === "assistant") {
1251
+ wire.push(serializeAssistant(message));
1252
+ continue;
1253
+ }
1254
+ const toolResults = message.content.filter((block) => block.type === "tool-result");
1255
+ const text = flattenText(message.content);
1256
+ if (text.length > 0 || toolResults.length === 0) wire.push({
1257
+ role: "user",
1258
+ content: text
1259
+ });
1260
+ for (const result of toolResults) wire.push({
1261
+ role: "tool",
1262
+ tool_call_id: String(result.toolCallId),
1263
+ content: flattenText(result.content) || "(no output)"
1264
+ });
1265
+ }
1266
+ return wire;
1267
+ }
1268
+ /**
1269
+ * Build the full wire request. Always streaming (`stream: true`); optional
1270
+ * fields are omitted rather than sent as null, so the backend defaults apply.
1271
+ * `temperature` resolves request → model mapping → backend default; `max_tokens`
1272
+ * is the harness-materialized `maxTokens`.
1273
+ * @param options - the harness request (model, history, system, tools, sampling).
1274
+ * @param backend - the resolved OpenAI-compatible backend.
1275
+ * @returns the `/v1/chat/completions` request body.
1276
+ */
1277
+ function serializeRequest(options, backend) {
1278
+ const mapping = backend.models.find((entry) => entry.name === options.model);
1279
+ const model = mapping?.model ?? options.model;
1280
+ const messages = [];
1281
+ if (options.system !== void 0) messages.push({
1282
+ role: "system",
1283
+ content: options.system
1284
+ });
1285
+ messages.push(...serializeMessages(options.messages));
1286
+ const temperature = options.temperature ?? mapping?.temperature ?? backend.temperature;
1287
+ const tools = options.tools?.map((tool) => ({
1288
+ type: "function",
1289
+ function: tool
1290
+ }));
1291
+ return {
1292
+ model,
1293
+ messages,
1294
+ stream: true,
1295
+ ...temperature === void 0 ? {} : { temperature },
1296
+ ...options.maxTokens === void 0 ? {} : { max_tokens: options.maxTokens },
1297
+ ...options.stop !== void 0 && options.stop.length > 0 ? { stop: options.stop } : {},
1298
+ ...tools !== void 0 && tools.length > 0 ? { tools } : {}
1299
+ };
1300
+ }
1301
+ //#endregion
1302
+ //#region src/openai-translate.ts
1303
+ /**
1304
+ * Translate OpenAI-compatible SSE chat chunks into the harness `StreamChunk`
1305
+ * protocol. Each `data:` payload carries `choices[].delta` (incremental
1306
+ * `content`, `reasoning_content`, and `tool_calls` whose `function.arguments`
1307
+ * fragments append directly) plus an eventual `finish_reason` and `usage`.
1308
+ * The `[DONE]` sentinel closes the stream; `block-end`, `usage`, and `finish`
1309
+ * are deferred until the end, guaranteeing no chunk follows `finish`.
1310
+ * @module dsh-local-ai/openai-translate
1311
+ */
1312
+ /**
1313
+ * Map the OpenAI `finish_reason` vocabulary to the harness FinishReason.
1314
+ * @param reason - the wire `finish_reason` string.
1315
+ * @returns the mapped reason; unrecognized values become `{kind: 'error'}`.
1316
+ */
1317
+ function mapOpenAIFinishReason(reason) {
1318
+ switch (reason) {
1319
+ case "stop": return { kind: "stop" };
1320
+ case "tool_calls":
1321
+ case "function_call": return { kind: "tool-calls" };
1322
+ case "length": return { kind: "max-tokens" };
1323
+ case "content_filter": return {
1324
+ kind: "error",
1325
+ failure: {
1326
+ message: "model stopped: content filter",
1327
+ code: "CONTENT_FILTER"
1328
+ }
1329
+ };
1330
+ default: return {
1331
+ kind: "error",
1332
+ failure: {
1333
+ message: `model stopped: ${reason}`,
1334
+ code: reason.toUpperCase()
1335
+ }
1336
+ };
1337
+ }
1338
+ }
1339
+ /** Assemble the final ContentBlock for one open block. */
1340
+ function closeBlock(block) {
1341
+ switch (block.kind) {
1342
+ case "text": return {
1343
+ type: "text",
1344
+ text: block.text
1345
+ };
1346
+ case "reasoning": return {
1347
+ type: "reasoning",
1348
+ text: block.text
1349
+ };
1350
+ case "tool-call": return {
1351
+ type: "tool-call",
1352
+ id: CallId(block.callId ?? ""),
1353
+ name: block.name ?? "",
1354
+ arguments: block.text
1355
+ };
1356
+ }
1357
+ }
1358
+ /**
1359
+ * Mint a session-unique tool-call id for a tool-call block that streamed no
1360
+ * wire id. Without it the emitted chunks and the persisted tool result carry an
1361
+ * empty CallId, and the session fails validation on resume.
1362
+ * @param block - the freshly opened tool-call block.
1363
+ * @returns the minted id.
1364
+ */
1365
+ function mintCallId(block) {
1366
+ return `openai-${Date.now().toString(36)}-${block.index}-${Math.random().toString(36).slice(2, 8)}`;
1367
+ }
1368
+ /**
1369
+ * Consume OpenAI SSE `data:` payload lines and yield StreamChunks. Text and
1370
+ * reasoning deltas stream as they arrive; tool-call argument fragments append
1371
+ * directly; `block-end`, `usage`, and `finish` are deferred to the `[DONE]`
1372
+ * sentinel. A `stop` finish with no opened blocks maps to an `EMPTY_RESPONSE`
1373
+ * error finish.
1374
+ * @param lines - `data:` payload strings (already prefix-stripped, including `[DONE]`).
1375
+ * @returns deltas as they arrive; `block-end`s, `usage`, and `finish` deferred to the end.
1376
+ */
1377
+ async function* translateOpenAIStream(lines) {
1378
+ let nextIndex = 0;
1379
+ let textBlock;
1380
+ let reasoningBlock;
1381
+ const toolBlocks = /* @__PURE__ */ new Map();
1382
+ const order = [];
1383
+ let finishReason = null;
1384
+ let usage;
1385
+ function open(kind) {
1386
+ const block = {
1387
+ index: nextIndex++,
1388
+ kind,
1389
+ text: ""
1390
+ };
1391
+ order.push(block);
1392
+ return block;
1393
+ }
1394
+ for await (const data of lines) {
1395
+ if (data === "[DONE]") break;
1396
+ if (data.length === 0) continue;
1397
+ let chunk;
1398
+ try {
1399
+ chunk = JSON.parse(data);
1400
+ } catch {
1401
+ throw new LlmError(`malformed OpenAI SSE payload: ${data.slice(0, 120)}`, "MALFORMED_RESPONSE");
1402
+ }
1403
+ const errorMessage = chunk.error?.message;
1404
+ if (typeof errorMessage === "string" && errorMessage.length > 0) throw new LlmError(errorMessage, "PROVIDER");
1405
+ for (const choice of chunk.choices ?? []) {
1406
+ const delta = choice.delta ?? {};
1407
+ const reasoning = delta.reasoning_content;
1408
+ if (typeof reasoning === "string" && reasoning.length > 0) {
1409
+ if (!reasoningBlock) {
1410
+ reasoningBlock = open("reasoning");
1411
+ yield {
1412
+ type: "block-start",
1413
+ index: reasoningBlock.index,
1414
+ blockType: "reasoning"
1415
+ };
1416
+ }
1417
+ reasoningBlock.text += reasoning;
1418
+ yield {
1419
+ type: "reasoning-delta",
1420
+ index: reasoningBlock.index,
1421
+ text: reasoning
1422
+ };
1423
+ }
1424
+ const content = delta.content;
1425
+ if (typeof content === "string" && content.length > 0) {
1426
+ if (!textBlock) {
1427
+ textBlock = open("text");
1428
+ yield {
1429
+ type: "block-start",
1430
+ index: textBlock.index,
1431
+ blockType: "text"
1432
+ };
1433
+ }
1434
+ textBlock.text += content;
1435
+ yield {
1436
+ type: "text-delta",
1437
+ index: textBlock.index,
1438
+ text: content
1439
+ };
1440
+ }
1441
+ for (const call of delta.tool_calls ?? []) {
1442
+ const index = call.index ?? 0;
1443
+ let block = toolBlocks.get(index);
1444
+ if (!block) {
1445
+ block = open("tool-call");
1446
+ block.callId = typeof call.id === "string" && call.id.length > 0 ? call.id : mintCallId(block);
1447
+ toolBlocks.set(index, block);
1448
+ yield {
1449
+ type: "block-start",
1450
+ index: block.index,
1451
+ blockType: "tool-call"
1452
+ };
1453
+ } else if (block.callId === void 0 && typeof call.id === "string" && call.id.length > 0) block.callId = call.id;
1454
+ if (block.name === void 0 && typeof call.function?.name === "string" && call.function.name.length > 0) block.name = call.function.name;
1455
+ const fragment = typeof call.function?.arguments === "string" ? call.function.arguments : "";
1456
+ if (fragment.length > 0) {
1457
+ block.text += fragment;
1458
+ yield {
1459
+ type: "tool-call-delta",
1460
+ index: block.index,
1461
+ id: CallId(block.callId ?? ""),
1462
+ ...block.name !== void 0 ? { name: block.name } : {},
1463
+ argumentsDelta: fragment
1464
+ };
1465
+ }
1466
+ }
1467
+ if (typeof choice.finish_reason === "string" && choice.finish_reason.length > 0) finishReason = choice.finish_reason;
1468
+ }
1469
+ if (chunk.usage !== void 0) usage = {
1470
+ inputTokens: chunk.usage.prompt_tokens ?? 0,
1471
+ outputTokens: chunk.usage.completion_tokens ?? 0
1472
+ };
1473
+ }
1474
+ for (const block of order) yield {
1475
+ type: "block-end",
1476
+ index: block.index,
1477
+ block: closeBlock(block)
1478
+ };
1479
+ if (usage !== void 0) yield {
1480
+ type: "usage",
1481
+ usage
1482
+ };
1483
+ const reason = mapOpenAIFinishReason(finishReason ?? "stop");
1484
+ yield {
1485
+ type: "finish",
1486
+ reason: reason.kind === "stop" && order.length === 0 ? {
1487
+ kind: "error",
1488
+ failure: {
1489
+ message: "model returned a completed response with no content",
1490
+ code: EMPTY_RESPONSE_CODE
1491
+ }
1492
+ } : reason
1493
+ };
1494
+ }
1495
+ //#endregion
1496
+ //#region src/openai-adapter.ts
1497
+ /**
1498
+ * `OpenAICompatibleAdapter`: the harness `LlmAdapter` over an OpenAI-compatible
1499
+ * `/v1/chat/completions` streaming endpoint — the common protocol spoken by
1500
+ * LM Studio, vLLM, and llama.cpp `--server`. One instance serves one backend;
1501
+ * the provider id and wire base URL are resolved per operation through a
1502
+ * `() => ResolvedOpenAIBackend` thunk, so a changed URL/model mapping reaches
1503
+ * the next request without re-registration. Text-only route: image content
1504
+ * fails loud with `UNSUPPORTED_CONTENT` (multimodal backends are out of scope).
1505
+ * @module dsh-local-ai/openai-adapter
1506
+ */
1507
+ /** Idle-timeout abort code stamped onto a stalled stream's timeout reason. */
1508
+ const STREAM_IDLE_TIMEOUT_CODE = "LLM_STREAM_IDLE_TIMEOUT";
1509
+ /**
1510
+ * The OpenAI-compatible provider adapter. One instance serves one backend
1511
+ * (LM Studio / vLLM / llama.cpp); the harness model name maps to the wire model
1512
+ * id through the backend's model mapping (identity when unmapped).
1513
+ */
1514
+ var OpenAICompatibleAdapter = class extends LlmAdapter {
1515
+ options;
1516
+ constructor(options) {
1517
+ super();
1518
+ this.options = options;
1519
+ }
1520
+ fetchImpl() {
1521
+ return this.options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
1522
+ }
1523
+ providerInfo(provider) {
1524
+ return {
1525
+ id: provider,
1526
+ name: `OpenAI-compatible (${this.options.config().name})`
1527
+ };
1528
+ }
1529
+ async listModels(provider) {
1530
+ const backend = this.options.config();
1531
+ const models = await listModels(backend.baseURL, backend.apiKey, this.fetchImpl()).catch(() => []);
1532
+ return (models.length > 0 ? models.map((model) => model.id) : backend.models.map((entry) => entry.model)).map((id) => {
1533
+ const name = backend.models.find((entry) => entry.model === id)?.name ?? id;
1534
+ return {
1535
+ provider,
1536
+ id: name,
1537
+ name,
1538
+ inputModalities: ["text"]
1539
+ };
1540
+ });
1541
+ }
1542
+ async resolveModel(provider, model, _signal) {
1543
+ const backend = this.options.config();
1544
+ const mapping = backend.models.find((entry) => entry.name === model);
1545
+ return {
1546
+ provider,
1547
+ id: model,
1548
+ name: model,
1549
+ inputModalities: ["text"],
1550
+ context: { contextWindow: mapping?.contextWindow ?? backend.defaultContextWindow },
1551
+ defaultMaxTokens: mapping?.maxTokens ?? backend.maxTokens
1552
+ };
1553
+ }
1554
+ async *stream(options) {
1555
+ try {
1556
+ var _usingCtx$1 = _usingCtx();
1557
+ const backend = this.options.config();
1558
+ const consumer = new AbortController();
1559
+ const upstream = options.signal === void 0 ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]);
1560
+ const watchdog = _usingCtx$1.u(idleWatchdog(upstream, backend.requestTimeoutMs, STREAM_IDLE_TIMEOUT_CODE));
1561
+ const iterator = this.request(options, backend, watchdog.signal)[Symbol.asyncIterator]();
1562
+ let exhausted = false;
1563
+ try {
1564
+ while (true) {
1565
+ const result = await watchdog.next(iterator);
1566
+ if (result.done) {
1567
+ exhausted = true;
1568
+ return;
1569
+ }
1570
+ yield result.value;
1571
+ }
1572
+ } catch (error) {
1573
+ if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== void 0) throw new LlmError(`OpenAI-compatible stream idle timeout after ${backend.requestTimeoutMs}ms`, "TIMEOUT", { cause: error });
1574
+ if (options.signal?.aborted) throw new LlmError("OpenAI-compatible request aborted by caller", "ABORTED", { cause: error });
1575
+ if (error instanceof LlmError) throw error;
1576
+ throw new LlmError(`OpenAI-compatible API stream from ${sanitizeEndpoint(backend.baseURL)} failed`, "TRANSPORT", { cause: error });
1577
+ } finally {
1578
+ consumer.abort("OpenAI-compatible stream consumer stopped");
1579
+ if (!exhausted && iterator.return !== void 0) try {
1580
+ await iterator.return();
1581
+ } catch (_abortedTransportTeardown) {}
1582
+ }
1583
+ } catch (_) {
1584
+ _usingCtx$1.e = _;
1585
+ } finally {
1586
+ _usingCtx$1.d();
1587
+ }
1588
+ }
1589
+ async *request(options, backend, signal) {
1590
+ const body = serializeRequest(options, backend);
1591
+ let response;
1592
+ try {
1593
+ response = await postChatCompletions(backend.baseURL, backend.apiKey, body, this.fetchImpl(), signal);
1594
+ } catch (error) {
1595
+ if (signal.aborted) throw error;
1596
+ if (error instanceof LlmError) throw error;
1597
+ throw new LlmError(`OpenAI-compatible API request to ${sanitizeEndpoint(backend.baseURL)} failed`, "TRANSPORT", { cause: error });
1598
+ }
1599
+ if (!response.body) throw new LlmError("OpenAI-compatible API returned no response body", "EMPTY_RESPONSE");
1600
+ yield* translateOpenAIStream(readSseLines(response.body));
1601
+ }
1602
+ };
1603
+ //#endregion
1044
1604
  //#region src/route.ts
1045
1605
  /**
1046
1606
  * Local-model routing decision and the streaming fallback. The pure decision
1047
1607
  * matches configured rules (task type via `purpose`, case-insensitive
1048
1608
  * keywords, or a blanket `always`) against a request, in list order — first
1049
1609
  * match wins. The streaming helper routes a matched request to the local
1050
- * Ollama adapter and, when the local route fails BEFORE producing any visible
1051
- * content, falls back to the cloud (`next()`) so a down Ollama never bricks a
1052
- * conversation. Once local content has started, it is streamed through — a
1053
- * mid-stream failure cannot be retracted.
1610
+ * provider adapter (Ollama or an OpenAI-compatible backend) and, when the
1611
+ * local route fails BEFORE producing any visible content, falls back to the
1612
+ * cloud (`next()`) so a down local server never bricks a conversation. Once
1613
+ * local content has started, it is streamed through — a mid-stream failure
1614
+ * cannot be retracted.
1054
1615
  * @module dsh-local-ai/route
1055
1616
  */
1056
1617
  /**
@@ -1080,16 +1641,31 @@ function ruleMatches(rule, options) {
1080
1641
  return false;
1081
1642
  }
1082
1643
  /**
1644
+ * Whether a provider id is one of this plugin's local providers. Explicit
1645
+ * selection of a local provider (or a prior re-route) must never re-route.
1646
+ * @param provider - the request's provider id, or `undefined` for the cloud default.
1647
+ * @param resolved - the resolved config.
1648
+ * @returns true when the request is already addressed to a local provider.
1649
+ */
1650
+ function isLocalProvider(provider, resolved) {
1651
+ if (provider === void 0) return false;
1652
+ if (provider === "ollama") return true;
1653
+ return resolved.backends.some((backend) => backend.providerId === provider);
1654
+ }
1655
+ /**
1083
1656
  * Decide whether a request should route to a local model. A request already
1084
- * addressed to the `ollama` provider (explicit selection or a prior re-route)
1085
- * never re-routes.
1657
+ * addressed to a local provider (explicit selection or a prior re-route) never
1658
+ * re-routes.
1086
1659
  * @param options - the request.
1087
1660
  * @param resolved - the resolved config.
1088
- * @returns the local model to use, or `undefined` to stay on the cloud route.
1661
+ * @returns the local provider + model to use, or `undefined` to stay on the cloud route.
1089
1662
  */
1090
1663
  function decideRoute(options, resolved) {
1091
- if (options.provider === "ollama") return void 0;
1092
- for (const rule of resolved.route) if (ruleMatches(rule, options)) return { model: rule.model };
1664
+ if (isLocalProvider(options.provider, resolved)) return void 0;
1665
+ for (const rule of resolved.route) if (ruleMatches(rule, options)) return {
1666
+ provider: rule.provider,
1667
+ model: rule.model
1668
+ };
1093
1669
  }
1094
1670
  /**
1095
1671
  * Stream a locally-routed request with automatic cloud fallback. The local
@@ -1106,7 +1682,7 @@ function decideRoute(options, resolved) {
1106
1682
  async function* routeLocal(streamLocal, options, decision, next) {
1107
1683
  const upstream = streamLocal({
1108
1684
  ...options,
1109
- provider: OLLAMA_PROVIDER,
1685
+ provider: decision.provider ?? "ollama",
1110
1686
  model: decision.model
1111
1687
  });
1112
1688
  let producedContent = false;
@@ -1235,7 +1811,7 @@ async function checkHealth(baseURL, fetchImpl, subprocess, requestTimeoutMs, gra
1235
1811
  * The `dsh-local-ai` plugin version. The release script bumps this string
1236
1812
  * alongside `package.json`; `test/version.spec.ts` trips when the two drift.
1237
1813
  */
1238
- const VERSION = "0.1.4";
1814
+ const VERSION = "0.2.0";
1239
1815
  //#endregion
1240
1816
  //#region src/index.ts
1241
1817
  const name = "local-ai";
@@ -1315,6 +1891,10 @@ function apply(ctx, config = {}) {
1315
1891
  resolveAttachments: () => ctx.get("attachments")
1316
1892
  });
1317
1893
  ctx.llm.registerAdapter([OLLAMA_PROVIDER], adapter);
1894
+ for (const backend of resolved.backends) ctx.llm.registerAdapter([backend.providerId], new OpenAICompatibleAdapter({
1895
+ config: () => backend,
1896
+ fetchImpl
1897
+ }));
1318
1898
  ctx.on("llm/stream", (options, next) => {
1319
1899
  const decision = decideRoute(options, resolved);
1320
1900
  if (decision === void 0) return next();
@@ -1332,7 +1912,7 @@ function apply(ctx, config = {}) {
1332
1912
  }]
1333
1913
  },
1334
1914
  async execute(_args, exec) {
1335
- const [models, running] = await Promise.all([listModels(resolved.baseURL, fetchImpl, exec.signal), listRunning(resolved.baseURL, fetchImpl, exec.signal).catch(() => [])]);
1915
+ const [models, running] = await Promise.all([listModels$1(resolved.baseURL, fetchImpl, exec.signal), listRunning(resolved.baseURL, fetchImpl, exec.signal).catch(() => [])]);
1336
1916
  const runningNames = new Set(running.map((model) => model.name));
1337
1917
  return {
1338
1918
  models: models.map((model) => ({
@@ -1451,7 +2031,7 @@ function apply(ctx, config = {}) {
1451
2031
  lines.push(`- process: ${health.process.present ? "alive" : "not detected"}`);
1452
2032
  let models = [];
1453
2033
  try {
1454
- models = await listModels(resolved.baseURL, fetchImpl);
2034
+ models = await listModels$1(resolved.baseURL, fetchImpl);
1455
2035
  } catch {}
1456
2036
  const totalBytes = models.reduce((sum, model) => sum + model.size, 0);
1457
2037
  lines.push(`- models: ${models.length} installed (${formatBytes(totalBytes)})`);
@@ -1464,7 +2044,7 @@ function apply(ctx, config = {}) {
1464
2044
  };
1465
2045
  }
1466
2046
  });
1467
- logger.info(`ollama adapter registered at ${resolved.baseURL} (${resolved.models.length} mapping(s), ${resolved.route.length} route rule(s))`);
2047
+ logger.info(`ollama adapter registered at ${resolved.baseURL} (${resolved.models.length} mapping(s), ${resolved.route.length} route rule(s)); ${resolved.backends.length} OpenAI-compatible backend(s): ${resolved.backends.map((backend) => backend.providerId).join(", ") || "none"}`);
1468
2048
  }
1469
2049
  //#endregion
1470
- export { Config, REDACTED, VERSION, apply, formatBytes, inject, name, redactSecrets, renderHealth, renderList, renderOperation, renderShow, resolveConfig, sanitizeEndpoint, sanitizePath, sanitizeText, truncate };
2050
+ export { Config, DEFAULT_PROVIDER, OPENAI_PROVIDER_PREFIX, OpenAICompatibleAdapter, REDACTED, VERSION, apply, formatBytes, inject, name, openaiProviderId, redactSecrets, renderHealth, renderList, renderOperation, renderShow, resolveConfig, sanitizeEndpoint, sanitizePath, sanitizeText, truncate };