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
@@ -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;
@@ -4,8 +4,16 @@
4
4
 
5
5
  import { describe, expect, test } from "bun:test";
6
6
  import { anthropicAiDriver, openAnthropic } from "./ai-anthropic.ts";
7
- import { openaiCompatibleAiDriver, openOpenaiCompatible } from "./ai-openai-compatible.ts";
8
7
  import { mockAiDriver } from "./ai-mock.ts";
8
+ import {
9
+ normalizeOllamaBaseUrl,
10
+ ollamaAiDriver,
11
+ OLLAMA_DEFAULT_MODEL,
12
+ openOllama,
13
+ OllamaUnavailableError,
14
+ resolveOllamaModel,
15
+ } from "./ai-ollama.ts";
16
+ import { openaiCompatibleAiDriver, openOpenaiCompatible } from "./ai-openai-compatible.ts";
9
17
 
10
18
  describe("anthropic driver", () => {
11
19
  test("id is anthropic; mock remains the only default elsewhere", () => {
@@ -144,7 +152,7 @@ describe("openai-compatible driver", () => {
144
152
  expect(paths.some((p) => p.endsWith("/embeddings"))).toBe(true);
145
153
  });
146
154
 
147
- test("requires apiKey", async () => {
155
+ test("requires apiKey for default OpenAI cloud base", async () => {
148
156
  const prev = process.env.OPENAI_API_KEY;
149
157
  delete process.env.OPENAI_API_KEY;
150
158
  try {
@@ -153,4 +161,173 @@ describe("openai-compatible driver", () => {
153
161
  if (prev !== undefined) process.env.OPENAI_API_KEY = prev;
154
162
  }
155
163
  });
164
+
165
+ test("custom baseUrl may omit apiKey (local / self-hosted)", async () => {
166
+ const prev = process.env.OPENAI_API_KEY;
167
+ delete process.env.OPENAI_API_KEY;
168
+ const headersSeen: Array<Record<string, string> | undefined> = [];
169
+ try {
170
+ const fetchFn: typeof fetch = Object.assign(
171
+ async (_input: string | URL | Request, init?: RequestInit) => {
172
+ headersSeen.push(init?.headers as Record<string, string> | undefined);
173
+ return new Response(
174
+ JSON.stringify({
175
+ model: "local",
176
+ choices: [{ message: { content: "ok" } }],
177
+ }),
178
+ { status: 200 },
179
+ );
180
+ },
181
+ { preconnect: () => {} },
182
+ ) as typeof fetch;
183
+ const client = await openOpenaiCompatible({
184
+ baseUrl: "http://127.0.0.1:1234/v1",
185
+ model: "local",
186
+ fetch: fetchFn,
187
+ });
188
+ const result = await client.complete({
189
+ messages: [{ role: "user", content: "hi" }],
190
+ });
191
+ expect(result.text).toBe("ok");
192
+ expect(headersSeen[0]?.Authorization).toBeUndefined();
193
+ } finally {
194
+ if (prev !== undefined) process.env.OPENAI_API_KEY = prev;
195
+ }
196
+ });
197
+
198
+ test("same driver serves Groq-shaped baseUrl + key + OpenRouter headers", async () => {
199
+ const calls: Array<{ url: string; headers: Record<string, string> }> = [];
200
+ const fetchFn: typeof fetch = Object.assign(
201
+ async (input: string | URL | Request, init?: RequestInit) => {
202
+ calls.push({
203
+ url: String(input),
204
+ headers: init?.headers as Record<string, string>,
205
+ });
206
+ return new Response(
207
+ JSON.stringify({
208
+ model: "llama-3.1-8b-instant",
209
+ choices: [{ message: { content: "groq-ok" } }],
210
+ }),
211
+ { status: 200 },
212
+ );
213
+ },
214
+ { preconnect: () => {} },
215
+ ) as typeof fetch;
216
+
217
+ const groq = await openOpenaiCompatible({
218
+ apiKey: "gsk-test",
219
+ baseUrl: "https://api.groq.com/openai/v1",
220
+ model: "llama-3.1-8b-instant",
221
+ fetch: fetchFn,
222
+ });
223
+ expect((await groq.complete({ messages: [{ role: "user", content: "x" }] })).text).toBe(
224
+ "groq-ok",
225
+ );
226
+ expect(calls[0]!.url).toBe("https://api.groq.com/openai/v1/chat/completions");
227
+ expect(calls[0]!.headers.Authorization).toBe("Bearer gsk-test");
228
+
229
+ calls.length = 0;
230
+ const openrouter = await openOpenaiCompatible({
231
+ apiKey: "or-test",
232
+ baseUrl: "https://openrouter.ai/api/v1",
233
+ model: "meta-llama/llama-3.1-8b-instruct",
234
+ headers: {
235
+ "HTTP-Referer": "https://example.com",
236
+ "X-Title": "oke-test",
237
+ },
238
+ fetch: fetchFn,
239
+ });
240
+ expect((await openrouter.complete({ messages: [{ role: "user", content: "x" }] })).text).toBe(
241
+ "groq-ok",
242
+ );
243
+ expect(calls[0]!.headers["HTTP-Referer"]).toBe("https://example.com");
244
+ expect(calls[0]!.headers["X-Title"]).toBe("oke-test");
245
+ expect(calls[0]!.headers.Authorization).toBe("Bearer or-test");
246
+ });
247
+
248
+ test("HTTP error fails loud (no silent mock fallback)", async () => {
249
+ const fetchFn = Object.assign(
250
+ async () =>
251
+ new Response(JSON.stringify({ error: { message: "quota exceeded" } }), {
252
+ status: 429,
253
+ }),
254
+ { preconnect: () => {} },
255
+ ) as typeof fetch;
256
+ const client = await openOpenaiCompatible({
257
+ apiKey: "sk-test",
258
+ baseUrl: "https://api.together.xyz/v1",
259
+ fetch: fetchFn,
260
+ });
261
+ await expect(client.complete({ messages: [{ role: "user", content: "x" }] })).rejects.toThrow(
262
+ "quota exceeded",
263
+ );
264
+ });
265
+ });
266
+
267
+ describe("ollama driver", () => {
268
+ test("id is ollama; documented default model is qwen3.5:9b (overridable)", () => {
269
+ expect(ollamaAiDriver.id).toBe("ollama");
270
+ expect(OLLAMA_DEFAULT_MODEL).toBe("qwen3.5:9b");
271
+ expect(resolveOllamaModel({ model: "llama3.2:1b" })).toBe("llama3.2:1b");
272
+ expect(normalizeOllamaBaseUrl("localhost:11434")).toBe("http://localhost:11434");
273
+ expect(normalizeOllamaBaseUrl("http://127.0.0.1:11434/v1")).toBe("http://127.0.0.1:11434");
274
+ });
275
+
276
+ test("complete via injectable fetch — native /api/chat, any model name", async () => {
277
+ const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
278
+ const fetchFn: typeof fetch = Object.assign(
279
+ async (input: string | URL | Request, init?: RequestInit) => {
280
+ const url = String(input);
281
+ if (url.endsWith("/api/tags")) {
282
+ return new Response(JSON.stringify({ models: [] }), { status: 200 });
283
+ }
284
+ const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
285
+ calls.push({ url, body });
286
+ return new Response(
287
+ JSON.stringify({
288
+ model: "llama3.2:1b",
289
+ message: { role: "assistant", content: "pong" },
290
+ prompt_eval_count: 4,
291
+ eval_count: 1,
292
+ }),
293
+ { status: 200 },
294
+ );
295
+ },
296
+ { preconnect: () => {} },
297
+ ) as typeof fetch;
298
+
299
+ const client = await openOllama({
300
+ model: "llama3.2:1b",
301
+ baseUrl: "http://ollama.test:11434",
302
+ fetch: fetchFn,
303
+ });
304
+ const result = await client.complete({
305
+ messages: [{ role: "user", content: "ping" }],
306
+ temperature: 0,
307
+ maxTokens: 16,
308
+ });
309
+
310
+ expect(result.driverId).toBe("ollama");
311
+ expect(result.text).toBe("pong");
312
+ expect(result.usage?.inputTokens).toBe(4);
313
+ expect(calls).toHaveLength(1);
314
+ expect(calls[0]!.url).toBe("http://ollama.test:11434/api/chat");
315
+ expect(calls[0]!.body.model).toBe("llama3.2:1b");
316
+ expect(calls[0]!.body.stream).toBe(false);
317
+ expect(calls[0]!.body.think).toBe(false);
318
+ expect(calls[0]!.body.options).toEqual({ temperature: 0, num_predict: 16 });
319
+ });
320
+
321
+ test("unreachable server throws OllamaUnavailableError (no silent fallback)", async () => {
322
+ const fetchFn: typeof fetch = Object.assign(
323
+ async () => {
324
+ throw new TypeError("connection refused");
325
+ },
326
+ { preconnect: () => {} },
327
+ ) as typeof fetch;
328
+
329
+ await expect(
330
+ openOllama({ baseUrl: "http://127.0.0.1:9", fetch: fetchFn }),
331
+ ).rejects.toBeInstanceOf(OllamaUnavailableError);
332
+ });
156
333
  });
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Real streaming + ambient AbortSignal cancellation (no second mechanism).
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { withAbortSignal } from "../kernel/abort-scope.ts";
7
+ import { createFx, createFxContext } from "../kernel/fx.ts";
8
+ import { ai, createAiRuntime } from "../elements/ai.ts";
9
+ import { openOpenaiCompatible } from "./ai-openai-compatible.ts";
10
+ import { openOllama } from "./ai-ollama.ts";
11
+ import { mockAiDriver } from "./ai-mock.ts";
12
+
13
+ describe("openai-compatible SSE stream", () => {
14
+ test("parses data: chunks and honours abort", async () => {
15
+ const sse =
16
+ 'data: {"choices":[{"delta":{"content":"Hel"}}]}\n\n' +
17
+ 'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n' +
18
+ "data: [DONE]\n\n";
19
+ let aborted = false;
20
+ const fetchFn: typeof fetch = Object.assign(
21
+ async (_input: string | URL | Request, init?: RequestInit) => {
22
+ const signal = init?.signal;
23
+ if (signal) {
24
+ signal.addEventListener("abort", () => {
25
+ aborted = true;
26
+ });
27
+ }
28
+ return new Response(sse, {
29
+ status: 200,
30
+ headers: { "content-type": "text/event-stream" },
31
+ });
32
+ },
33
+ { preconnect: () => {} },
34
+ ) as typeof fetch;
35
+
36
+ const client = await openOpenaiCompatible({
37
+ apiKey: "sk-test",
38
+ baseUrl: "https://example.test/v1",
39
+ fetch: fetchFn,
40
+ });
41
+ const parts: string[] = [];
42
+ for await (const chunk of client.stream!({
43
+ messages: [{ role: "user", content: "hi" }],
44
+ })) {
45
+ if (chunk.text) parts.push(chunk.text);
46
+ }
47
+ expect(parts.join("")).toBe("Hello");
48
+
49
+ const ac = new AbortController();
50
+ const iter = client.stream!({
51
+ messages: [{ role: "user", content: "hi" }],
52
+ signal: ac.signal,
53
+ })[Symbol.asyncIterator]();
54
+ await iter.next();
55
+ ac.abort();
56
+ // Consumer abort should mark the signal; driver checks between reads.
57
+ expect(ac.signal.aborted).toBe(true);
58
+ void aborted;
59
+ });
60
+ });
61
+
62
+ describe("ollama NDJSON stream", () => {
63
+ test("yields message.content deltas", async () => {
64
+ const ndjson =
65
+ JSON.stringify({ message: { content: "A" }, done: false }) +
66
+ "\n" +
67
+ JSON.stringify({ message: { content: "B" }, done: true }) +
68
+ "\n";
69
+ const fetchFn: typeof fetch = Object.assign(
70
+ async (input: string | URL | Request) => {
71
+ const url = String(input);
72
+ if (url.endsWith("/api/tags")) {
73
+ return new Response(JSON.stringify({ models: [] }), { status: 200 });
74
+ }
75
+ return new Response(ndjson, { status: 200 });
76
+ },
77
+ { preconnect: () => {} },
78
+ ) as typeof fetch;
79
+
80
+ const client = await openOllama({
81
+ model: "llama3.2:1b",
82
+ baseUrl: "http://ollama.test:11434",
83
+ fetch: fetchFn,
84
+ });
85
+ const parts: string[] = [];
86
+ for await (const chunk of client.stream!({
87
+ messages: [{ role: "user", content: "x" }],
88
+ })) {
89
+ if (chunk.text) parts.push(chunk.text);
90
+ }
91
+ expect(parts.join("")).toBe("AB");
92
+ });
93
+ });
94
+
95
+ describe("fx.stream uses ambient AbortSignal", () => {
96
+ test("race abort cancels in-flight stream fetch", async () => {
97
+ let sawAbort = false;
98
+ const smart = ai.model("smart");
99
+ const runtime = createAiRuntime({
100
+ models: [smart],
101
+ defaultDriver: {
102
+ id: "mock",
103
+ async open() {
104
+ return {
105
+ driverId: "mock" as const,
106
+ model: "smart",
107
+ async complete() {
108
+ return { text: "", model: "smart", driverId: "mock" as const };
109
+ },
110
+ async *stream(opts) {
111
+ const signal = opts.signal;
112
+ yield { text: "start" };
113
+ await new Promise<void>((resolve, reject) => {
114
+ const t = setTimeout(resolve, 5_000);
115
+ signal?.addEventListener("abort", () => {
116
+ sawAbort = true;
117
+ clearTimeout(t);
118
+ const err = new Error("aborted");
119
+ err.name = "AbortError";
120
+ reject(err);
121
+ });
122
+ });
123
+ yield { text: "never" };
124
+ },
125
+ };
126
+ },
127
+ },
128
+ });
129
+
130
+ const fx = createFx({
131
+ flow: "stream-host",
132
+ effects: { asks: ["smart"] },
133
+ aiRuntime: runtime,
134
+ });
135
+
136
+ await expect(
137
+ fx.race([
138
+ async () => {
139
+ for await (const _c of fx.stream("smart", { prompt: "long" })) {
140
+ // consume until aborted by the race sibling
141
+ }
142
+ return "stream";
143
+ },
144
+ async () => {
145
+ await new Promise((r) => setTimeout(r, 20));
146
+ return "winner";
147
+ },
148
+ ]),
149
+ ).resolves.toBe("winner");
150
+
151
+ // Give the abort listener a tick.
152
+ await new Promise((r) => setTimeout(r, 30));
153
+ expect(sawAbort).toBe(true);
154
+ });
155
+
156
+ test("mock driver streams through fx.stream", async () => {
157
+ const smart = ai.model("smart");
158
+ const runtime = createAiRuntime({
159
+ models: [smart],
160
+ defaultDriver: mockAiDriver,
161
+ clients: {
162
+ smart: await mockAiDriver.open({
163
+ model: "smart",
164
+ mockResponses: { "*": "abcdef" },
165
+ }),
166
+ },
167
+ });
168
+ const { fx } = createFxContext({
169
+ flow: "s",
170
+ effects: { asks: ["smart"] },
171
+ aiRuntime: runtime,
172
+ });
173
+ const parts: string[] = [];
174
+ for await (const c of fx.stream("smart", { prompt: "hi" })) {
175
+ parts.push(c);
176
+ }
177
+ expect(parts.join("")).toBe("abcdef");
178
+ });
179
+
180
+ test("withAbortSignal aborts cooperative mock stream", async () => {
181
+ const client = await mockAiDriver.open({
182
+ mockResponses: { "*": "abcdefghij" },
183
+ });
184
+ const ac = new AbortController();
185
+ await withAbortSignal(ac.signal, async () => {
186
+ const iter = client.stream!({
187
+ messages: [{ role: "user", content: "x" }],
188
+ signal: ac.signal,
189
+ })[Symbol.asyncIterator]();
190
+ await iter.next();
191
+ ac.abort();
192
+ await expect(iter.next()).rejects.toThrow();
193
+ });
194
+ });
195
+ });