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.
Files changed (76) hide show
  1. package/package.json +2 -1
  2. package/site/content/docs/elements/ai.mdx +82 -1
  3. package/site/content/docs/elements/channel.mdx +6 -1
  4. package/site/content/docs/elements/flow.mdx +20 -17
  5. package/site/content/docs/plugins/email-otp.mdx +25 -19
  6. package/site/content/docs/plugins/magic-link.mdx +27 -21
  7. package/site/content/docs/reference/configuration.mdx +7 -0
  8. package/site/content/docs/reference/environment-variables.mdx +10 -5
  9. package/site/content/docs/reference/errors.mdx +14 -0
  10. package/site/content/docs/reference/fx.mdx +68 -16
  11. package/site/content/docs/reference/i18n.mdx +313 -0
  12. package/site/content/docs/reference/index.mdx +6 -1
  13. package/site/content/docs/reference/meta.json +1 -0
  14. package/site/content/docs/reference/plugins.mdx +1 -0
  15. package/src/auth/auth.test.ts +3 -0
  16. package/src/auth/bindings.ts +1 -1
  17. package/src/auth/method-context.ts +12 -2
  18. package/src/compiler/aot.test.ts +16 -13
  19. package/src/compiler/effects-infer.ts +46 -0
  20. package/src/console/server/ai.test.ts +34 -5
  21. package/src/docker/compose.ts +9 -0
  22. package/src/docker/docker.test.ts +39 -0
  23. package/src/docker/index.ts +11 -1
  24. package/src/docker/recipes/index.ts +3 -1
  25. package/src/docker/recipes/ollama.ts +43 -0
  26. package/src/docker/stack-id.ts +2 -0
  27. package/src/drivers/ai-mock.ts +60 -0
  28. package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
  29. package/src/drivers/ai-ollama.integration.test.ts +197 -0
  30. package/src/drivers/ai-ollama.ts +327 -0
  31. package/src/drivers/ai-openai-compatible.ts +211 -21
  32. package/src/drivers/ai-providers.test.ts +179 -2
  33. package/src/drivers/ai-stream.test.ts +195 -0
  34. package/src/drivers/ai-types.ts +42 -1
  35. package/src/drivers/channel-smtp.ts +8 -2
  36. package/src/drivers/index.ts +21 -1
  37. package/src/drivers/ollama.ts +14 -0
  38. package/src/elements/ai/rate.test.ts +53 -0
  39. package/src/elements/ai/rate.ts +66 -0
  40. package/src/elements/ai/redacted-prompt.test.ts +90 -0
  41. package/src/elements/ai/runtime.ts +330 -100
  42. package/src/elements/ai/tools.test.ts +99 -0
  43. package/src/elements/ai.test.ts +26 -2
  44. package/src/elements/ai.ts +10 -1
  45. package/src/i18n/catalogs/ar.ts +67 -0
  46. package/src/i18n/catalogs/en.ts +68 -0
  47. package/src/i18n/failure-message.test.ts +56 -0
  48. package/src/i18n/failure-message.ts +93 -0
  49. package/src/i18n/format.ts +67 -0
  50. package/src/i18n/index.ts +57 -0
  51. package/src/i18n/locale-context.ts +48 -0
  52. package/src/i18n/messages.test.ts +173 -0
  53. package/src/i18n/messages.ts +169 -0
  54. package/src/i18n/types.ts +90 -0
  55. package/src/index.ts +26 -0
  56. package/src/kernel/app.ts +92 -2
  57. package/src/kernel/boot-bind/ai.test.ts +60 -0
  58. package/src/kernel/boot-bind/ai.ts +125 -2
  59. package/src/kernel/boot.test.ts +4 -3
  60. package/src/kernel/boot.ts +1 -1
  61. package/src/kernel/errors.ts +56 -5
  62. package/src/kernel/fx.test.ts +27 -0
  63. package/src/kernel/fx.ts +74 -18
  64. package/src/kernel/pipeline.test.ts +4 -0
  65. package/src/kernel/pipeline.ts +1 -1
  66. package/src/kernel/plugin.ts +16 -0
  67. package/src/kernel/registry.ts +15 -0
  68. package/src/plugins/auth/shared.ts +5 -1
  69. package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
  70. package/src/plugins/auth-methods.security.test.ts +12 -10
  71. package/src/plugins/email-otp.ts +54 -1
  72. package/src/plugins/index.ts +16 -2
  73. package/src/plugins/magic-link.ts +63 -3
  74. package/src/plugins/username-policy.test.ts +302 -0
  75. package/src/plugins/username.ts +290 -9
  76. 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
- * Covers OpenAI, vLLM, Groq, Together, LM Studio, and most self-hosted
5
- * servers (unified-theory §16). Injectable `fetch` for tests. Never a
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
- const DEFAULT_BASE = "https://api.openai.com/v1";
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
- * @param options - API key / model / base URL / injectable fetch
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((m) => ({
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 text = raw.choices?.[0]?.message?.content ?? "";
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?: { readonly content?: string | null };
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;