okengine 0.5.1 → 0.6.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/package.json +2 -1
- package/site/content/docs/elements/ai.mdx +82 -1
- package/site/content/docs/elements/channel.mdx +6 -1
- package/site/content/docs/elements/flow.mdx +20 -17
- package/site/content/docs/plugins/email-otp.mdx +25 -19
- package/site/content/docs/plugins/magic-link.mdx +27 -21
- package/site/content/docs/reference/configuration.mdx +7 -0
- package/site/content/docs/reference/environment-variables.mdx +10 -5
- package/site/content/docs/reference/errors.mdx +14 -0
- package/site/content/docs/reference/fx.mdx +68 -16
- package/site/content/docs/reference/i18n.mdx +313 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +1 -0
- package/site/content/docs/reference/plugins.mdx +1 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/method-context.ts +12 -2
- package/src/compiler/aot.test.ts +16 -13
- package/src/compiler/effects-infer.ts +46 -0
- package/src/console/server/ai.test.ts +34 -5
- package/src/docker/compose.ts +9 -0
- package/src/docker/docker.test.ts +39 -0
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -1
- package/src/docker/recipes/ollama.ts +43 -0
- package/src/docker/stack-id.ts +2 -0
- package/src/drivers/ai-mock.ts +60 -0
- package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
- package/src/drivers/ai-ollama.integration.test.ts +197 -0
- package/src/drivers/ai-ollama.ts +327 -0
- package/src/drivers/ai-openai-compatible.ts +211 -21
- package/src/drivers/ai-providers.test.ts +179 -2
- package/src/drivers/ai-stream.test.ts +195 -0
- package/src/drivers/ai-types.ts +42 -1
- package/src/drivers/channel-smtp.ts +8 -2
- package/src/drivers/index.ts +21 -1
- package/src/drivers/ollama.ts +14 -0
- package/src/elements/ai/rate.test.ts +53 -0
- package/src/elements/ai/rate.ts +66 -0
- package/src/elements/ai/redacted-prompt.test.ts +90 -0
- package/src/elements/ai/runtime.ts +330 -100
- package/src/elements/ai/tools.test.ts +99 -0
- package/src/elements/ai.test.ts +26 -2
- package/src/elements/ai.ts +10 -1
- package/src/i18n/catalogs/ar.ts +67 -0
- package/src/i18n/catalogs/en.ts +68 -0
- package/src/i18n/failure-message.test.ts +56 -0
- package/src/i18n/failure-message.ts +93 -0
- package/src/i18n/format.ts +67 -0
- package/src/i18n/index.ts +57 -0
- package/src/i18n/locale-context.ts +48 -0
- package/src/i18n/messages.test.ts +173 -0
- package/src/i18n/messages.ts +169 -0
- package/src/i18n/types.ts +90 -0
- package/src/index.ts +26 -0
- package/src/kernel/app.ts +92 -2
- package/src/kernel/boot-bind/ai.test.ts +60 -0
- package/src/kernel/boot-bind/ai.ts +125 -2
- package/src/kernel/boot.test.ts +4 -3
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/errors.ts +56 -5
- package/src/kernel/fx.test.ts +27 -0
- package/src/kernel/fx.ts +74 -18
- package/src/kernel/pipeline.test.ts +4 -0
- package/src/kernel/pipeline.ts +1 -1
- package/src/kernel/plugin.ts +16 -0
- package/src/kernel/registry.ts +15 -0
- package/src/plugins/auth/shared.ts +5 -1
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
- package/src/plugins/auth-methods.security.test.ts +12 -10
- package/src/plugins/email-otp.ts +54 -1
- package/src/plugins/index.ts +16 -2
- package/src/plugins/magic-link.ts +63 -3
- package/src/plugins/username-policy.test.ts +302 -0
- package/src/plugins/username.ts +290 -9
- package/src/release/measure.ts +8 -1
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ollama` AI driver — thin fetch client for the native Ollama HTTP API.
|
|
3
|
+
*
|
|
4
|
+
* Any pulled model works via `model` / `OKE_AI_MODEL`. The documented local-dev
|
|
5
|
+
* default is `qwen3.5:9b` (balanced starting point — override freely; on Apple
|
|
6
|
+
* Silicon consider `qwen3.5:9b-mlx`). Fail-loud:
|
|
7
|
+
* configured but unreachable throws {@link OllamaUnavailableError} — never a
|
|
8
|
+
* silent mock fallback.
|
|
9
|
+
*
|
|
10
|
+
* Native API: `POST /api/chat` (not the OpenAI-compat shim). Default base URL
|
|
11
|
+
* `http://127.0.0.1:11434`. Supports `complete`, `stream` (NDJSON), and tools.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
AiCompleteOptions,
|
|
16
|
+
AiCompleteResult,
|
|
17
|
+
AiDriver,
|
|
18
|
+
AiModelClient,
|
|
19
|
+
AiOpenOptions,
|
|
20
|
+
AiStreamChunk,
|
|
21
|
+
AiToolCall,
|
|
22
|
+
} from "./ai-types.ts";
|
|
23
|
+
|
|
24
|
+
/** Documented local-dev default — override via `model` / `OKE_AI_MODEL`. */
|
|
25
|
+
export const OLLAMA_DEFAULT_MODEL = "qwen3.5:9b";
|
|
26
|
+
|
|
27
|
+
/** Default Ollama listen URL (host installs + compose host port). */
|
|
28
|
+
export const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434";
|
|
29
|
+
|
|
30
|
+
/** Error thrown when Ollama is unreachable / unhealthy / rejects a call. */
|
|
31
|
+
export class OllamaUnavailableError extends Error {
|
|
32
|
+
constructor(message: string) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "OllamaUnavailableError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Normalize a base URL or bare `host:port` (as in `OLLAMA_HOST`) to an origin.
|
|
40
|
+
*
|
|
41
|
+
* @param raw - URL or host:port
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeOllamaBaseUrl(raw: string): string {
|
|
44
|
+
const trimmed = raw.trim().replace(/\/$/, "");
|
|
45
|
+
if (!trimmed) return OLLAMA_DEFAULT_BASE_URL;
|
|
46
|
+
if (/^https?:\/\//i.test(trimmed)) return trimmed.replace(/\/v1\/?$/i, "");
|
|
47
|
+
return `http://${trimmed}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the Ollama base URL from options / env (never invent a cloud endpoint).
|
|
52
|
+
*
|
|
53
|
+
* @param options - Open options
|
|
54
|
+
*/
|
|
55
|
+
export function resolveOllamaBaseUrl(options: AiOpenOptions = {}): string {
|
|
56
|
+
const raw =
|
|
57
|
+
options.baseUrl?.trim() ||
|
|
58
|
+
process.env.OKE_AI_URL?.trim() ||
|
|
59
|
+
process.env.OLLAMA_HOST?.trim() ||
|
|
60
|
+
OLLAMA_DEFAULT_BASE_URL;
|
|
61
|
+
return normalizeOllamaBaseUrl(raw);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the model name — fully configurable; documented default only.
|
|
66
|
+
*
|
|
67
|
+
* @param options - Open options
|
|
68
|
+
* @param override - Per-call model override
|
|
69
|
+
*/
|
|
70
|
+
export function resolveOllamaModel(options: AiOpenOptions = {}, override?: string): string {
|
|
71
|
+
return (
|
|
72
|
+
override?.trim() ||
|
|
73
|
+
options.model?.trim() ||
|
|
74
|
+
process.env.OKE_AI_MODEL?.trim() ||
|
|
75
|
+
OLLAMA_DEFAULT_MODEL
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Open an Ollama chat client. Health-checks `/api/tags` before returning.
|
|
81
|
+
*
|
|
82
|
+
* @param options - model / baseUrl / injectable fetch
|
|
83
|
+
*/
|
|
84
|
+
export async function openOllama(options: AiOpenOptions = {}): Promise<AiModelClient> {
|
|
85
|
+
const baseUrl = resolveOllamaBaseUrl(options);
|
|
86
|
+
const model = resolveOllamaModel(options);
|
|
87
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
88
|
+
|
|
89
|
+
await healthCheck(baseUrl, fetchFn);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
driverId: "ollama",
|
|
93
|
+
model,
|
|
94
|
+
async complete(opts: AiCompleteOptions): Promise<AiCompleteResult> {
|
|
95
|
+
const resolvedModel = resolveOllamaModel(options, opts.model);
|
|
96
|
+
const body = buildChatBody(resolvedModel, opts, false);
|
|
97
|
+
|
|
98
|
+
let res: Response;
|
|
99
|
+
try {
|
|
100
|
+
res = await fetchFn(`${baseUrl}/api/chat`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { "content-type": "application/json" },
|
|
103
|
+
body: JSON.stringify(body),
|
|
104
|
+
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
105
|
+
});
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (isAbortError(err)) throw err;
|
|
108
|
+
throw new OllamaUnavailableError(
|
|
109
|
+
`ollama: unreachable at ${baseUrl} — ${err instanceof Error ? err.message : String(err)}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const raw = (await res.json().catch(() => ({}))) as OllamaChatResponse;
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
const msg = raw.error ?? `ollama HTTP ${res.status}`;
|
|
116
|
+
throw new OllamaUnavailableError(`ollama: ${msg}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const text = raw.message?.content ?? "";
|
|
120
|
+
const toolCalls = parseOllamaToolCalls(raw.message?.tool_calls);
|
|
121
|
+
return {
|
|
122
|
+
text,
|
|
123
|
+
raw,
|
|
124
|
+
model: raw.model ?? resolvedModel,
|
|
125
|
+
driverId: "ollama",
|
|
126
|
+
...(toolCalls !== undefined ? { toolCalls } : {}),
|
|
127
|
+
usage: {
|
|
128
|
+
inputTokens: raw.prompt_eval_count,
|
|
129
|
+
outputTokens: raw.eval_count,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
async *stream(opts: AiCompleteOptions): AsyncIterable<AiStreamChunk> {
|
|
134
|
+
const resolvedModel = resolveOllamaModel(options, opts.model);
|
|
135
|
+
const body = buildChatBody(resolvedModel, opts, true);
|
|
136
|
+
|
|
137
|
+
let res: Response;
|
|
138
|
+
try {
|
|
139
|
+
res = await fetchFn(`${baseUrl}/api/chat`, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: { "content-type": "application/json" },
|
|
142
|
+
body: JSON.stringify(body),
|
|
143
|
+
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
144
|
+
});
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (isAbortError(err)) throw err;
|
|
147
|
+
throw new OllamaUnavailableError(
|
|
148
|
+
`ollama: unreachable at ${baseUrl} — ${err instanceof Error ? err.message : String(err)}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (!res.ok) {
|
|
152
|
+
const raw = (await res.json().catch(() => ({}))) as OllamaChatResponse;
|
|
153
|
+
const msg = raw.error ?? `ollama HTTP ${res.status}`;
|
|
154
|
+
throw new OllamaUnavailableError(`ollama: ${msg}`);
|
|
155
|
+
}
|
|
156
|
+
if (!res.body) {
|
|
157
|
+
throw new OllamaUnavailableError("ollama: stream response has no body");
|
|
158
|
+
}
|
|
159
|
+
yield* readOllamaNdjson(res.body, opts.signal);
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Protocol-named ollama driver. */
|
|
165
|
+
export const ollamaAiDriver: AiDriver = {
|
|
166
|
+
id: "ollama",
|
|
167
|
+
open: openOllama,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
function buildChatBody(
|
|
171
|
+
resolvedModel: string,
|
|
172
|
+
opts: AiCompleteOptions,
|
|
173
|
+
stream: boolean,
|
|
174
|
+
): Record<string, unknown> {
|
|
175
|
+
const body: Record<string, unknown> = {
|
|
176
|
+
model: resolvedModel,
|
|
177
|
+
messages: opts.messages.map((m) => {
|
|
178
|
+
const msg: Record<string, unknown> = {
|
|
179
|
+
role: m.role,
|
|
180
|
+
content: m.content,
|
|
181
|
+
};
|
|
182
|
+
if (m.name !== undefined) msg.name = m.name;
|
|
183
|
+
if (m.toolCalls !== undefined && m.toolCalls.length > 0) {
|
|
184
|
+
msg.tool_calls = m.toolCalls.map((tc) => ({
|
|
185
|
+
type: "function",
|
|
186
|
+
function: {
|
|
187
|
+
name: tc.name,
|
|
188
|
+
arguments: tc.arguments,
|
|
189
|
+
},
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
return msg;
|
|
193
|
+
}),
|
|
194
|
+
stream,
|
|
195
|
+
// Thinking models (e.g. qwen3 / qwen3.5) otherwise spend the token budget in
|
|
196
|
+
// `message.thinking` and leave `content` empty — baseline complete
|
|
197
|
+
// wants the answer text.
|
|
198
|
+
think: false,
|
|
199
|
+
};
|
|
200
|
+
const modelOptions: Record<string, unknown> = {};
|
|
201
|
+
if (opts.temperature !== undefined) modelOptions.temperature = opts.temperature;
|
|
202
|
+
if (opts.maxTokens !== undefined) modelOptions.num_predict = opts.maxTokens;
|
|
203
|
+
if (Object.keys(modelOptions).length > 0) body.options = modelOptions;
|
|
204
|
+
if (opts.responseFormat !== undefined) body.format = opts.responseFormat;
|
|
205
|
+
if (opts.tools !== undefined && opts.tools.length > 0) {
|
|
206
|
+
body.tools = opts.tools.map((t) => ({
|
|
207
|
+
type: "function",
|
|
208
|
+
function: {
|
|
209
|
+
name: t.name,
|
|
210
|
+
...(t.description !== undefined ? { description: t.description } : {}),
|
|
211
|
+
...(t.parameters !== undefined ? { parameters: t.parameters } : {}),
|
|
212
|
+
},
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
return body;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function parseOllamaToolCalls(
|
|
219
|
+
raw: readonly OllamaToolCall[] | undefined,
|
|
220
|
+
): readonly AiToolCall[] | undefined {
|
|
221
|
+
if (!raw || raw.length === 0) return undefined;
|
|
222
|
+
return raw.map((tc, i) => ({
|
|
223
|
+
id: `ollama_call_${i}`,
|
|
224
|
+
name: tc.function?.name ?? "",
|
|
225
|
+
arguments: tc.function?.arguments ?? {},
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Parse Ollama NDJSON stream lines.
|
|
231
|
+
*
|
|
232
|
+
* @param body - Response body
|
|
233
|
+
* @param signal - Optional abort
|
|
234
|
+
*/
|
|
235
|
+
async function* readOllamaNdjson(
|
|
236
|
+
body: ReadableStream<Uint8Array>,
|
|
237
|
+
signal?: AbortSignal,
|
|
238
|
+
): AsyncGenerator<AiStreamChunk> {
|
|
239
|
+
const reader = body.getReader();
|
|
240
|
+
const decoder = new TextDecoder();
|
|
241
|
+
let buffer = "";
|
|
242
|
+
try {
|
|
243
|
+
while (true) {
|
|
244
|
+
if (signal?.aborted) {
|
|
245
|
+
throw abortAsError(signal.reason);
|
|
246
|
+
}
|
|
247
|
+
const { done, value } = await reader.read();
|
|
248
|
+
if (done) break;
|
|
249
|
+
buffer += decoder.decode(value, { stream: true });
|
|
250
|
+
const lines = buffer.split("\n");
|
|
251
|
+
buffer = lines.pop() ?? "";
|
|
252
|
+
for (const line of lines) {
|
|
253
|
+
const trimmed = line.trim();
|
|
254
|
+
if (!trimmed) continue;
|
|
255
|
+
try {
|
|
256
|
+
const chunk = JSON.parse(trimmed) as OllamaChatResponse;
|
|
257
|
+
const delta = chunk.message?.content;
|
|
258
|
+
if (typeof delta === "string" && delta.length > 0) {
|
|
259
|
+
yield { text: delta };
|
|
260
|
+
}
|
|
261
|
+
if (chunk.done) {
|
|
262
|
+
yield { text: "", done: true };
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
// ignore malformed lines
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
yield { text: "", done: true };
|
|
271
|
+
} finally {
|
|
272
|
+
reader.releaseLock();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Probe Ollama — fail loud before the first completion.
|
|
278
|
+
*
|
|
279
|
+
* @param baseUrl - Origin
|
|
280
|
+
* @param fetchFn - Injectable fetch
|
|
281
|
+
*/
|
|
282
|
+
async function healthCheck(baseUrl: string, fetchFn: typeof globalThis.fetch): Promise<void> {
|
|
283
|
+
let res: Response;
|
|
284
|
+
try {
|
|
285
|
+
res = await fetchFn(`${baseUrl}/api/tags`, { method: "GET" });
|
|
286
|
+
} catch (err) {
|
|
287
|
+
throw new OllamaUnavailableError(
|
|
288
|
+
`ollama: unreachable at ${baseUrl} — ${err instanceof Error ? err.message : String(err)}`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (!res.ok) {
|
|
292
|
+
const detail = await res.text().catch(() => "");
|
|
293
|
+
throw new OllamaUnavailableError(
|
|
294
|
+
`ollama: health check failed at ${baseUrl}/api/tags (${res.status})${
|
|
295
|
+
detail ? ` — ${detail.slice(0, 200)}` : ""
|
|
296
|
+
}`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isAbortError(err: unknown): boolean {
|
|
302
|
+
return err instanceof Error && err.name === "AbortError";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function abortAsError(reason?: unknown): Error {
|
|
306
|
+
if (reason instanceof Error) return reason;
|
|
307
|
+
const err = new Error(reason !== undefined ? String(reason) : "This operation was aborted");
|
|
308
|
+
err.name = "AbortError";
|
|
309
|
+
return err;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
interface OllamaToolCall {
|
|
313
|
+
readonly function?: { readonly name?: string; readonly arguments?: unknown };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
interface OllamaChatResponse {
|
|
317
|
+
readonly model?: string;
|
|
318
|
+
readonly message?: {
|
|
319
|
+
readonly role?: string;
|
|
320
|
+
readonly content?: string;
|
|
321
|
+
readonly tool_calls?: readonly OllamaToolCall[];
|
|
322
|
+
};
|
|
323
|
+
readonly done?: boolean;
|
|
324
|
+
readonly prompt_eval_count?: number;
|
|
325
|
+
readonly eval_count?: number;
|
|
326
|
+
readonly error?: string;
|
|
327
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `openai-compatible` AI driver — thin HTTP client for OpenAI-shaped APIs.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* One protocol driver for OpenAI, Groq, Together, OpenRouter, vLLM, LM Studio,
|
|
5
|
+
* Ollama `/v1`, and other chat/completions endpoints. Configure via
|
|
6
|
+
* `baseUrl` + `apiKey` + `model` (+ optional `headers`). Native Ollama stays
|
|
7
|
+
* on the separate `ollama` driver. Injectable `fetch` for tests. Never a
|
|
6
8
|
* production default — prod must declare.
|
|
7
9
|
*/
|
|
8
10
|
|
|
@@ -14,23 +16,67 @@ import type {
|
|
|
14
16
|
AiEmbedResult,
|
|
15
17
|
AiModelClient,
|
|
16
18
|
AiOpenOptions,
|
|
19
|
+
AiStreamChunk,
|
|
20
|
+
AiToolCall,
|
|
17
21
|
} from "./ai-types.ts";
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
/** Default OpenAI cloud base — apiKey is required for this origin. */
|
|
24
|
+
export const OPENAI_COMPAT_DEFAULT_BASE = "https://api.openai.com/v1";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Normalize a chat/embeddings base URL (strip trailing slash).
|
|
28
|
+
*
|
|
29
|
+
* @param raw - Base URL
|
|
30
|
+
*/
|
|
31
|
+
export function normalizeOpenaiCompatibleBaseUrl(raw: string): string {
|
|
32
|
+
return raw.trim().replace(/\/$/, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether this base is the OpenAI cloud default (key required).
|
|
37
|
+
*
|
|
38
|
+
* @param baseUrl - Normalized base
|
|
39
|
+
*/
|
|
40
|
+
export function isOpenaiCloudBase(baseUrl: string): boolean {
|
|
41
|
+
return normalizeOpenaiCompatibleBaseUrl(baseUrl) === OPENAI_COMPAT_DEFAULT_BASE;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build request headers: content-type, optional Bearer, optional extras.
|
|
46
|
+
*
|
|
47
|
+
* @param apiKey - Bearer token when present
|
|
48
|
+
* @param extra - Caller headers
|
|
49
|
+
*/
|
|
50
|
+
export function openaiCompatibleHeaders(
|
|
51
|
+
apiKey: string | undefined,
|
|
52
|
+
extra?: Readonly<Record<string, string>>,
|
|
53
|
+
): Record<string, string> {
|
|
54
|
+
const headers: Record<string, string> = {
|
|
55
|
+
"content-type": "application/json",
|
|
56
|
+
...(extra ?? {}),
|
|
57
|
+
};
|
|
58
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
59
|
+
return headers;
|
|
60
|
+
}
|
|
20
61
|
|
|
21
62
|
/**
|
|
22
63
|
* Open an OpenAI-compatible chat / embeddings client.
|
|
23
64
|
*
|
|
24
|
-
*
|
|
65
|
+
* apiKey is required for the default OpenAI cloud base. Custom `baseUrl`
|
|
66
|
+
* (LM Studio, Ollama `/v1`, Groq, …) may omit the key — Authorization is
|
|
67
|
+
* then omitted. HTTP failures always throw (never silent mock fallback).
|
|
68
|
+
*
|
|
69
|
+
* @param options - API key / model / base URL / headers / injectable fetch
|
|
25
70
|
*/
|
|
26
71
|
export async function openOpenaiCompatible(options: AiOpenOptions = {}): Promise<AiModelClient> {
|
|
72
|
+
const baseUrl = normalizeOpenaiCompatibleBaseUrl(options.baseUrl ?? OPENAI_COMPAT_DEFAULT_BASE);
|
|
27
73
|
const apiKey = options.apiKey ?? process.env.OPENAI_API_KEY;
|
|
28
|
-
if (!apiKey) {
|
|
74
|
+
if (isOpenaiCloudBase(baseUrl) && !apiKey) {
|
|
29
75
|
throw new Error("openai-compatible: apiKey is required (or OPENAI_API_KEY)");
|
|
30
76
|
}
|
|
31
77
|
const model = options.model ?? "gpt-4o-mini";
|
|
32
|
-
const baseUrl = (options.baseUrl ?? DEFAULT_BASE).replace(/\/$/, "");
|
|
33
78
|
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
79
|
+
const extraHeaders = options.headers;
|
|
34
80
|
|
|
35
81
|
return {
|
|
36
82
|
driverId: "openai-compatible",
|
|
@@ -39,52 +85,82 @@ export async function openOpenaiCompatible(options: AiOpenOptions = {}): Promise
|
|
|
39
85
|
const resolvedModel = opts.model ?? model;
|
|
40
86
|
const body: Record<string, unknown> = {
|
|
41
87
|
model: resolvedModel,
|
|
42
|
-
messages: opts.messages.map(
|
|
43
|
-
role: m.role,
|
|
44
|
-
content: m.content,
|
|
45
|
-
...(m.name !== undefined ? { name: m.name } : {}),
|
|
46
|
-
})),
|
|
88
|
+
messages: opts.messages.map(serializeMessage),
|
|
47
89
|
};
|
|
48
90
|
if (opts.temperature !== undefined) body.temperature = opts.temperature;
|
|
49
91
|
if (opts.maxTokens !== undefined) body.max_tokens = opts.maxTokens;
|
|
50
92
|
if (opts.responseFormat !== undefined) {
|
|
51
93
|
body.response_format = opts.responseFormat;
|
|
52
94
|
}
|
|
95
|
+
if (opts.tools !== undefined && opts.tools.length > 0) {
|
|
96
|
+
body.tools = opts.tools.map((t) => ({
|
|
97
|
+
type: "function",
|
|
98
|
+
function: {
|
|
99
|
+
name: t.name,
|
|
100
|
+
...(t.description !== undefined ? { description: t.description } : {}),
|
|
101
|
+
...(t.parameters !== undefined ? { parameters: t.parameters } : {}),
|
|
102
|
+
},
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
53
105
|
|
|
54
106
|
const res = await fetchFn(`${baseUrl}/chat/completions`, {
|
|
55
107
|
method: "POST",
|
|
56
|
-
headers:
|
|
57
|
-
"content-type": "application/json",
|
|
58
|
-
Authorization: `Bearer ${apiKey}`,
|
|
59
|
-
},
|
|
108
|
+
headers: openaiCompatibleHeaders(apiKey, extraHeaders),
|
|
60
109
|
body: JSON.stringify(body),
|
|
110
|
+
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
61
111
|
});
|
|
62
112
|
const raw = (await res.json().catch(() => ({}))) as OpenAiChatResponse;
|
|
63
113
|
if (!res.ok) {
|
|
64
114
|
const msg = raw.error?.message ?? `openai-compatible HTTP ${res.status}`;
|
|
65
115
|
throw new Error(`openai-compatible: ${msg}`);
|
|
66
116
|
}
|
|
67
|
-
const
|
|
117
|
+
const message = raw.choices?.[0]?.message;
|
|
118
|
+
const text = message?.content ?? "";
|
|
119
|
+
const toolCalls = parseToolCalls(message?.tool_calls);
|
|
68
120
|
return {
|
|
69
121
|
text,
|
|
70
122
|
raw,
|
|
71
123
|
model: raw.model ?? resolvedModel,
|
|
72
124
|
driverId: "openai-compatible",
|
|
125
|
+
...(toolCalls !== undefined ? { toolCalls } : {}),
|
|
73
126
|
usage: {
|
|
74
127
|
inputTokens: raw.usage?.prompt_tokens,
|
|
75
128
|
outputTokens: raw.usage?.completion_tokens,
|
|
76
129
|
},
|
|
77
130
|
};
|
|
78
131
|
},
|
|
132
|
+
async *stream(opts: AiCompleteOptions): AsyncIterable<AiStreamChunk> {
|
|
133
|
+
const resolvedModel = opts.model ?? model;
|
|
134
|
+
const body: Record<string, unknown> = {
|
|
135
|
+
model: resolvedModel,
|
|
136
|
+
messages: opts.messages.map(serializeMessage),
|
|
137
|
+
stream: true,
|
|
138
|
+
};
|
|
139
|
+
if (opts.temperature !== undefined) body.temperature = opts.temperature;
|
|
140
|
+
if (opts.maxTokens !== undefined) body.max_tokens = opts.maxTokens;
|
|
141
|
+
|
|
142
|
+
const res = await fetchFn(`${baseUrl}/chat/completions`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: openaiCompatibleHeaders(apiKey, extraHeaders),
|
|
145
|
+
body: JSON.stringify(body),
|
|
146
|
+
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
147
|
+
});
|
|
148
|
+
if (!res.ok) {
|
|
149
|
+
const raw = (await res.json().catch(() => ({}))) as OpenAiChatResponse;
|
|
150
|
+
const msg = raw.error?.message ?? `openai-compatible HTTP ${res.status}`;
|
|
151
|
+
throw new Error(`openai-compatible: ${msg}`);
|
|
152
|
+
}
|
|
153
|
+
if (!res.body) {
|
|
154
|
+
throw new Error("openai-compatible: stream response has no body");
|
|
155
|
+
}
|
|
156
|
+
yield* readOpenaiSse(res.body, opts.signal);
|
|
157
|
+
},
|
|
79
158
|
async embed(opts: AiEmbedOptions): Promise<AiEmbedResult> {
|
|
80
159
|
const resolvedModel = opts.model ?? model;
|
|
81
160
|
const input = opts.input;
|
|
82
161
|
const res = await fetchFn(`${baseUrl}/embeddings`, {
|
|
83
162
|
method: "POST",
|
|
84
|
-
headers:
|
|
85
|
-
"content-type": "application/json",
|
|
86
|
-
Authorization: `Bearer ${apiKey}`,
|
|
87
|
-
},
|
|
163
|
+
headers: openaiCompatibleHeaders(apiKey, extraHeaders),
|
|
88
164
|
body: JSON.stringify({ model: resolvedModel, input }),
|
|
89
165
|
});
|
|
90
166
|
const raw = (await res.json().catch(() => ({}))) as OpenAiEmbedResponse;
|
|
@@ -111,10 +187,124 @@ export const openaiCompatibleAiDriver: AiDriver = {
|
|
|
111
187
|
open: openOpenaiCompatible,
|
|
112
188
|
};
|
|
113
189
|
|
|
190
|
+
function serializeMessage(m: AiCompleteOptions["messages"][number]): Record<string, unknown> {
|
|
191
|
+
const out: Record<string, unknown> = {
|
|
192
|
+
role: m.role,
|
|
193
|
+
content: m.content,
|
|
194
|
+
};
|
|
195
|
+
if (m.name !== undefined) out.name = m.name;
|
|
196
|
+
if (m.toolCallId !== undefined) out.tool_call_id = m.toolCallId;
|
|
197
|
+
if (m.toolCalls !== undefined && m.toolCalls.length > 0) {
|
|
198
|
+
out.tool_calls = m.toolCalls.map((tc) => ({
|
|
199
|
+
id: tc.id,
|
|
200
|
+
type: "function",
|
|
201
|
+
function: {
|
|
202
|
+
name: tc.name,
|
|
203
|
+
arguments:
|
|
204
|
+
typeof tc.arguments === "string" ? tc.arguments : JSON.stringify(tc.arguments ?? {}),
|
|
205
|
+
},
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function parseToolCalls(
|
|
212
|
+
raw: readonly OpenAiToolCall[] | undefined,
|
|
213
|
+
): readonly AiToolCall[] | undefined {
|
|
214
|
+
if (!raw || raw.length === 0) return undefined;
|
|
215
|
+
return raw.map((tc, i) => {
|
|
216
|
+
const name = tc.function?.name ?? "";
|
|
217
|
+
const argStr = tc.function?.arguments ?? "{}";
|
|
218
|
+
let args: unknown = argStr;
|
|
219
|
+
try {
|
|
220
|
+
args = JSON.parse(argStr) as unknown;
|
|
221
|
+
} catch {
|
|
222
|
+
args = { _raw: argStr };
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
id: tc.id ?? `call_${i}`,
|
|
226
|
+
name,
|
|
227
|
+
arguments: args,
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Parse OpenAI SSE chat.completion.chunk stream.
|
|
234
|
+
*
|
|
235
|
+
* @param body - Response body
|
|
236
|
+
* @param signal - Optional abort
|
|
237
|
+
*/
|
|
238
|
+
async function* readOpenaiSse(
|
|
239
|
+
body: ReadableStream<Uint8Array>,
|
|
240
|
+
signal?: AbortSignal,
|
|
241
|
+
): AsyncGenerator<AiStreamChunk> {
|
|
242
|
+
const reader = body.getReader();
|
|
243
|
+
const decoder = new TextDecoder();
|
|
244
|
+
let buffer = "";
|
|
245
|
+
try {
|
|
246
|
+
while (true) {
|
|
247
|
+
if (signal?.aborted) {
|
|
248
|
+
throw abortAsError(signal.reason);
|
|
249
|
+
}
|
|
250
|
+
const { done, value } = await reader.read();
|
|
251
|
+
if (done) break;
|
|
252
|
+
buffer += decoder.decode(value, { stream: true });
|
|
253
|
+
const lines = buffer.split("\n");
|
|
254
|
+
buffer = lines.pop() ?? "";
|
|
255
|
+
for (const line of lines) {
|
|
256
|
+
const trimmed = line.trim();
|
|
257
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
258
|
+
const data = trimmed.slice(5).trim();
|
|
259
|
+
if (data === "[DONE]") {
|
|
260
|
+
yield { text: "", done: true };
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const chunk = JSON.parse(data) as {
|
|
265
|
+
choices?: readonly {
|
|
266
|
+
delta?: { content?: string | null };
|
|
267
|
+
finish_reason?: string | null;
|
|
268
|
+
}[];
|
|
269
|
+
};
|
|
270
|
+
const delta = chunk.choices?.[0]?.delta?.content;
|
|
271
|
+
if (typeof delta === "string" && delta.length > 0) {
|
|
272
|
+
yield { text: delta };
|
|
273
|
+
}
|
|
274
|
+
if (chunk.choices?.[0]?.finish_reason) {
|
|
275
|
+
yield { text: "", done: true };
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
// ignore malformed SSE lines
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
yield { text: "", done: true };
|
|
284
|
+
} finally {
|
|
285
|
+
reader.releaseLock();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function abortAsError(reason?: unknown): Error {
|
|
290
|
+
if (reason instanceof Error) return reason;
|
|
291
|
+
const err = new Error(reason !== undefined ? String(reason) : "This operation was aborted");
|
|
292
|
+
err.name = "AbortError";
|
|
293
|
+
return err;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
interface OpenAiToolCall {
|
|
297
|
+
readonly id?: string;
|
|
298
|
+
readonly function?: { readonly name?: string; readonly arguments?: string };
|
|
299
|
+
}
|
|
300
|
+
|
|
114
301
|
interface OpenAiChatResponse {
|
|
115
302
|
readonly model?: string;
|
|
116
303
|
readonly choices?: readonly {
|
|
117
|
-
readonly message?: {
|
|
304
|
+
readonly message?: {
|
|
305
|
+
readonly content?: string | null;
|
|
306
|
+
readonly tool_calls?: readonly OpenAiToolCall[];
|
|
307
|
+
};
|
|
118
308
|
}[];
|
|
119
309
|
readonly usage?: {
|
|
120
310
|
readonly prompt_tokens?: number;
|