okengine 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/site/content/docs/elements/ai.mdx +82 -1
- package/site/content/docs/elements/channel.mdx +6 -1
- package/site/content/docs/elements/flow.mdx +20 -17
- package/site/content/docs/plugins/email-otp.mdx +25 -19
- package/site/content/docs/plugins/magic-link.mdx +27 -21
- package/site/content/docs/reference/configuration.mdx +7 -0
- package/site/content/docs/reference/environment-variables.mdx +10 -5
- package/site/content/docs/reference/errors.mdx +14 -0
- package/site/content/docs/reference/fx.mdx +68 -16
- package/site/content/docs/reference/i18n.mdx +313 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +1 -0
- package/site/content/docs/reference/plugins.mdx +1 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/method-context.ts +12 -2
- package/src/compiler/aot.test.ts +16 -13
- package/src/compiler/effects-infer.ts +46 -0
- package/src/console/server/ai.test.ts +34 -5
- package/src/docker/compose.ts +9 -0
- package/src/docker/docker.test.ts +39 -0
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -1
- package/src/docker/recipes/ollama.ts +43 -0
- package/src/docker/stack-id.ts +2 -0
- package/src/drivers/ai-mock.ts +60 -0
- package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
- package/src/drivers/ai-ollama.integration.test.ts +197 -0
- package/src/drivers/ai-ollama.ts +327 -0
- package/src/drivers/ai-openai-compatible.ts +211 -21
- package/src/drivers/ai-providers.test.ts +179 -2
- package/src/drivers/ai-stream.test.ts +195 -0
- package/src/drivers/ai-types.ts +42 -1
- package/src/drivers/channel-smtp.ts +8 -2
- package/src/drivers/index.ts +21 -1
- package/src/drivers/ollama.ts +14 -0
- package/src/elements/ai/rate.test.ts +53 -0
- package/src/elements/ai/rate.ts +66 -0
- package/src/elements/ai/redacted-prompt.test.ts +90 -0
- package/src/elements/ai/runtime.ts +330 -100
- package/src/elements/ai/tools.test.ts +99 -0
- package/src/elements/ai.test.ts +26 -2
- package/src/elements/ai.ts +10 -1
- package/src/i18n/catalogs/ar.ts +67 -0
- package/src/i18n/catalogs/en.ts +68 -0
- package/src/i18n/failure-message.test.ts +56 -0
- package/src/i18n/failure-message.ts +93 -0
- package/src/i18n/format.ts +67 -0
- package/src/i18n/index.ts +57 -0
- package/src/i18n/locale-context.ts +48 -0
- package/src/i18n/messages.test.ts +173 -0
- package/src/i18n/messages.ts +169 -0
- package/src/i18n/types.ts +90 -0
- package/src/index.ts +26 -0
- package/src/kernel/app.ts +92 -2
- package/src/kernel/boot-bind/ai.test.ts +60 -0
- package/src/kernel/boot-bind/ai.ts +125 -2
- package/src/kernel/boot.test.ts +4 -3
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/errors.ts +56 -5
- package/src/kernel/fx.test.ts +27 -0
- package/src/kernel/fx.ts +74 -18
- package/src/kernel/pipeline.test.ts +4 -0
- package/src/kernel/pipeline.ts +1 -1
- package/src/kernel/plugin.ts +16 -0
- package/src/kernel/registry.ts +15 -0
- package/src/plugins/auth/shared.ts +5 -1
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
- package/src/plugins/auth-methods.security.test.ts +12 -10
- package/src/plugins/email-otp.ts +54 -1
- package/src/plugins/index.ts +16 -2
- package/src/plugins/magic-link.ts +63 -3
- package/src/plugins/username-policy.test.ts +302 -0
- package/src/plugins/username.ts +290 -9
- package/src/release/measure.ts +8 -1
|
@@ -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
|
+
});
|
package/src/drivers/ai-types.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 };
|
package/src/drivers/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
+
}
|