okengine 0.5.1 → 0.6.1

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 (105) hide show
  1. package/README.md +148 -13
  2. package/package.json +4 -3
  3. package/site/content/docs/elements/ai.mdx +82 -1
  4. package/site/content/docs/elements/channel.mdx +77 -8
  5. package/site/content/docs/elements/flow.mdx +20 -17
  6. package/site/content/docs/plugins/email-otp.mdx +25 -19
  7. package/site/content/docs/plugins/magic-link.mdx +27 -21
  8. package/site/content/docs/reference/configuration.mdx +12 -4
  9. package/site/content/docs/reference/environment-variables.mdx +42 -13
  10. package/site/content/docs/reference/errors.mdx +14 -0
  11. package/site/content/docs/reference/fx.mdx +68 -16
  12. package/site/content/docs/reference/i18n.mdx +313 -0
  13. package/site/content/docs/reference/index.mdx +6 -1
  14. package/site/content/docs/reference/meta.json +1 -0
  15. package/site/content/docs/reference/plugins.mdx +1 -0
  16. package/src/auth/auth.test.ts +3 -0
  17. package/src/auth/bindings.ts +1 -1
  18. package/src/auth/method-context.ts +12 -2
  19. package/src/cli/openbao-restart.integration.test.ts +106 -97
  20. package/src/compiler/aot.test.ts +16 -13
  21. package/src/compiler/effects-infer.ts +46 -0
  22. package/src/console/server/ai.test.ts +34 -5
  23. package/src/docker/compose.ts +9 -0
  24. package/src/docker/docker.test.ts +39 -0
  25. package/src/docker/dockerfile.integration.test.ts +126 -119
  26. package/src/docker/index.ts +11 -1
  27. package/src/docker/recipes/index.ts +3 -1
  28. package/src/docker/recipes/ollama.ts +43 -0
  29. package/src/docker/stack-id.ts +2 -0
  30. package/src/docker/stack.integration.test.ts +118 -102
  31. package/src/drivers/ai-mock.ts +60 -0
  32. package/src/drivers/ai-ollama-tools.integration.test.ts +109 -0
  33. package/src/drivers/ai-ollama.integration.test.ts +181 -0
  34. package/src/drivers/ai-ollama.ts +327 -0
  35. package/src/drivers/ai-openai-compatible.ts +211 -21
  36. package/src/drivers/ai-providers.test.ts +179 -2
  37. package/src/drivers/ai-stream.test.ts +195 -0
  38. package/src/drivers/ai-types.ts +42 -1
  39. package/src/drivers/channel-fcm.ts +49 -53
  40. package/src/drivers/channel-msegat.ts +61 -0
  41. package/src/drivers/channel-sently-map.ts +57 -0
  42. package/src/drivers/channel-sently.test.ts +99 -0
  43. package/src/drivers/channel-smtp.ts +8 -2
  44. package/src/drivers/channel-sndr.ts +28 -0
  45. package/src/drivers/channel-taqnyat.ts +57 -0
  46. package/src/drivers/channel-types.ts +79 -2
  47. package/src/drivers/channel-unifonic.ts +26 -43
  48. package/src/drivers/channel-wa-cloud.ts +33 -47
  49. package/src/drivers/channel-webpush.ts +39 -239
  50. package/src/drivers/index.ts +25 -1
  51. package/src/drivers/ollama.ts +14 -0
  52. package/src/elements/ai/rate.test.ts +53 -0
  53. package/src/elements/ai/rate.ts +66 -0
  54. package/src/elements/ai/redacted-prompt.test.ts +90 -0
  55. package/src/elements/ai/runtime.ts +330 -100
  56. package/src/elements/ai/tools.test.ts +99 -0
  57. package/src/elements/ai.test.ts +26 -2
  58. package/src/elements/ai.ts +10 -1
  59. package/src/elements/channel/costs.test.ts +2 -2
  60. package/src/elements/channel/costs.ts +14 -2
  61. package/src/elements/channel/mime.ts +11 -0
  62. package/src/elements/channel/runtime.ts +94 -0
  63. package/src/elements/channel/sndr-webhooks.test.ts +26 -0
  64. package/src/elements/channel.ts +10 -1
  65. package/src/elements/index.ts +9 -0
  66. package/src/i18n/catalogs/ar.ts +67 -0
  67. package/src/i18n/catalogs/en.ts +68 -0
  68. package/src/i18n/failure-message.test.ts +56 -0
  69. package/src/i18n/failure-message.ts +93 -0
  70. package/src/i18n/format.ts +67 -0
  71. package/src/i18n/index.ts +57 -0
  72. package/src/i18n/locale-context.ts +48 -0
  73. package/src/i18n/messages.test.ts +173 -0
  74. package/src/i18n/messages.ts +169 -0
  75. package/src/i18n/types.ts +90 -0
  76. package/src/index.ts +26 -0
  77. package/src/kernel/app.ts +92 -2
  78. package/src/kernel/boot-bind/ai.test.ts +60 -0
  79. package/src/kernel/boot-bind/ai.ts +125 -2
  80. package/src/kernel/boot-bind/channel.test.ts +68 -3
  81. package/src/kernel/boot-bind/channel.ts +93 -2
  82. package/src/kernel/boot.test.ts +4 -3
  83. package/src/kernel/boot.ts +1 -1
  84. package/src/kernel/errors.ts +56 -5
  85. package/src/kernel/fx.test.ts +27 -0
  86. package/src/kernel/fx.ts +74 -18
  87. package/src/kernel/pipeline.test.ts +4 -0
  88. package/src/kernel/pipeline.ts +1 -1
  89. package/src/kernel/plugin.ts +16 -0
  90. package/src/kernel/registry.ts +15 -0
  91. package/src/plugins/auth/shared.ts +5 -1
  92. package/src/plugins/auth-delivery.mailpit.integration.test.ts +336 -0
  93. package/src/plugins/auth-methods.security.test.ts +12 -10
  94. package/src/plugins/email-otp.ts +54 -1
  95. package/src/plugins/index.ts +16 -2
  96. package/src/plugins/magic-link.ts +63 -3
  97. package/src/plugins/username-policy.test.ts +302 -0
  98. package/src/plugins/username.ts +290 -9
  99. package/src/release/exports.test.ts +26 -0
  100. package/src/release/exports.ts +64 -5
  101. package/src/release/index.ts +5 -0
  102. package/src/release/measure.exports.test.ts +13 -1
  103. package/src/release/measure.ts +84 -14
  104. package/src/release/official-plugins.ts +46 -0
  105. package/src/release/readme.test.ts +30 -2
@@ -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,9 @@
1
1
  /**
2
- * `fcm` channel driver — Firebase Cloud Messaging HTTP v1 (protocol-shaped).
2
+ * `fcm` channel driver — Firebase Cloud Messaging HTTP v1 via sently.
3
3
  */
4
4
 
5
+ import { FcmTransport } from "sently/transports/fcm";
6
+ import { mapSentlySendError, mapSentlySendResult } from "./channel-sently-map.ts";
5
7
  import type {
6
8
  ChannelDriver,
7
9
  ChannelMessage,
@@ -13,70 +15,64 @@ import type {
13
15
  /**
14
16
  * Open an FCM push driver.
15
17
  *
16
- * @param options - `token` (OAuth access token) + `from` (project id)
18
+ * Prefer service-account credentials (`clientEmail` + `privateKey` + `from`/
19
+ * `projectId`). For tests, pass `token` as a pre-fetched access token via
20
+ * `getAccessToken` (still requires `from` / project id).
21
+ *
22
+ * @param options - Project id + service account or injectable token
17
23
  */
18
24
  export function openFcmChannel(options: ChannelOpenOptions = {}): ChannelDriver {
25
+ const projectId = options.projectId ?? options.from;
26
+ if (!projectId) {
27
+ throw new Error("fcm channel: projectId (or from) is required");
28
+ }
29
+
30
+ const clientEmail = options.clientEmail ?? options.user;
31
+ const privateKey = options.privateKey ?? options.pass;
19
32
  const accessToken = options.token ?? options.apiKey;
20
- const projectId = options.from;
21
- const fetchFn = options.fetch ?? globalThis.fetch;
22
- const base = options.url ?? "https://fcm.googleapis.com";
33
+
34
+ if (!accessToken && !(clientEmail && privateKey)) {
35
+ throw new Error(
36
+ "fcm channel: clientEmail+privateKey (service account) or token (access token) required",
37
+ );
38
+ }
39
+ if ((clientEmail && !privateKey) || (!clientEmail && privateKey)) {
40
+ throw new Error("fcm channel: clientEmail and privateKey must be provided together");
41
+ }
42
+
43
+ // Token-only mode (tests / pre-fetched OAuth): sently still requires placeholder
44
+ // service-account fields; getAccessToken skips JWT exchange.
45
+ const transport = new FcmTransport({
46
+ projectId,
47
+ clientEmail: clientEmail ?? "oke-fcm@local",
48
+ privateKey:
49
+ privateKey ??
50
+ "-----BEGIN PRIVATE KEY-----\nMIIEowIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF6PZGFw=\n-----END PRIVATE KEY-----\n",
51
+ ...(accessToken ? { getAccessToken: async () => accessToken } : {}),
52
+ });
23
53
 
24
54
  const channel: ChannelTransport = {
25
55
  provider: "fcm",
26
56
  mediums: ["push"],
27
57
  async send(message: ChannelMessage): Promise<ChannelSendResult> {
28
- if (!accessToken || !projectId) {
29
- throw new Error("fcm: token and from (project id) are required");
30
- }
31
- const res = await fetchFn(`${base}/v1/projects/${projectId}/messages:send`, {
32
- method: "POST",
33
- headers: {
34
- Authorization: `Bearer ${accessToken}`,
35
- "Content-Type": "application/json",
36
- },
37
- body: JSON.stringify({
38
- message: {
39
- token: message.to,
40
- notification: {
41
- title: message.subject ?? message.template ?? "notification",
42
- body: message.text ?? "",
43
- },
44
- data: Object.fromEntries(
45
- Object.entries(message.data ?? {}).map(([k, v]) => [k, String(v)]),
46
- ),
47
- },
48
- }),
49
- });
50
- const body = (await res.json().catch(() => ({}))) as {
51
- name?: string;
52
- error?: { message?: string };
53
- };
54
- const id = body.name ?? crypto.randomUUID();
55
- if (!res.ok) {
56
- return {
57
- ok: false,
58
- messageId: id,
59
- driverId: "fcm",
60
- attempts: [
61
- {
62
- driverId: "fcm",
63
- ok: false,
64
- error: body.error?.message ?? `HTTP ${res.status}`,
65
- at: Date.now(),
66
- },
67
- ],
68
- };
58
+ try {
59
+ const title = message.subject ?? message.template ?? "notification";
60
+ const body = message.text ?? "";
61
+ const result = await transport.send({
62
+ token: message.to,
63
+ title,
64
+ body,
65
+ ...(message.data ? { data: { ...message.data } } : {}),
66
+ });
67
+ return mapSentlySendResult("fcm", result);
68
+ } catch (err) {
69
+ return mapSentlySendError("fcm", err);
69
70
  }
70
- return {
71
- ok: true,
72
- messageId: id,
73
- driverId: "fcm",
74
- attempts: [{ driverId: "fcm", ok: true, at: Date.now(), messageId: id }],
75
- };
76
71
  },
72
+ verify: () => transport.verify(),
77
73
  };
78
74
 
79
- return { id: "fcm", channel };
75
+ return { id: "fcm", channel, pushTransport: transport };
80
76
  }
81
77
 
82
78
  /** FCM driver factory. */
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `msegat` channel driver — SMS via sently's Msegat transport.
3
+ */
4
+
5
+ import { MsegatTransport } from "sently/transports/msegat";
6
+ import { mapSentlySendError, mapSentlySendResult } from "./channel-sently-map.ts";
7
+ import type {
8
+ ChannelDriver,
9
+ ChannelMessage,
10
+ ChannelOpenOptions,
11
+ ChannelSendResult,
12
+ ChannelTransport,
13
+ } from "./channel-types.ts";
14
+
15
+ /**
16
+ * Open a Msegat SMS driver.
17
+ *
18
+ * @param options - `userName`/`user` + `apiKey` + `sender`/`from`
19
+ */
20
+ export function openMsegatChannel(options: ChannelOpenOptions = {}): ChannelDriver {
21
+ const userName = options.userName ?? options.user;
22
+ const apiKey = options.apiKey;
23
+ const sender = options.sender ?? options.from;
24
+ if (!userName) {
25
+ throw new Error("msegat channel: userName (or user) is required");
26
+ }
27
+ if (!apiKey) {
28
+ throw new Error("msegat channel: apiKey is required");
29
+ }
30
+ if (!sender) {
31
+ throw new Error("msegat channel: sender (or from) is required");
32
+ }
33
+
34
+ const transport = new MsegatTransport({ userName, apiKey, sender });
35
+
36
+ const channel: ChannelTransport = {
37
+ provider: "msegat",
38
+ mediums: ["sms"],
39
+ async send(message: ChannelMessage): Promise<ChannelSendResult> {
40
+ try {
41
+ const result = await transport.send({
42
+ to: message.to,
43
+ body: message.text ?? String(message.data?.code ?? ""),
44
+ ...(message.from ? { from: message.from } : {}),
45
+ });
46
+ return mapSentlySendResult("msegat", result);
47
+ } catch (err) {
48
+ return mapSentlySendError("msegat", err);
49
+ }
50
+ },
51
+ verify: () => transport.verify(),
52
+ };
53
+
54
+ return { id: "msegat", channel, smsTransport: transport };
55
+ }
56
+
57
+ /** Msegat driver factory. */
58
+ export const msegatChannelDriver = {
59
+ id: "msegat" as const,
60
+ open: openMsegatChannel,
61
+ };