okengine 0.5.0 → 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 (94) 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/get-started/why.mdx +10 -10
  6. package/site/content/docs/plugins/email-otp.mdx +25 -19
  7. package/site/content/docs/plugins/headers.mdx +10 -10
  8. package/site/content/docs/plugins/magic-link.mdx +27 -21
  9. package/site/content/docs/plugins/passkey.mdx +36 -24
  10. package/site/content/docs/plugins/two-factor.mdx +2 -1
  11. package/site/content/docs/reference/configuration.mdx +7 -0
  12. package/site/content/docs/reference/environment-variables.mdx +10 -5
  13. package/site/content/docs/reference/errors.mdx +14 -0
  14. package/site/content/docs/reference/fx.mdx +68 -16
  15. package/site/content/docs/reference/i18n.mdx +313 -0
  16. package/site/content/docs/reference/index.mdx +6 -1
  17. package/site/content/docs/reference/meta.json +1 -0
  18. package/site/content/docs/reference/plugins.mdx +1 -0
  19. package/src/auth/auth.test.ts +3 -0
  20. package/src/auth/bindings.ts +1 -1
  21. package/src/auth/constant-time.ts +22 -0
  22. package/src/auth/index.ts +2 -0
  23. package/src/auth/method-context.ts +12 -2
  24. package/src/cli/competitor-mention-removal.test.ts +3 -3
  25. package/src/compiler/aot.test.ts +16 -13
  26. package/src/compiler/effects-infer.ts +46 -0
  27. package/src/console/server/ai.test.ts +34 -5
  28. package/src/docker/compose.ts +9 -0
  29. package/src/docker/docker.test.ts +39 -0
  30. package/src/docker/index.ts +11 -1
  31. package/src/docker/recipes/index.ts +3 -1
  32. package/src/docker/recipes/ollama.ts +43 -0
  33. package/src/docker/stack-id.ts +2 -0
  34. package/src/drivers/ai-mock.ts +60 -0
  35. package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
  36. package/src/drivers/ai-ollama.integration.test.ts +197 -0
  37. package/src/drivers/ai-ollama.ts +327 -0
  38. package/src/drivers/ai-openai-compatible.ts +211 -21
  39. package/src/drivers/ai-providers.test.ts +179 -2
  40. package/src/drivers/ai-stream.test.ts +195 -0
  41. package/src/drivers/ai-types.ts +42 -1
  42. package/src/drivers/channel-smtp.ts +8 -2
  43. package/src/drivers/index.ts +21 -1
  44. package/src/drivers/ollama.ts +14 -0
  45. package/src/elements/ai/rate.test.ts +53 -0
  46. package/src/elements/ai/rate.ts +66 -0
  47. package/src/elements/ai/redacted-prompt.test.ts +90 -0
  48. package/src/elements/ai/runtime.ts +330 -100
  49. package/src/elements/ai/tools.test.ts +99 -0
  50. package/src/elements/ai.test.ts +26 -2
  51. package/src/elements/ai.ts +10 -1
  52. package/src/i18n/catalogs/ar.ts +67 -0
  53. package/src/i18n/catalogs/en.ts +68 -0
  54. package/src/i18n/failure-message.test.ts +56 -0
  55. package/src/i18n/failure-message.ts +93 -0
  56. package/src/i18n/format.ts +67 -0
  57. package/src/i18n/index.ts +57 -0
  58. package/src/i18n/locale-context.ts +48 -0
  59. package/src/i18n/messages.test.ts +173 -0
  60. package/src/i18n/messages.ts +169 -0
  61. package/src/i18n/types.ts +90 -0
  62. package/src/index.ts +26 -0
  63. package/src/kernel/app.ts +92 -2
  64. package/src/kernel/boot-bind/ai.test.ts +60 -0
  65. package/src/kernel/boot-bind/ai.ts +125 -2
  66. package/src/kernel/boot.test.ts +4 -3
  67. package/src/kernel/boot.ts +1 -1
  68. package/src/kernel/errors.ts +56 -5
  69. package/src/kernel/fx.test.ts +27 -0
  70. package/src/kernel/fx.ts +74 -18
  71. package/src/kernel/pipeline.test.ts +4 -0
  72. package/src/kernel/pipeline.ts +1 -1
  73. package/src/kernel/plugin.ts +16 -0
  74. package/src/kernel/registry.ts +15 -0
  75. package/src/plugins/auth/shared.ts +5 -1
  76. package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
  77. package/src/plugins/auth-methods.security.test.ts +764 -0
  78. package/src/plugins/compression.ts +1 -1
  79. package/src/plugins/config-source.test.ts +11 -11
  80. package/src/plugins/config-source.ts +2 -2
  81. package/src/plugins/cors.ts +1 -1
  82. package/src/plugins/email-otp.ts +54 -1
  83. package/src/plugins/{security-headers.test.ts → headers.test.ts} +18 -18
  84. package/src/plugins/headers.ts +240 -41
  85. package/src/plugins/index.ts +27 -5
  86. package/src/plugins/magic-link.ts +63 -3
  87. package/src/plugins/passkey-webauthn.ts +217 -0
  88. package/src/plugins/passkey.ts +99 -33
  89. package/src/plugins/response-headers.ts +54 -0
  90. package/src/plugins/two-factor.ts +6 -2
  91. package/src/plugins/username-policy.test.ts +302 -0
  92. package/src/plugins/username.ts +290 -9
  93. package/src/release/measure.ts +8 -1
  94. package/src/plugins/security-headers.ts +0 -255
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Live Ollama round-trip — real prompt, real response, documented default model.
3
+ *
4
+ * Opt-in (never auto-pull a multi-GB model in ordinary `bun test`):
5
+ * 1. `OKE_TEST_OLLAMA_URL` — point at any reachable Ollama
6
+ * 2. Ollama already listening on `http://127.0.0.1:11434`
7
+ * 3. `OKE_TEST_OLLAMA_DOCKER=1` — start `ollama/ollama` via the recipe and pull
8
+ * `qwen3.5:9b` (slow first run)
9
+ *
10
+ * Without one of those, the suite skips with a visible reason (never an empty pass).
11
+ */
12
+
13
+ import { afterAll, describe, expect, test } from "bun:test";
14
+ import { mkdtemp, rm } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { deriveInfrastructure, writeDerivedFiles } from "../docker/index.ts";
18
+ import { ai } from "../elements/ai.ts";
19
+ import { bindAi } from "../kernel/boot-bind/ai.ts";
20
+ import { OLLAMA_DEFAULT_MODEL, openOllama } from "./ai-ollama.ts";
21
+
22
+ const OLLAMA_IMAGE = "ollama/ollama:latest";
23
+ const DEFAULT_LOCAL = "http://127.0.0.1:11434";
24
+ const ENV_URL = process.env.OKE_TEST_OLLAMA_URL?.trim();
25
+ const WANT_DOCKER = process.env.OKE_TEST_OLLAMA_DOCKER === "1";
26
+
27
+ function dockerAvailable(): boolean {
28
+ try {
29
+ return Bun.spawnSync(["docker", "info"], { stdout: "pipe", stderr: "pipe" }).exitCode === 0;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ async function probeOllama(url: string): Promise<boolean> {
36
+ try {
37
+ const res = await fetch(`${url}/api/tags`, { signal: AbortSignal.timeout(1_500) });
38
+ return res.ok;
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ const DOCKER = dockerAvailable();
45
+ const localUp = ENV_URL ? false : await probeOllama(DEFAULT_LOCAL);
46
+ const canLive = Boolean(ENV_URL) || localUp || (WANT_DOCKER && DOCKER);
47
+
48
+ if (!canLive) {
49
+ const reasons: string[] = [];
50
+ if (!ENV_URL) reasons.push("OKE_TEST_OLLAMA_URL not set");
51
+ if (!localUp) reasons.push("no Ollama on :11434");
52
+ if (!WANT_DOCKER) reasons.push("OKE_TEST_OLLAMA_DOCKER≠1");
53
+ else if (!DOCKER) reasons.push("docker daemon not available");
54
+ console.log(`skip: live ollama e2e (${reasons.join("; ")})`);
55
+ }
56
+ const live = canLive ? test : test.skip;
57
+
58
+ let cleanup: (() => Promise<void>) | undefined;
59
+
60
+ afterAll(async () => {
61
+ if (cleanup) await cleanup();
62
+ });
63
+
64
+ async function waitForOllama(url: string, timeoutMs: number, requireModel: boolean): Promise<void> {
65
+ const deadline = Date.now() + timeoutMs;
66
+ while (Date.now() < deadline) {
67
+ try {
68
+ const res = await fetch(`${url}/api/tags`);
69
+ if (res.ok) {
70
+ if (!requireModel) return;
71
+ const body = (await res.json()) as { models?: readonly { name?: string }[] };
72
+ const names = (body.models ?? []).map((m) => m.name ?? "");
73
+ if (
74
+ names.some((n) => n === OLLAMA_DEFAULT_MODEL || n.startsWith(`${OLLAMA_DEFAULT_MODEL}`))
75
+ ) {
76
+ return;
77
+ }
78
+ }
79
+ } catch {
80
+ // still starting
81
+ }
82
+ await Bun.sleep(1_000);
83
+ }
84
+ throw new Error(
85
+ `ollama not ready at ${url} within ${timeoutMs}ms${
86
+ requireModel ? ` (need model ${OLLAMA_DEFAULT_MODEL})` : ""
87
+ }`,
88
+ );
89
+ }
90
+
91
+ async function resolveLiveUrl(): Promise<{ url: string; model: string }> {
92
+ if (ENV_URL) {
93
+ await waitForOllama(ENV_URL, 30_000, false);
94
+ return { url: ENV_URL, model: process.env.OKE_AI_MODEL?.trim() || OLLAMA_DEFAULT_MODEL };
95
+ }
96
+
97
+ if (localUp) {
98
+ return { url: DEFAULT_LOCAL, model: process.env.OKE_AI_MODEL?.trim() || OLLAMA_DEFAULT_MODEL };
99
+ }
100
+
101
+ const dir = await mkdtemp(join(tmpdir(), "oke-ollama-"));
102
+ const dockerDir = join(dir, "docker");
103
+ const project = `oke-ollama-${Date.now()}`;
104
+ const instanceId = crypto.randomUUID().replace(/-/g, "").slice(0, 6);
105
+ const derived = deriveInfrastructure({
106
+ images: { ai: OLLAMA_IMAGE },
107
+ app: "ollama-e2e",
108
+ host: "127.0.0.1",
109
+ includeApp: false,
110
+ composeDir: "docker",
111
+ instanceId,
112
+ controls: { OKE_AI_MODEL: OLLAMA_DEFAULT_MODEL },
113
+ });
114
+ await writeDerivedFiles(derived, dockerDir, { writeStackEnv: true });
115
+
116
+ const composeFiles = ["compose.yml", "compose.ai.yml"];
117
+ const up = Bun.spawn(
118
+ ["docker", "compose", "-p", project, ...composeFiles.flatMap((f) => ["-f", f]), "up", "-d"],
119
+ {
120
+ cwd: dockerDir,
121
+ stdout: "pipe",
122
+ stderr: "pipe",
123
+ env: { ...process.env, ...derived.stackEnv, OKE_AI_MODEL: OLLAMA_DEFAULT_MODEL },
124
+ },
125
+ );
126
+ const [upErr, upCode] = await Promise.all([new Response(up.stderr).text(), up.exited]);
127
+ if (upCode !== 0) {
128
+ await rm(dir, { recursive: true, force: true });
129
+ throw new Error(`docker compose up failed: ${upErr}`);
130
+ }
131
+
132
+ const url = derived.stackEnv.OKE_AI_URL!;
133
+ cleanup = async () => {
134
+ const down = Bun.spawn(
135
+ ["docker", "compose", "-p", project, ...composeFiles.flatMap((f) => ["-f", f]), "down", "-v"],
136
+ { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
137
+ );
138
+ await down.exited;
139
+ await rm(dir, { recursive: true, force: true });
140
+ };
141
+
142
+ // Model pull can take a long time on first run.
143
+ await waitForOllama(url, 20 * 60_000, true);
144
+ return { url, model: OLLAMA_DEFAULT_MODEL };
145
+ }
146
+
147
+ describe("ollama live — real completion", () => {
148
+ live(
149
+ "boots against a real Ollama, completes with a real model, returns a real response",
150
+ async () => {
151
+ const { url, model } = await resolveLiveUrl();
152
+ const prevUrl = process.env.OKE_AI_URL;
153
+ const prevModel = process.env.OKE_AI_MODEL;
154
+ process.env.OKE_AI_URL = url;
155
+ process.env.OKE_AI_MODEL = model;
156
+
157
+ try {
158
+ const smart = ai.model("smart", { provider: "ollama", model });
159
+ const ping = smart.prompt("ollama-ping", { version: 1 });
160
+
161
+ const runtime = bindAi(
162
+ {
163
+ config: { drivers: { ai: { local: "ollama", test: "ollama" } } },
164
+ ai: { models: [smart], prompts: [ping] },
165
+ },
166
+ undefined,
167
+ () => Date.now(),
168
+ "local",
169
+ false,
170
+ );
171
+ expect(runtime.prompts.has("ollama-ping")).toBe(true);
172
+
173
+ const client = await openOllama({ baseUrl: url, model });
174
+ const result = await client.complete({
175
+ messages: [
176
+ {
177
+ role: "user",
178
+ content: "Reply with exactly the single word: pong",
179
+ },
180
+ ],
181
+ temperature: 0,
182
+ maxTokens: 32,
183
+ });
184
+
185
+ expect(result.driverId).toBe("ollama");
186
+ expect(result.text.trim().length).toBeGreaterThan(0);
187
+ expect(result.text.toLowerCase()).toContain("pong");
188
+ } finally {
189
+ if (prevUrl === undefined) delete process.env.OKE_AI_URL;
190
+ else process.env.OKE_AI_URL = prevUrl;
191
+ if (prevModel === undefined) delete process.env.OKE_AI_MODEL;
192
+ else process.env.OKE_AI_MODEL = prevModel;
193
+ }
194
+ },
195
+ 25 * 60_000,
196
+ );
197
+ });
@@ -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
+ }