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
@@ -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
+ });
@@ -14,14 +14,33 @@ export type AiDriverId =
14
14
  | "vertex"
15
15
  | "ollama";
16
16
 
17
+ /** Tool definition offered to a model (Flow-backed at the fx layer). */
18
+ export interface AiToolDef {
19
+ readonly name: string;
20
+ readonly description?: string;
21
+ /** JSON-schema-like parameters object. */
22
+ readonly parameters?: unknown;
23
+ }
24
+
25
+ /** One model-initiated tool invocation. */
26
+ export interface AiToolCall {
27
+ readonly id: string;
28
+ readonly name: string;
29
+ readonly arguments: unknown;
30
+ }
31
+
17
32
  /** One chat / completion turn. */
18
33
  export interface AiMessage {
19
34
  readonly role: "system" | "user" | "assistant" | "tool";
20
35
  readonly content: string;
21
36
  readonly name?: string;
37
+ /** When role is `tool`, the id of the tool call this message answers. */
38
+ readonly toolCallId?: string;
39
+ /** When role is `assistant`, optional tool calls the model requested. */
40
+ readonly toolCalls?: readonly AiToolCall[];
22
41
  }
23
42
 
24
- /** Options for a model completion. */
43
+ /** Options for a model completion (or stream start). */
25
44
  export interface AiCompleteOptions {
26
45
  readonly messages: readonly AiMessage[];
27
46
  readonly model?: string;
@@ -29,6 +48,10 @@ export interface AiCompleteOptions {
29
48
  readonly maxTokens?: number;
30
49
  /** Structured output hint (JSON schema or name). */
31
50
  readonly responseFormat?: unknown;
51
+ /** Tools the model may call (OpenAI-shaped; drivers map). */
52
+ readonly tools?: readonly AiToolDef[];
53
+ /** Ambient / local abort — cooperative cancellation for complete + stream. */
54
+ readonly signal?: AbortSignal;
32
55
  }
33
56
 
34
57
  /** Result of a model completion. */
@@ -37,6 +60,7 @@ export interface AiCompleteResult {
37
60
  readonly raw?: unknown;
38
61
  readonly model: string;
39
62
  readonly driverId: AiDriverId;
63
+ readonly toolCalls?: readonly AiToolCall[];
40
64
  readonly usage?: {
41
65
  readonly inputTokens?: number;
42
66
  readonly outputTokens?: number;
@@ -44,6 +68,12 @@ export interface AiCompleteResult {
44
68
  };
45
69
  }
46
70
 
71
+ /** One streamed token / delta from a model. */
72
+ export interface AiStreamChunk {
73
+ readonly text: string;
74
+ readonly done?: boolean;
75
+ }
76
+
47
77
  /** Embedding request. */
48
78
  export interface AiEmbedOptions {
49
79
  readonly input: string | readonly string[];
@@ -62,6 +92,12 @@ export interface AiModelClient {
62
92
  readonly driverId: AiDriverId;
63
93
  readonly model: string;
64
94
  complete(options: AiCompleteOptions): Promise<AiCompleteResult>;
95
+ /**
96
+ * Stream tokens. Optional — callers fail loud when missing (no stub echo).
97
+ *
98
+ * @param options - Same as complete, plus optional signal
99
+ */
100
+ stream?(options: AiCompleteOptions): AsyncIterable<AiStreamChunk>;
65
101
  embed?(options: AiEmbedOptions): Promise<AiEmbedResult>;
66
102
  close?(): Promise<void>;
67
103
  }
@@ -71,6 +107,11 @@ export interface AiOpenOptions {
71
107
  readonly model?: string;
72
108
  readonly apiKey?: string;
73
109
  readonly baseUrl?: string;
110
+ /**
111
+ * Extra HTTP headers (e.g. OpenRouter `HTTP-Referer` / `X-Title`).
112
+ * Merged into chat and embed requests; does not invent a new driver.
113
+ */
114
+ readonly headers?: Readonly<Record<string, string>>;
74
115
  /** Mock canned responses keyed by prompt name or substring. */
75
116
  readonly mockResponses?: Readonly<Record<string, unknown>>;
76
117
  readonly fetch?: typeof globalThis.fetch;
@@ -1,7 +1,8 @@
1
1
  /**
2
- * `smtp` channel driver — wraps sently's SMTP transport unchanged.
2
+ * `smtp` channel driver — wraps sently's SMTP transport with the Bun socket adapter.
3
3
  */
4
4
 
5
+ import { BunAdapter } from "sently/adapters/bun";
5
6
  import { SMTPTransport } from "sently/transports/smtp";
6
7
  import type { ChannelDriver, ChannelOpenOptions } from "./channel-types.ts";
7
8
 
@@ -14,9 +15,14 @@ export function openSmtpChannel(options: ChannelOpenOptions = {}): ChannelDriver
14
15
  if (!options.host) {
15
16
  throw new Error("smtp channel: host is required");
16
17
  }
18
+ const port = options.port ?? 587;
19
+ // Implicit TLS only on the classic SMTPS port; Mailpit / local relays are plain.
20
+ const secure = port === 465;
17
21
  const transport = new SMTPTransport({
18
22
  host: options.host,
19
- port: options.port ?? 587,
23
+ port,
24
+ secure,
25
+ adapter: new BunAdapter({ secure }),
20
26
  ...(options.user && options.pass ? { auth: { user: options.user, pass: options.pass } } : {}),
21
27
  });
22
28
  return { id: "smtp", transport };
@@ -147,8 +147,11 @@ export { webpushChannelDriver, openWebPushChannel } from "./channel-webpush.ts";
147
147
  export type {
148
148
  AiDriverId,
149
149
  AiMessage,
150
+ AiToolDef,
151
+ AiToolCall,
150
152
  AiCompleteOptions,
151
153
  AiCompleteResult,
154
+ AiStreamChunk,
152
155
  AiEmbedOptions,
153
156
  AiEmbedResult,
154
157
  AiModelClient,
@@ -158,4 +161,21 @@ export type {
158
161
 
159
162
  export { mockAiDriver, createMockAiDriver } from "./ai-mock.ts";
160
163
  export { anthropicAiDriver, openAnthropic } from "./ai-anthropic.ts";
161
- export { openaiCompatibleAiDriver, openOpenaiCompatible } from "./ai-openai-compatible.ts";
164
+ export {
165
+ openaiCompatibleAiDriver,
166
+ openOpenaiCompatible,
167
+ OPENAI_COMPAT_DEFAULT_BASE,
168
+ normalizeOpenaiCompatibleBaseUrl,
169
+ isOpenaiCloudBase,
170
+ openaiCompatibleHeaders,
171
+ } from "./ai-openai-compatible.ts";
172
+ export {
173
+ ollamaAiDriver,
174
+ openOllama,
175
+ OllamaUnavailableError,
176
+ OLLAMA_DEFAULT_MODEL,
177
+ OLLAMA_DEFAULT_BASE_URL,
178
+ normalizeOllamaBaseUrl,
179
+ resolveOllamaBaseUrl,
180
+ resolveOllamaModel,
181
+ } from "./ai-ollama.ts";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Protocol-named re-export — prefer `okengine/drivers/ai-ollama` or
3
+ * `okengine/drivers` (`ollamaAiDriver`). Same surface as {@link ./ai-ollama.ts}.
4
+ */
5
+ export {
6
+ ollamaAiDriver,
7
+ openOllama,
8
+ OllamaUnavailableError,
9
+ OLLAMA_DEFAULT_MODEL,
10
+ OLLAMA_DEFAULT_BASE_URL,
11
+ normalizeOllamaBaseUrl,
12
+ resolveOllamaBaseUrl,
13
+ resolveOllamaModel,
14
+ } from "./ai-ollama.ts";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * AI rate presets — gate.rate only, no parallel budgeting system.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { memoryKvDriver } from "../../drivers/index.ts";
7
+ import { createGateRuntime } from "../gate.ts";
8
+ import { AI_RATE_PRESETS, aiRateGate, createAiRateGates } from "./rate.ts";
9
+
10
+ describe("AI_RATE_PRESETS", () => {
11
+ test("ask is stricter than embed; agent is strictest", () => {
12
+ expect(AI_RATE_PRESETS.ask).toEqual({ max: 20, per: "1m", keyBy: "user" });
13
+ expect(AI_RATE_PRESETS.agent).toEqual({ max: 10, per: "1m", keyBy: "user" });
14
+ expect(AI_RATE_PRESETS.embed).toEqual({ max: 60, per: "1m", keyBy: "user" });
15
+ expect(AI_RATE_PRESETS.agent.max).toBeLessThan(AI_RATE_PRESETS.ask.max);
16
+ expect(AI_RATE_PRESETS.ask.max).toBeLessThan(AI_RATE_PRESETS.embed.max);
17
+ });
18
+
19
+ test("aiRateGate builds a real gate.rate decl", () => {
20
+ const g = aiRateGate("ask");
21
+ expect(g.kind).toBe("rate");
22
+ expect(g.max).toBe(20);
23
+ expect(g.per).toBe("1m");
24
+ expect(g.keyBy).toBe("user");
25
+ expect(g.name).toContain("20/1m");
26
+ });
27
+
28
+ test("createAiRateGates materializes three decls; deny after max", async () => {
29
+ const kv = await memoryKvDriver.open({ name: "ai-rate" });
30
+ const gates = createAiRateGates();
31
+ expect(gates).toHaveLength(3);
32
+ const runtime = createGateRuntime({ gates: [...gates], kv, now: () => 1_000 });
33
+ const ask = gates[0]!;
34
+ const ctx = {
35
+ auth: { userId: "u1", scopes: new Set<string>() },
36
+ operator: { id: null },
37
+ meta: {},
38
+ };
39
+ for (let i = 0; i < ask.max; i++) {
40
+ const ev = await runtime.check([ask.name], ctx);
41
+ expect(ev.every((e) => e.allowed)).toBe(true);
42
+ }
43
+ const denied = await runtime.check([ask.name], ctx);
44
+ expect(denied.some((e) => !e.allowed)).toBe(true);
45
+ await kv.close();
46
+ });
47
+
48
+ test("public AI edge can override keyBy to ip", () => {
49
+ const g = aiRateGate("ask", { keyBy: "ip", max: 5 });
50
+ expect(g.keyBy).toBe("ip");
51
+ expect(g.max).toBe(5);
52
+ });
53
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * AI rate-limit presets — reuse {@link gate.rate}, no parallel budgeting.
3
+ *
4
+ * Cost caps stay on prompt/agent `budget` decls. These presets throttle
5
+ * request volume on HTTP triggers that wrap `fx.ask` / agents / embeds.
6
+ */
7
+
8
+ import { gate, type RateGateDecl } from "../gate/declare.ts";
9
+
10
+ /** Preset keys for AI-facing HTTP edges. */
11
+ export type AiRatePreset = "ask" | "agent" | "embed";
12
+
13
+ /** One AI rate preset row. */
14
+ export type AiRatePresetSpec = {
15
+ readonly max: number;
16
+ readonly per: string;
17
+ readonly keyBy: string;
18
+ };
19
+
20
+ /**
21
+ * Sensible defaults: AI calls are far more expensive per request than
22
+ * ordinary HTTP, so limits are tighter than typical API rate limits.
23
+ * Prefer `keyBy: "user"` when the edge is authenticated; use `ip` on
24
+ * public unauthenticated AI surfaces.
25
+ */
26
+ export const AI_RATE_PRESETS: Readonly<Record<AiRatePreset, AiRatePresetSpec>> = {
27
+ ask: { max: 20, per: "1m", keyBy: "user" },
28
+ agent: { max: 10, per: "1m", keyBy: "user" },
29
+ embed: { max: 60, per: "1m", keyBy: "user" },
30
+ };
31
+
32
+ /**
33
+ * Build a `gate.rate` declaration from an AI preset.
34
+ *
35
+ * @param kind - ask · agent · embed
36
+ * @param overrides - Optional max / per / keyBy overrides
37
+ */
38
+ export function aiRateGate(
39
+ kind: AiRatePreset,
40
+ overrides?: {
41
+ readonly max?: number;
42
+ readonly per?: string;
43
+ readonly keyBy?: string;
44
+ readonly description?: string;
45
+ },
46
+ ): RateGateDecl {
47
+ const preset = AI_RATE_PRESETS[kind];
48
+ return gate.rate({
49
+ max: overrides?.max ?? preset.max,
50
+ per: overrides?.per ?? preset.per,
51
+ keyBy: overrides?.keyBy ?? preset.keyBy,
52
+ description:
53
+ overrides?.description ??
54
+ `AI ${kind} rate limit (${overrides?.max ?? preset.max}/${overrides?.per ?? preset.per})`,
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Materialize all AI rate gate decls (ask + agent + embed).
60
+ *
61
+ * @param enabled - When false, returns []
62
+ */
63
+ export function createAiRateGates(enabled = true): readonly RateGateDecl[] {
64
+ if (!enabled) return [];
65
+ return [aiRateGate("ask"), aiRateGate("agent"), aiRateGate("embed")];
66
+ }