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
@@ -1,5 +1,7 @@
1
1
  /**
2
2
  * Integration: generated compose brings up postgres; app URL talks to it.
3
+ *
4
+ * Opt-in via `OKE_TEST_DOCKER=1` plus a live Docker daemon. Never an empty pass.
3
5
  */
4
6
 
5
7
  import { describe, expect, test } from "bun:test";
@@ -8,127 +10,141 @@ import { tmpdir } from "node:os";
8
10
  import { join } from "node:path";
9
11
  import { deriveInfrastructure, formatStackEnv, writeDerivedFiles } from "./index.ts";
10
12
 
11
- async function dockerAvailable(): Promise<boolean> {
13
+ function dockerAvailable(): boolean {
12
14
  try {
13
- const proc = Bun.spawn(["docker", "info"], {
14
- stdout: "pipe",
15
- stderr: "pipe",
16
- });
17
- return (await proc.exited) === 0;
15
+ return Bun.spawnSync(["docker", "info"], { stdout: "pipe", stderr: "pipe" }).exitCode === 0;
18
16
  } catch {
19
17
  return false;
20
18
  }
21
19
  }
22
20
 
23
- describe("oke dev --docker postgres integration", () => {
24
- test("compose brings up postgres and the app talks to it", async () => {
25
- if (!(await dockerAvailable())) {
26
- console.warn("skipping: docker daemon not available");
27
- return;
28
- }
29
-
30
- const dir = await mkdtemp(join(tmpdir(), "oke-stack-pg-"));
31
- const dockerDir = join(dir, "docker");
32
- const project = `oke-pg-${Date.now()}`;
33
- try {
34
- const derived = deriveInfrastructure({
35
- images: { "store.sql": "postgres:18-alpine" },
36
- credentials: {
37
- "store.sql": {
38
- user: "oke",
39
- password: "stack-integration-pass",
40
- database: "oke",
41
- },
42
- },
43
- app: "stacktest",
44
- host: "127.0.0.1",
45
- includeApp: false,
46
- composeDir: "docker",
47
- });
48
- await writeDerivedFiles(derived, dockerDir, {
49
- writeStackEnv: true,
50
- });
21
+ const WANT = process.env.OKE_TEST_DOCKER === "1";
22
+ const DOCKER = WANT && dockerAvailable();
23
+ if (!DOCKER) {
24
+ console.log(
25
+ WANT
26
+ ? "skip: docker postgres stack e2e (docker daemon not available)"
27
+ : "skip: docker postgres stack e2e (OKE_TEST_DOCKER≠1)",
28
+ );
29
+ }
30
+ const live = DOCKER ? test : test.skip;
51
31
 
52
- // Infra-only: network + role compose (no app build).
53
- const composeFiles = ["compose.yml", "compose.store.sql.yml"];
54
- const up = Bun.spawn(
55
- ["docker", "compose", "-p", project, ...composeFiles.flatMap((f) => ["-f", f]), "up", "-d"],
56
- {
57
- cwd: dockerDir,
58
- stdout: "pipe",
59
- stderr: "pipe",
60
- env: {
61
- ...process.env,
62
- ...derived.stackEnv,
32
+ describe("oke dev --docker postgres integration", () => {
33
+ live(
34
+ "compose brings up postgres and the app talks to it",
35
+ async () => {
36
+ const dir = await mkdtemp(join(tmpdir(), "oke-stack-pg-"));
37
+ const dockerDir = join(dir, "docker");
38
+ const project = `oke-pg-${Date.now()}`;
39
+ try {
40
+ const derived = deriveInfrastructure({
41
+ images: { "store.sql": "postgres:18-alpine" },
42
+ credentials: {
43
+ "store.sql": {
44
+ user: "oke",
45
+ password: "stack-integration-pass",
46
+ database: "oke",
47
+ },
63
48
  },
64
- },
65
- );
66
- const [upOut, upErr, upCode] = await Promise.all([
67
- new Response(up.stdout).text(),
68
- new Response(up.stderr).text(),
69
- up.exited,
70
- ]);
71
- expect(upCode).toBe(0);
72
- if (upCode !== 0) console.error(upOut, upErr);
49
+ app: "stacktest",
50
+ host: "127.0.0.1",
51
+ includeApp: false,
52
+ composeDir: "docker",
53
+ });
54
+ await writeDerivedFiles(derived, dockerDir, {
55
+ writeStackEnv: true,
56
+ });
73
57
 
74
- // Wait for healthy
75
- let healthy = false;
76
- for (let i = 0; i < 40; i++) {
77
- const ps = Bun.spawn(
58
+ // Infra-only: network + role compose (no app build).
59
+ const composeFiles = ["compose.yml", "compose.store.sql.yml"];
60
+ const up = Bun.spawn(
78
61
  [
79
62
  "docker",
80
63
  "compose",
81
64
  "-p",
82
65
  project,
83
66
  ...composeFiles.flatMap((f) => ["-f", f]),
84
- "ps",
85
- "--format",
86
- "json",
67
+ "up",
68
+ "-d",
87
69
  ],
88
- { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
70
+ {
71
+ cwd: dockerDir,
72
+ stdout: "pipe",
73
+ stderr: "pipe",
74
+ env: {
75
+ ...process.env,
76
+ ...derived.stackEnv,
77
+ },
78
+ },
89
79
  );
90
- const text = await new Response(ps.stdout).text();
91
- await ps.exited;
92
- if (/healthy/i.test(text)) {
93
- healthy = true;
94
- break;
80
+ const [upOut, upErr, upCode] = await Promise.all([
81
+ new Response(up.stdout).text(),
82
+ new Response(up.stderr).text(),
83
+ up.exited,
84
+ ]);
85
+ expect(upCode).toBe(0);
86
+ if (upCode !== 0) console.error(upOut, upErr);
87
+
88
+ // Wait for healthy
89
+ let healthy = false;
90
+ for (let i = 0; i < 40; i++) {
91
+ const ps = Bun.spawn(
92
+ [
93
+ "docker",
94
+ "compose",
95
+ "-p",
96
+ project,
97
+ ...composeFiles.flatMap((f) => ["-f", f]),
98
+ "ps",
99
+ "--format",
100
+ "json",
101
+ ],
102
+ { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
103
+ );
104
+ const text = await new Response(ps.stdout).text();
105
+ await ps.exited;
106
+ if (/healthy/i.test(text)) {
107
+ healthy = true;
108
+ break;
109
+ }
110
+ await Bun.sleep(500);
95
111
  }
96
- await Bun.sleep(500);
97
- }
98
- expect(healthy).toBe(true);
112
+ expect(healthy).toBe(true);
99
113
 
100
- const url = derived.stackEnv.DATABASE_URL!;
101
- // App talks to postgres via recipe URL (Bun.SQL) — kernel never sees env-var names.
102
- const sql = new Bun.SQL(url);
103
- try {
104
- const rows = (await sql`select 1::int as n`) as Array<{ n: number }>;
105
- expect(rows[0]?.n).toBe(1);
114
+ const url = derived.stackEnv.DATABASE_URL!;
115
+ // App talks to postgres via recipe URL (Bun.SQL) — kernel never sees env-var names.
116
+ const sql = new Bun.SQL(url);
117
+ try {
118
+ const rows = (await sql`select 1::int as n`) as Array<{ n: number }>;
119
+ expect(rows[0]?.n).toBe(1);
120
+ } finally {
121
+ await sql.close();
122
+ }
123
+
124
+ // Prove credentials live in docker/.env.docker, not YAML.
125
+ const yml = await Bun.file(join(dockerDir, "compose.store.sql.yml")).text();
126
+ expect(yml).not.toContain("stack-integration-pass");
127
+ expect(formatStackEnv(derived.stackEnv)).toContain("stack-integration-pass");
128
+ expect(await Bun.file(join(dockerDir, ".env.docker")).exists()).toBe(true);
106
129
  } finally {
107
- await sql.close();
130
+ await Bun.spawn(
131
+ [
132
+ "docker",
133
+ "compose",
134
+ "-p",
135
+ project,
136
+ "-f",
137
+ "compose.yml",
138
+ "-f",
139
+ "compose.store.sql.yml",
140
+ "down",
141
+ "-v",
142
+ ],
143
+ { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
144
+ ).exited.catch(() => {});
145
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
108
146
  }
109
-
110
- // Prove credentials live in docker/.env.docker, not YAML.
111
- const yml = await Bun.file(join(dockerDir, "compose.store.sql.yml")).text();
112
- expect(yml).not.toContain("stack-integration-pass");
113
- expect(formatStackEnv(derived.stackEnv)).toContain("stack-integration-pass");
114
- expect(await Bun.file(join(dockerDir, ".env.docker")).exists()).toBe(true);
115
- } finally {
116
- await Bun.spawn(
117
- [
118
- "docker",
119
- "compose",
120
- "-p",
121
- project,
122
- "-f",
123
- "compose.yml",
124
- "-f",
125
- "compose.store.sql.yml",
126
- "down",
127
- "-v",
128
- ],
129
- { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
130
- ).exited.catch(() => {});
131
- await rm(dir, { recursive: true, force: true }).catch(() => {});
132
- }
133
- }, 120_000);
147
+ },
148
+ 120_000,
149
+ );
134
150
  });
@@ -12,8 +12,16 @@ import type {
12
12
  AiEmbedResult,
13
13
  AiModelClient,
14
14
  AiOpenOptions,
15
+ AiStreamChunk,
16
+ AiToolCall,
15
17
  } from "./ai-types.ts";
16
18
 
19
+ /** Scripted mock tool-call turn (optional). */
20
+ export type MockToolCallScript = {
21
+ readonly toolCalls: readonly AiToolCall[];
22
+ readonly text?: string;
23
+ };
24
+
17
25
  /**
18
26
  * Create a mock AI driver.
19
27
  */
@@ -27,6 +35,11 @@ export const mockAiDriver: AiDriver = {
27
35
  driverId: "mock",
28
36
  model,
29
37
  async complete(opts: AiCompleteOptions): Promise<AiCompleteResult> {
38
+ if (opts.signal?.aborted) {
39
+ const err = new Error("This operation was aborted");
40
+ err.name = "AbortError";
41
+ throw err;
42
+ }
30
43
  const last = opts.messages[opts.messages.length - 1]?.content ?? "";
31
44
  let payload: unknown | undefined;
32
45
  for (const [key, value] of Object.entries(canned)) {
@@ -39,6 +52,26 @@ export const mockAiDriver: AiDriver = {
39
52
  if (payload === undefined) {
40
53
  payload = canned["*"] ?? { ok: true, echo: last };
41
54
  }
55
+
56
+ // Scripted tool calls: `{ __toolCalls: [...] }` or MockToolCallScript shape
57
+ if (
58
+ payload &&
59
+ typeof payload === "object" &&
60
+ payload !== null &&
61
+ "__toolCalls" in payload
62
+ ) {
63
+ const script = payload as { __toolCalls: readonly AiToolCall[]; text?: string };
64
+ const text = script.text ?? "";
65
+ return {
66
+ text,
67
+ raw: payload,
68
+ model: opts.model ?? model,
69
+ driverId: "mock",
70
+ toolCalls: script.__toolCalls,
71
+ usage: { inputTokens: last.length, outputTokens: text.length, cost: 0 },
72
+ };
73
+ }
74
+
42
75
  const text = typeof payload === "string" ? payload : JSON.stringify(payload);
43
76
  return {
44
77
  text,
@@ -48,6 +81,33 @@ export const mockAiDriver: AiDriver = {
48
81
  usage: { inputTokens: last.length, outputTokens: text.length, cost: 0 },
49
82
  };
50
83
  },
84
+ async *stream(opts: AiCompleteOptions): AsyncIterable<AiStreamChunk> {
85
+ if (opts.signal?.aborted) {
86
+ const err = new Error("This operation was aborted");
87
+ err.name = "AbortError";
88
+ throw err;
89
+ }
90
+ const last = opts.messages[opts.messages.length - 1]?.content ?? "";
91
+ let payload: unknown = canned["*"] ?? last;
92
+ for (const [key, value] of Object.entries(canned)) {
93
+ if (key === "*") continue;
94
+ if (last.includes(key) || opts.model === key) {
95
+ payload = value;
96
+ break;
97
+ }
98
+ }
99
+ const text = typeof payload === "string" ? payload : JSON.stringify(payload);
100
+ const size = Math.max(1, Math.ceil(text.length / 3));
101
+ for (let i = 0; i < text.length; i += size) {
102
+ if (opts.signal?.aborted) {
103
+ const err = new Error("This operation was aborted");
104
+ err.name = "AbortError";
105
+ throw err;
106
+ }
107
+ yield { text: text.slice(i, i + size) };
108
+ }
109
+ yield { text: "", done: true };
110
+ },
51
111
  async embed(opts: AiEmbedOptions): Promise<AiEmbedResult> {
52
112
  const inputs = Array.isArray(opts.input) ? opts.input : [opts.input];
53
113
  const vectors = inputs.map((text) => {
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Live Ollama tool-calling through fx.ask → fx.call (capability + ledger).
3
+ *
4
+ * Opt-in via `OKE_TEST_OLLAMA_URL` (reachable Ollama). A local daemon on
5
+ * :11434 is not enough — set the URL explicitly. Never an empty pass.
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { memoryKvDriver } from "./index.ts";
10
+ import { ai, aiRateGate, createAiRuntime } from "../elements/ai.ts";
11
+ import { createGateRuntime, gate } from "../elements/gate.ts";
12
+ import { createFxContext } from "../kernel/fx.ts";
13
+ import { OLLAMA_DEFAULT_MODEL, openOllama } from "./ai-ollama.ts";
14
+
15
+ const ENV_URL = process.env.OKE_TEST_OLLAMA_URL?.trim();
16
+
17
+ async function probeOllama(url: string): Promise<boolean> {
18
+ try {
19
+ const res = await fetch(`${url}/api/tags`, { signal: AbortSignal.timeout(1_500) });
20
+ return res.ok;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ const reachable = ENV_URL ? await probeOllama(ENV_URL) : false;
27
+ const canLive = Boolean(ENV_URL) && reachable;
28
+
29
+ if (!canLive) {
30
+ console.log(
31
+ ENV_URL
32
+ ? `skip: live ollama tool-calling e2e (unreachable at ${ENV_URL})`
33
+ : "skip: live ollama tool-calling e2e (OKE_TEST_OLLAMA_URL not set)",
34
+ );
35
+ }
36
+ const live = canLive ? test : test.skip;
37
+
38
+ describe("ollama live — tool-calling via fx.call", () => {
39
+ live(
40
+ "gated ask → real tool call → capability ledger portal entry",
41
+ async () => {
42
+ const url = ENV_URL!;
43
+ const model = process.env.OKE_AI_MODEL?.trim() || OLLAMA_DEFAULT_MODEL;
44
+
45
+ const kv = await memoryKvDriver.open({ name: "ollama-tools-rate" });
46
+ const askRate = aiRateGate("ask");
47
+ const gates = createGateRuntime({
48
+ gates: [gate.public, askRate],
49
+ kv,
50
+ now: () => Date.now(),
51
+ });
52
+
53
+ // Simulate HTTP beforeHandle rate gate (AI ask preset).
54
+ const allowed = await gates.allow([askRate.name], {
55
+ auth: { userId: "user-1", scopes: new Set() },
56
+ operator: { id: null },
57
+ });
58
+ expect(allowed).toBe(true);
59
+
60
+ const client = await openOllama({ baseUrl: url, model });
61
+ const smart = ai.model("smart", { provider: "ollama", model });
62
+ const prompt = smart.prompt("tool-host");
63
+ const runtime = createAiRuntime({
64
+ models: [smart],
65
+ prompts: [prompt],
66
+ clients: { smart: client },
67
+ forceJournal: false,
68
+ });
69
+
70
+ const toolInvocations: Array<{ name: string; input: unknown }> = [];
71
+ const { fx, ledger } = createFxContext({
72
+ flow: "assistant.chat",
73
+ effects: {
74
+ asks: ["tool-host"],
75
+ calls: ["lookup.echo"],
76
+ },
77
+ aiRuntime: runtime,
78
+ callHandler: async (name, input) => {
79
+ toolInvocations.push({ name, input });
80
+ return { ok: true, echoed: input };
81
+ },
82
+ });
83
+
84
+ await fx.ask(
85
+ prompt,
86
+ [
87
+ "You are a tool-using assistant.",
88
+ 'You MUST call the function lookup.echo with arguments {"text":"pong"}.',
89
+ "Do not answer in plain text until after the tool has been called.",
90
+ ].join(" "),
91
+ { tools: ["lookup.echo"], maxSteps: 4 },
92
+ );
93
+
94
+ expect(toolInvocations.length).toBeGreaterThan(0);
95
+ expect(toolInvocations.some((c) => c.name === "lookup.echo")).toBe(true);
96
+ expect(ledger.entries.some((e) => e.kind === "ask" && e.resource === "tool-host")).toBe(true);
97
+ expect(
98
+ ledger.entries.some(
99
+ (e) => e.kind === "call" && e.resource === "lookup.echo" && e.reversibility === "portal",
100
+ ),
101
+ ).toBe(true);
102
+
103
+ await kv.close();
104
+ },
105
+ // Match ai-ollama.integration.test.ts — cold model load on a 6GB+ pull
106
+ // can exceed a short CI wall clock even when the hot path is ~7s.
107
+ 25 * 60_000,
108
+ );
109
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Live Ollama round-trip — real prompt, real response, documented default model.
3
+ *
4
+ * Opt-in (never auto-pull a multi-GB model in ordinary `bun test`):
5
+ * 1. `OKE_TEST_OLLAMA_URL` — point at any reachable Ollama
6
+ * 2. `OKE_TEST_OLLAMA_DOCKER=1` — start `ollama/ollama` via the recipe and pull
7
+ * `qwen3.5:9b` (slow first run)
8
+ *
9
+ * Without one of those, the suite skips with a visible reason (never an empty pass).
10
+ * A local daemon on :11434 is not enough — set `OKE_TEST_OLLAMA_URL` explicitly.
11
+ */
12
+
13
+ import { afterAll, describe, expect, test } from "bun:test";
14
+ import { mkdtemp, rm } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { deriveInfrastructure, writeDerivedFiles } from "../docker/index.ts";
18
+ import { ai } from "../elements/ai.ts";
19
+ import { bindAi } from "../kernel/boot-bind/ai.ts";
20
+ import { OLLAMA_DEFAULT_MODEL, openOllama } from "./ai-ollama.ts";
21
+
22
+ const OLLAMA_IMAGE = "ollama/ollama:latest";
23
+ const ENV_URL = process.env.OKE_TEST_OLLAMA_URL?.trim();
24
+ const WANT_DOCKER = process.env.OKE_TEST_OLLAMA_DOCKER === "1";
25
+
26
+ function dockerAvailable(): boolean {
27
+ try {
28
+ return Bun.spawnSync(["docker", "info"], { stdout: "pipe", stderr: "pipe" }).exitCode === 0;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ const DOCKER = dockerAvailable();
35
+ const canLive = Boolean(ENV_URL) || (WANT_DOCKER && DOCKER);
36
+
37
+ if (!canLive) {
38
+ const reasons: string[] = [];
39
+ if (!ENV_URL) reasons.push("OKE_TEST_OLLAMA_URL not set");
40
+ if (!WANT_DOCKER) reasons.push("OKE_TEST_OLLAMA_DOCKER≠1");
41
+ else if (!DOCKER) reasons.push("docker daemon not available");
42
+ console.log(`skip: live ollama e2e (${reasons.join("; ")})`);
43
+ }
44
+ const live = canLive ? test : test.skip;
45
+
46
+ let cleanup: (() => Promise<void>) | undefined;
47
+
48
+ afterAll(async () => {
49
+ if (cleanup) await cleanup();
50
+ });
51
+
52
+ async function waitForOllama(url: string, timeoutMs: number, requireModel: boolean): Promise<void> {
53
+ const deadline = Date.now() + timeoutMs;
54
+ while (Date.now() < deadline) {
55
+ try {
56
+ const res = await fetch(`${url}/api/tags`);
57
+ if (res.ok) {
58
+ if (!requireModel) return;
59
+ const body = (await res.json()) as { models?: readonly { name?: string }[] };
60
+ const names = (body.models ?? []).map((m) => m.name ?? "");
61
+ if (
62
+ names.some((n) => n === OLLAMA_DEFAULT_MODEL || n.startsWith(`${OLLAMA_DEFAULT_MODEL}`))
63
+ ) {
64
+ return;
65
+ }
66
+ }
67
+ } catch {
68
+ // still starting
69
+ }
70
+ await Bun.sleep(1_000);
71
+ }
72
+ throw new Error(
73
+ `ollama not ready at ${url} within ${timeoutMs}ms${
74
+ requireModel ? ` (need model ${OLLAMA_DEFAULT_MODEL})` : ""
75
+ }`,
76
+ );
77
+ }
78
+
79
+ async function resolveLiveUrl(): Promise<{ url: string; model: string }> {
80
+ if (ENV_URL) {
81
+ await waitForOllama(ENV_URL, 30_000, false);
82
+ return { url: ENV_URL, model: process.env.OKE_AI_MODEL?.trim() || OLLAMA_DEFAULT_MODEL };
83
+ }
84
+
85
+ const dir = await mkdtemp(join(tmpdir(), "oke-ollama-"));
86
+ const dockerDir = join(dir, "docker");
87
+ const project = `oke-ollama-${Date.now()}`;
88
+ const instanceId = crypto.randomUUID().replace(/-/g, "").slice(0, 6);
89
+ const derived = deriveInfrastructure({
90
+ images: { ai: OLLAMA_IMAGE },
91
+ app: "ollama-e2e",
92
+ host: "127.0.0.1",
93
+ includeApp: false,
94
+ composeDir: "docker",
95
+ instanceId,
96
+ controls: { OKE_AI_MODEL: OLLAMA_DEFAULT_MODEL },
97
+ });
98
+ await writeDerivedFiles(derived, dockerDir, { writeStackEnv: true });
99
+
100
+ const composeFiles = ["compose.yml", "compose.ai.yml"];
101
+ const up = Bun.spawn(
102
+ ["docker", "compose", "-p", project, ...composeFiles.flatMap((f) => ["-f", f]), "up", "-d"],
103
+ {
104
+ cwd: dockerDir,
105
+ stdout: "pipe",
106
+ stderr: "pipe",
107
+ env: { ...process.env, ...derived.stackEnv, OKE_AI_MODEL: OLLAMA_DEFAULT_MODEL },
108
+ },
109
+ );
110
+ const [upErr, upCode] = await Promise.all([new Response(up.stderr).text(), up.exited]);
111
+ if (upCode !== 0) {
112
+ await rm(dir, { recursive: true, force: true });
113
+ throw new Error(`docker compose up failed: ${upErr}`);
114
+ }
115
+
116
+ const url = derived.stackEnv.OKE_AI_URL!;
117
+ cleanup = async () => {
118
+ const down = Bun.spawn(
119
+ ["docker", "compose", "-p", project, ...composeFiles.flatMap((f) => ["-f", f]), "down", "-v"],
120
+ { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
121
+ );
122
+ await down.exited;
123
+ await rm(dir, { recursive: true, force: true });
124
+ };
125
+
126
+ // Model pull can take a long time on first run.
127
+ await waitForOllama(url, 20 * 60_000, true);
128
+ return { url, model: OLLAMA_DEFAULT_MODEL };
129
+ }
130
+
131
+ describe("ollama live — real completion", () => {
132
+ live(
133
+ "boots against a real Ollama, completes with a real model, returns a real response",
134
+ async () => {
135
+ const { url, model } = await resolveLiveUrl();
136
+ const prevUrl = process.env.OKE_AI_URL;
137
+ const prevModel = process.env.OKE_AI_MODEL;
138
+ process.env.OKE_AI_URL = url;
139
+ process.env.OKE_AI_MODEL = model;
140
+
141
+ try {
142
+ const smart = ai.model("smart", { provider: "ollama", model });
143
+ const ping = smart.prompt("ollama-ping", { version: 1 });
144
+
145
+ const runtime = bindAi(
146
+ {
147
+ config: { drivers: { ai: { local: "ollama", test: "ollama" } } },
148
+ ai: { models: [smart], prompts: [ping] },
149
+ },
150
+ undefined,
151
+ () => Date.now(),
152
+ "local",
153
+ false,
154
+ );
155
+ expect(runtime.prompts.has("ollama-ping")).toBe(true);
156
+
157
+ const client = await openOllama({ baseUrl: url, model });
158
+ const result = await client.complete({
159
+ messages: [
160
+ {
161
+ role: "user",
162
+ content: "Reply with exactly the single word: pong",
163
+ },
164
+ ],
165
+ temperature: 0,
166
+ maxTokens: 32,
167
+ });
168
+
169
+ expect(result.driverId).toBe("ollama");
170
+ expect(result.text.trim().length).toBeGreaterThan(0);
171
+ expect(result.text.toLowerCase()).toContain("pong");
172
+ } finally {
173
+ if (prevUrl === undefined) delete process.env.OKE_AI_URL;
174
+ else process.env.OKE_AI_URL = prevUrl;
175
+ if (prevModel === undefined) delete process.env.OKE_AI_MODEL;
176
+ else process.env.OKE_AI_MODEL = prevModel;
177
+ }
178
+ },
179
+ 25 * 60_000,
180
+ );
181
+ });