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.
- package/CHANGELOG.md +12 -0
- package/README.es.md +10 -0
- package/README.hi.md +10 -0
- package/README.md +12 -0
- package/README.pt.md +10 -0
- package/README.zh.md +10 -0
- package/cordis.patch.yml +14 -2
- package/lib/index.js +644 -64
- package/lib/types/config.d.ts +40 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +92 -24
- package/lib/types/config.js.map +1 -1
- package/lib/types/index.d.ts +12 -9
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +22 -9
- package/lib/types/index.js.map +1 -1
- package/lib/types/openai-adapter.d.ts +37 -0
- package/lib/types/openai-adapter.d.ts.map +1 -0
- package/lib/types/openai-adapter.js +181 -0
- package/lib/types/openai-adapter.js.map +1 -0
- package/lib/types/openai-serialize.d.ts +61 -0
- package/lib/types/openai-serialize.d.ts.map +1 -0
- package/lib/types/openai-serialize.js +115 -0
- package/lib/types/openai-serialize.js.map +1 -0
- package/lib/types/openai-translate.d.ts +53 -0
- package/lib/types/openai-translate.d.ts.map +1 -0
- package/lib/types/openai-translate.js +168 -0
- package/lib/types/openai-translate.js.map +1 -0
- package/lib/types/openai.d.ts +30 -0
- package/lib/types/openai.d.ts.map +1 -0
- package/lib/types/openai.js +109 -0
- package/lib/types/openai.js.map +1 -0
- package/lib/types/route.d.ts +19 -8
- package/lib/types/route.d.ts.map +1 -1
- package/lib/types/route.js +26 -10
- package/lib/types/route.js.map +1 -1
- package/lib/types/version.d.ts +1 -1
- package/lib/types/version.js +1 -1
- package/package.json +1 -1
- package/src/config.ts +127 -19
- package/src/index.ts +24 -10
- package/src/openai-adapter.ts +145 -0
- package/src/openai-serialize.ts +147 -0
- package/src/openai-translate.ts +208 -0
- package/src/openai.ts +121 -0
- package/src/route.ts +28 -11
- 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
|
|
90
|
-
const
|
|
91
|
-
if (typeof
|
|
92
|
-
const name =
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
1063
|
+
_usingCtx$2.e = _;
|
|
1005
1064
|
} finally {
|
|
1006
|
-
_usingCtx$
|
|
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
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
1053
|
-
* mid-stream failure
|
|
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
|
|
1085
|
-
*
|
|
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
|
|
1092
|
-
for (const rule of resolved.route) if (ruleMatches(rule, options)) return {
|
|
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:
|
|
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.
|
|
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 };
|