okengine 0.9.0 → 0.10.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 (175) hide show
  1. package/AGENTS.md +1 -1
  2. package/manifest.v1.schema.json +9 -1
  3. package/package.json +3 -3
  4. package/site/content/docs/console/runs.mdx +10 -0
  5. package/site/content/docs/console/traces.mdx +4 -0
  6. package/site/content/docs/elements/ai.mdx +54 -32
  7. package/site/content/docs/elements/channel.mdx +105 -14
  8. package/site/content/docs/elements/clock.mdx +1 -0
  9. package/site/content/docs/elements/flow.mdx +42 -8
  10. package/site/content/docs/get-started/basic-usage.mdx +5 -1
  11. package/site/content/docs/get-started/installation.mdx +9 -5
  12. package/site/content/docs/meta.json +2 -0
  13. package/site/content/docs/plugins/otp.mdx +58 -46
  14. package/site/content/docs/providers/azure-redis.mdx +85 -0
  15. package/site/content/docs/providers/cockroachdb.mdx +86 -0
  16. package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
  17. package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
  18. package/site/content/docs/providers/elasticache.mdx +92 -0
  19. package/site/content/docs/providers/index.mdx +101 -0
  20. package/site/content/docs/providers/memorystore.mdx +89 -0
  21. package/site/content/docs/providers/meta.json +20 -0
  22. package/site/content/docs/providers/neon.mdx +115 -0
  23. package/site/content/docs/providers/redis-cloud.mdx +93 -0
  24. package/site/content/docs/providers/supabase.mdx +117 -0
  25. package/site/content/docs/providers/upstash.mdx +91 -0
  26. package/site/content/docs/providers/yugabytedb.mdx +95 -0
  27. package/site/content/docs/recipes/caddy.mdx +128 -0
  28. package/site/content/docs/recipes/cockroachdb.mdx +127 -0
  29. package/site/content/docs/recipes/dragonfly.mdx +125 -0
  30. package/site/content/docs/recipes/index.mdx +146 -0
  31. package/site/content/docs/recipes/llama-cpp.mdx +153 -0
  32. package/site/content/docs/recipes/mailpit.mdx +136 -0
  33. package/site/content/docs/recipes/meilisearch.mdx +139 -0
  34. package/site/content/docs/recipes/meta.json +31 -0
  35. package/site/content/docs/recipes/ollama.mdx +142 -0
  36. package/site/content/docs/recipes/openbao.mdx +141 -0
  37. package/site/content/docs/recipes/pgdog.mdx +137 -0
  38. package/site/content/docs/recipes/postgres.mdx +136 -0
  39. package/site/content/docs/recipes/redis.mdx +130 -0
  40. package/site/content/docs/recipes/rustfs.mdx +148 -0
  41. package/site/content/docs/recipes/sglang.mdx +105 -0
  42. package/site/content/docs/recipes/supabase-docker.mdx +145 -0
  43. package/site/content/docs/recipes/timescale.mdx +129 -0
  44. package/site/content/docs/recipes/traefik.mdx +136 -0
  45. package/site/content/docs/recipes/valkey.mdx +121 -0
  46. package/site/content/docs/recipes/vllm.mdx +106 -0
  47. package/site/content/docs/recipes/yugabytedb.mdx +135 -0
  48. package/site/content/docs/reference/cli.md +42 -6
  49. package/site/content/docs/reference/configuration.mdx +5 -3
  50. package/site/content/docs/reference/environment-variables.mdx +12 -12
  51. package/site/content/docs/reference/fx.mdx +38 -4
  52. package/src/auth/otp-capability.ts +14 -14
  53. package/src/cli/ai-setup/ai-setup.test.ts +32 -12
  54. package/src/cli/ai-setup/apply.ts +65 -23
  55. package/src/cli/ai-setup/catalog.ts +1316 -35
  56. package/src/cli/ai-setup/detect-ollama.ts +47 -0
  57. package/src/cli/ai-setup/index.ts +44 -7
  58. package/src/cli/ai-setup/prompts.ts +430 -486
  59. package/src/cli/ai-setup/recommend.test.ts +150 -50
  60. package/src/cli/ai-setup/recommend.ts +118 -101
  61. package/src/cli/ask-seed.test.ts +68 -0
  62. package/src/cli/ask-seed.ts +79 -0
  63. package/src/cli/db-seed-redact.test.ts +25 -0
  64. package/src/cli/db-seed.ts +36 -7
  65. package/src/cli/db.test.ts +1 -1
  66. package/src/cli/db.ts +2 -2
  67. package/src/cli/dev-controls.test.ts +107 -0
  68. package/src/cli/dev-controls.ts +354 -0
  69. package/src/cli/dev-schema-sync.ts +12 -4
  70. package/src/cli/dev.test.ts +50 -30
  71. package/src/cli/dev.ts +607 -66
  72. package/src/cli/drizzle-env.test.ts +40 -6
  73. package/src/cli/drizzle-env.ts +22 -1
  74. package/src/cli/ensure-drizzle-config.ts +6 -4
  75. package/src/cli/hero-meta.test.ts +13 -1
  76. package/src/cli/hero-meta.ts +28 -6
  77. package/src/cli/index.ts +5 -0
  78. package/src/cli/registry.ts +24 -0
  79. package/src/cli/replay.test.ts +105 -0
  80. package/src/cli/replay.ts +206 -0
  81. package/src/compiler/extract.ts +3 -3
  82. package/src/config/index.ts +3 -3
  83. package/src/console/server/claim.ts +3 -1
  84. package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
  85. package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
  86. package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
  87. package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
  88. package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
  89. package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
  90. package/src/console/ui/dist/index.html +1 -1
  91. package/src/console/ui/overview/slo.ts +31 -3
  92. package/src/console/ui/overview/types.ts +6 -0
  93. package/src/console/ui/runs/errors.test.ts +53 -0
  94. package/src/console/ui/runs/errors.ts +88 -0
  95. package/src/console/ui/runs/index.ts +8 -0
  96. package/src/console/ui/runs/search.ts +16 -0
  97. package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
  98. package/src/docker/ai-model-status.test.ts +101 -0
  99. package/src/docker/ai-model-status.ts +276 -0
  100. package/src/docker/compose-health.test.ts +115 -0
  101. package/src/docker/compose-health.ts +234 -0
  102. package/src/docker/compose.ts +24 -8
  103. package/src/docker/derive.ts +35 -9
  104. package/src/docker/docker.test.ts +304 -12
  105. package/src/docker/helpers.ts +56 -0
  106. package/src/docker/index.ts +52 -0
  107. package/src/docker/ollama-pull.ts +232 -0
  108. package/src/docker/recipes/cockroach.ts +24 -0
  109. package/src/docker/recipes/index.ts +32 -0
  110. package/src/docker/recipes/llama-cpp.ts +273 -0
  111. package/src/docker/recipes/ollama.ts +19 -23
  112. package/src/docker/recipes/pgdog.ts +4 -1
  113. package/src/docker/recipes/postgres.ts +5 -18
  114. package/src/docker/recipes/sglang.ts +55 -0
  115. package/src/docker/recipes/supabase.ts +21 -0
  116. package/src/docker/recipes/timescale.ts +18 -0
  117. package/src/docker/recipes/vllm.ts +44 -0
  118. package/src/docker/recipes/yugabyte.ts +23 -0
  119. package/src/docker/stack-id.test.ts +22 -0
  120. package/src/docker/stack-id.ts +38 -4
  121. package/src/docker/types.ts +15 -0
  122. package/src/drivers/ai-ollama.integration.test.ts +7 -4
  123. package/src/drivers/signal-engine.ts +10 -0
  124. package/src/drivers/signal-postgres.ts +24 -2
  125. package/src/drivers/signal-types.ts +10 -0
  126. package/src/elements/channel/fallback.test.ts +91 -0
  127. package/src/elements/channel/hard-bounce.test.ts +82 -0
  128. package/src/elements/channel/injection.test.ts +153 -0
  129. package/src/elements/channel/locale-ar.test.ts +102 -0
  130. package/src/elements/channel/otp-delivery.ts +5 -2
  131. package/src/elements/channel/receipts-ledger.test.ts +100 -0
  132. package/src/elements/channel/runtime.ts +24 -1
  133. package/src/elements/channel/test-helpers.ts +60 -0
  134. package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
  135. package/src/elements/store/cache.ts +1 -1
  136. package/src/elements/store/emit-drizzle.ts +43 -9
  137. package/src/elements/store/runtime.ts +1 -1
  138. package/src/elements/store/schema-decl.test.ts +2 -0
  139. package/src/elements/store/schema-decl.ts +15 -0
  140. package/src/elements/store/seed.ts +1 -1
  141. package/src/index.ts +1 -0
  142. package/src/kernel/app.ts +118 -11
  143. package/src/kernel/boot-bind/signal.ts +6 -2
  144. package/src/kernel/boot-bind/store.ts +2 -1
  145. package/src/kernel/boot.test.ts +6 -5
  146. package/src/kernel/boot.ts +8 -3
  147. package/src/kernel/compensate.test.ts +131 -0
  148. package/src/kernel/correlation.test.ts +108 -0
  149. package/src/kernel/effect-timing.test.ts +82 -0
  150. package/src/kernel/flow.ts +38 -0
  151. package/src/kernel/fx-runs.test.ts +49 -0
  152. package/src/kernel/fx.test.ts +14 -0
  153. package/src/kernel/fx.ts +111 -1
  154. package/src/manifest/types.ts +8 -2
  155. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  156. package/src/plugins/auth-methods.security.test.ts +3 -3
  157. package/src/plugins/auth-methods.test.ts +1 -1
  158. package/src/plugins/index.ts +2 -2
  159. package/src/plugins/otp.test.ts +21 -19
  160. package/src/plugins/otp.ts +36 -34
  161. package/src/plugins/taqnyat.live.test.ts +3 -3
  162. package/src/runs/collect.ts +3 -0
  163. package/src/runs/export-otlp.ts +64 -0
  164. package/src/runs/index.ts +16 -0
  165. package/src/runs/parquet.ts +9 -0
  166. package/src/runs/types.ts +5 -0
  167. package/src/runs/window.test.ts +68 -0
  168. package/src/runs/window.ts +148 -0
  169. package/src/runtime/boot-warn.test.ts +45 -0
  170. package/src/runtime/boot-warn.ts +17 -0
  171. package/src/runtime/dev-request-log.ts +5 -5
  172. package/src/term.test.ts +95 -10
  173. package/src/term.ts +369 -14
  174. package/src/console/ui/dist/assets/panel-overview-DONDzBd2.js +0 -1
  175. package/src/console/ui/dist/assets/panel-runs-D5zf-D9c.js +0 -1
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Pull an Ollama model via the container's HTTP API on its exposed host port.
3
+ *
4
+ * Never shells out to a host `ollama` CLI — that talks to whichever server wins
5
+ * on the default local port (often a separately installed host daemon), so the
6
+ * model can land outside the recipe container.
7
+ *
8
+ * Skips `/api/pull` when the model is already listed on `/api/tags`.
9
+ */
10
+
11
+ /** Fetch contract (injectable for tests) — call signature only, not Bun's `preconnect`. */
12
+ export type OllamaFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
13
+
14
+ /** Options for {@link ensureOllamaModel}. */
15
+ export interface EnsureOllamaModelOptions {
16
+ /** Base URL of the container API, e.g. `http://127.0.0.1:11434`. */
17
+ readonly url: string;
18
+ /** Model tag to pull (e.g. `qwen3.5:9b`). */
19
+ readonly model: string;
20
+ readonly fetch?: OllamaFetch;
21
+ /** How long to wait for `/api/tags` before giving up (ms). Default 90s. */
22
+ readonly readyTimeoutMs?: number;
23
+ /** Overall pull deadline (ms). Default 20 minutes. */
24
+ readonly pullTimeoutMs?: number;
25
+ /** Progress / status lines (e.g. `oke: …`). */
26
+ readonly onStatus?: (line: string) => void;
27
+ }
28
+
29
+ /** Fail-loud pull / readiness error. */
30
+ export class OllamaPullError extends Error {
31
+ constructor(message: string) {
32
+ super(message);
33
+ this.name = "OllamaPullError";
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Normalize a base URL (trim trailing slash).
39
+ *
40
+ * @param url - Raw base URL
41
+ */
42
+ export function normalizeOllamaPullUrl(url: string): string {
43
+ return url.replace(/\/+$/, "");
44
+ }
45
+
46
+ /**
47
+ * Whether an installed tag covers the requested model id.
48
+ *
49
+ * @param want - Requested id (e.g. `gemma4:e4b`)
50
+ * @param installed - Names from `/api/tags`
51
+ */
52
+ export function ollamaTagsInclude(want: string, installed: readonly string[]): boolean {
53
+ const id = want.trim();
54
+ if (!id) return false;
55
+ const base = id.split(":")[0] ?? id;
56
+ return installed.some((name) => {
57
+ if (name === id) return true;
58
+ if (name.startsWith(`${id}-`)) return true;
59
+ if (name.startsWith(`${base}:`) && id.startsWith(`${base}:`)) {
60
+ const instTag = name.slice(base.length + 1);
61
+ const wantTag = id.slice(base.length + 1);
62
+ return (
63
+ instTag === wantTag ||
64
+ instTag.startsWith(`${wantTag}-`) ||
65
+ wantTag.startsWith(instTag) ||
66
+ (instTag === "latest" && wantTag === "latest")
67
+ );
68
+ }
69
+ // bare name matches `name:latest`
70
+ if (name === `${id}:latest` || id === `${name}:latest`) return true;
71
+ return false;
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Parse model names from an `/api/tags` JSON body.
77
+ *
78
+ * @param json - Parsed response
79
+ */
80
+ export function parseOllamaTagsNames(json: unknown): string[] {
81
+ if (!json || typeof json !== "object") return [];
82
+ const models = (json as { models?: unknown }).models;
83
+ if (!Array.isArray(models)) return [];
84
+ return models
85
+ .map((m) =>
86
+ m && typeof m === "object" && typeof (m as { name?: unknown }).name === "string"
87
+ ? (m as { name: string }).name
88
+ : null,
89
+ )
90
+ .filter((n): n is string => typeof n === "string" && n.length > 0);
91
+ }
92
+
93
+ /**
94
+ * Wait until `GET /api/tags` succeeds; skip pull when the model is already
95
+ * present; otherwise `POST /api/pull` with streaming progress.
96
+ *
97
+ * @param opts - Target URL + model (+ optional injectable fetch)
98
+ */
99
+ export async function ensureOllamaModel(opts: EnsureOllamaModelOptions): Promise<void> {
100
+ const fetchFn = opts.fetch ?? globalThis.fetch;
101
+ const base = normalizeOllamaPullUrl(opts.url);
102
+ const model = opts.model.trim();
103
+ const status = opts.onStatus ?? (() => {});
104
+ if (!model) throw new OllamaPullError("ollama pull: model id is empty");
105
+
106
+ const readyDeadline = Date.now() + (opts.readyTimeoutMs ?? 90_000);
107
+ let tagsBody: unknown = null;
108
+ while (Date.now() < readyDeadline) {
109
+ try {
110
+ const res = await fetchFn(`${base}/api/tags`, { method: "GET" });
111
+ if (res.ok) {
112
+ tagsBody = await res.json().catch(() => null);
113
+ break;
114
+ }
115
+ } catch {
116
+ // still starting
117
+ }
118
+ await Bun.sleep(500);
119
+ }
120
+ if (tagsBody === null) {
121
+ throw new OllamaPullError(`ollama pull: unreachable at ${base}/api/tags`);
122
+ }
123
+
124
+ const installed = parseOllamaTagsNames(tagsBody);
125
+ if (ollamaTagsInclude(model, installed)) {
126
+ status(`oke: Ollama already has ${model} at ${base} — skip pull`);
127
+ return;
128
+ }
129
+
130
+ status(`oke: pulling ${model} into Ollama at ${base}…`);
131
+
132
+ const pullDeadline = Date.now() + (opts.pullTimeoutMs ?? 20 * 60_000);
133
+ const controller = new AbortController();
134
+ const timer = setTimeout(() => controller.abort(), Math.max(1, pullDeadline - Date.now()));
135
+ try {
136
+ const res = await fetchFn(`${base}/api/pull`, {
137
+ method: "POST",
138
+ headers: { "content-type": "application/json" },
139
+ body: JSON.stringify({ model, stream: true }),
140
+ signal: controller.signal,
141
+ });
142
+ if (!res.ok) {
143
+ const text = await res.text().catch(() => "");
144
+ throw new OllamaPullError(
145
+ `ollama pull: POST ${base}/api/pull → ${res.status}${text ? ` ${text.slice(0, 200)}` : ""}`,
146
+ );
147
+ }
148
+ await consumeOllamaPullStream(res, status);
149
+ } catch (err) {
150
+ if (err instanceof OllamaPullError) throw err;
151
+ const msg = err instanceof Error ? err.message : String(err);
152
+ throw new OllamaPullError(`ollama pull: POST ${base}/api/pull failed — ${msg}`);
153
+ } finally {
154
+ clearTimeout(timer);
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Read NDJSON pull progress; surface concise status updates.
160
+ *
161
+ * @param res - Streaming pull response
162
+ * @param status - Status writer
163
+ */
164
+ async function consumeOllamaPullStream(
165
+ res: Response,
166
+ status: (line: string) => void,
167
+ ): Promise<void> {
168
+ // Non-body responses (mocked tests) — treat as complete.
169
+ if (!res.body) {
170
+ await res.arrayBuffer().catch(() => undefined);
171
+ return;
172
+ }
173
+
174
+ const reader = res.body.getReader();
175
+ const decoder = new TextDecoder();
176
+ let buf = "";
177
+ const state: PullStreamState = { lastStatus: "", lastPct: -1 };
178
+
179
+ for (;;) {
180
+ const { done, value } = await reader.read();
181
+ if (done) break;
182
+ buf += decoder.decode(value, { stream: true });
183
+ const lines = buf.split("\n");
184
+ buf = lines.pop() ?? "";
185
+ for (const line of lines) {
186
+ handlePullLine(line, status, state);
187
+ }
188
+ }
189
+ buf += decoder.decode();
190
+ if (buf.trim()) {
191
+ handlePullLine(buf, status, state);
192
+ }
193
+ }
194
+
195
+ type PullStreamState = { lastStatus: string; lastPct: number };
196
+
197
+ function handlePullLine(
198
+ line: string,
199
+ status: (line: string) => void,
200
+ state: PullStreamState,
201
+ ): void {
202
+ const trimmed = line.trim();
203
+ if (!trimmed) return;
204
+ let row: {
205
+ status?: string;
206
+ error?: string;
207
+ completed?: number;
208
+ total?: number;
209
+ };
210
+ try {
211
+ row = JSON.parse(trimmed) as typeof row;
212
+ } catch {
213
+ return;
214
+ }
215
+ if (row.error) {
216
+ throw new OllamaPullError(`ollama pull: ${row.error}`);
217
+ }
218
+ const st = row.status?.trim() ?? "";
219
+ if (typeof row.completed === "number" && typeof row.total === "number" && row.total > 0) {
220
+ const pct = Math.min(100, Math.floor((100 * row.completed) / row.total));
221
+ if (pct !== state.lastPct && (pct === 100 || pct - state.lastPct >= 5)) {
222
+ state.lastPct = pct;
223
+ status(`oke: Ollama pull ${pct}%${st ? ` (${st})` : ""}`);
224
+ }
225
+ return;
226
+ }
227
+ if (st && st !== state.lastStatus) {
228
+ state.lastStatus = st;
229
+ if (/^pulling\s+[a-f0-9]{12}/i.test(st)) return;
230
+ status(`oke: Ollama ${st}`);
231
+ }
232
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * CockroachDB image recipe — self-hosted single-node Postgres-wire SQL.
3
+ *
4
+ * Driver id stays `postgres`. Official image: `cockroachdb/cockroach`.
5
+ */
6
+
7
+ import { cockroachEnv, cockroachHealth } from "../helpers.ts";
8
+ import type { ImageRecipe } from "../types.ts";
9
+
10
+ /** CockroachDB — SQL on 26257; DB Console on 8080. */
11
+ export const cockroach: ImageRecipe = {
12
+ id: "cockroach",
13
+ port: 26257,
14
+ match: (i) => /cockroach/i.test(i),
15
+ apply: (s) => ({
16
+ environment: cockroachEnv(s),
17
+ command: ["start-single-node", "--accept-sql-without-tls"],
18
+ healthcheck: cockroachHealth,
19
+ volumes: [`${s.serviceName}-data:/cockroach/cockroach-data`],
20
+ extraPorts: [{ host: 8080, container: 8080 }],
21
+ }),
22
+ url: (_s, c) =>
23
+ `postgres://${c.user}:${encodeURIComponent(c.password)}@${c.host}:${c.port}/${c.database}?sslmode=require`,
24
+ };
@@ -4,7 +4,9 @@
4
4
 
5
5
  import type { ImageRecipe } from "../types.ts";
6
6
  import { caddy } from "./caddy.ts";
7
+ import { cockroach } from "./cockroach.ts";
7
8
  import { dragonfly } from "./dragonfly.ts";
9
+ import { llamaCpp } from "./llama-cpp.ts";
8
10
  import { mailpit } from "./mailpit.ts";
9
11
  import { meilisearch } from "./meilisearch.ts";
10
12
  import { ollama } from "./ollama.ts";
@@ -13,11 +15,20 @@ import { pgdog } from "./pgdog.ts";
13
15
  import { postgres } from "./postgres.ts";
14
16
  import { redis } from "./redis.ts";
15
17
  import { rustfs } from "./rustfs.ts";
18
+ import { sglang } from "./sglang.ts";
19
+ import { supabase } from "./supabase.ts";
20
+ import { timescale } from "./timescale.ts";
16
21
  import { traefik } from "./traefik.ts";
17
22
  import { valkey } from "./valkey.ts";
23
+ import { vllm } from "./vllm.ts";
24
+ import { yugabyte } from "./yugabyte.ts";
18
25
 
19
26
  /** Default recipe catalogue — more specific image matches before protocol peers. */
20
27
  export const builtinRecipes: readonly ImageRecipe[] = [
28
+ cockroach,
29
+ yugabyte,
30
+ timescale,
31
+ supabase,
21
32
  postgres,
22
33
  pgdog,
23
34
  dragonfly,
@@ -27,6 +38,9 @@ export const builtinRecipes: readonly ImageRecipe[] = [
27
38
  rustfs,
28
39
  openbao,
29
40
  meilisearch,
41
+ llamaCpp,
42
+ vllm,
43
+ sglang,
30
44
  ollama,
31
45
  caddy,
32
46
  traefik,
@@ -34,7 +48,9 @@ export const builtinRecipes: readonly ImageRecipe[] = [
34
48
 
35
49
  export {
36
50
  caddy,
51
+ cockroach,
37
52
  dragonfly,
53
+ llamaCpp,
38
54
  mailpit,
39
55
  meilisearch,
40
56
  ollama,
@@ -43,10 +59,26 @@ export {
43
59
  postgres,
44
60
  redis,
45
61
  rustfs,
62
+ sglang,
63
+ supabase,
64
+ timescale,
46
65
  traefik,
47
66
  valkey,
67
+ vllm,
68
+ yugabyte,
48
69
  };
49
70
 
71
+ export {
72
+ buildLlamaCppEntrypoint,
73
+ LLAMA_CPP_ENTRYPOINT_FILE,
74
+ LLAMA_CPP_ENTRYPOINT_MOUNT,
75
+ LLAMA_CPP_IMAGE,
76
+ LLAMA_CPP_MIN_SAFE_BUILD,
77
+ } from "./llama-cpp.ts";
78
+ export { OLLAMA_IMAGE, OLLAMA_MIN_SAFE_VERSION } from "./ollama.ts";
79
+ export { SGLANG_IMAGE } from "./sglang.ts";
80
+ export { VLLM_IMAGE } from "./vllm.ts";
81
+
50
82
  /**
51
83
  * Resolve the recipe for an image reference.
52
84
  *
@@ -0,0 +1,273 @@
1
+ /**
2
+ * llama.cpp image recipe — default local AI (`ghcr.io/ggml-org/llama.cpp`).
3
+ *
4
+ * OpenAI-compatible `llama-server` on 8080. Pin ≥ {@link LLAMA_CPP_MIN_SAFE_BUILD}
5
+ * (CVE-2026-27940 / CVE-2026-33298 floor). Host publish is loopback-only;
6
+ * never publish RPC. Models via Docker Hub `ai/` — do not load arbitrary
7
+ * untrusted GGUF files.
8
+ *
9
+ * b10290+ issues this recipe works around:
10
+ * 1. Bare `--docker-repo` / router `--models-preset` children re-enter empty
11
+ * router mode (`is_router_server` only checks `-m` / `-hf`) → forever
12
+ * `loading`.
13
+ * 2. `llama download -dr` only accepts `application/vnd.docker.ai.gguf.v3`;
14
+ * newer Hub models (e.g. gemma4) ship `vnd.cncf.model.weight.v1.raw`.
15
+ *
16
+ * Entrypoint: Hub-pull (native download, then CNCF-aware fallback) → serve
17
+ * single-model with `-m` + `--alias`.
18
+ */
19
+
20
+ import type { ImageRecipe } from "../types.ts";
21
+
22
+ /** Minimum safe llama.cpp build (GGUF parser CVEs through CVE-2026-27940). */
23
+ export const LLAMA_CPP_MIN_SAFE_BUILD = 8146;
24
+
25
+ /** Pinned default image — verified ≥ {@link LLAMA_CPP_MIN_SAFE_BUILD}; never `latest`. */
26
+ export const LLAMA_CPP_IMAGE = "ghcr.io/ggml-org/llama.cpp:server-b10290";
27
+
28
+ /** Compose-relative entrypoint that downloads then serves one Hub model. */
29
+ export const LLAMA_CPP_ENTRYPOINT_FILE = "llama-entrypoint.py";
30
+
31
+ /** In-container path for {@link LLAMA_CPP_ENTRYPOINT_FILE}. */
32
+ export const LLAMA_CPP_ENTRYPOINT_MOUNT = "/oke/llama-entrypoint.py";
33
+
34
+ /**
35
+ * Python entrypoint: pull curated Docker Hub `ai/` GGUF into the cache volume,
36
+ * then `exec` `llama-server -m <gguf> --alias <id>` (single-model, not router).
37
+ */
38
+ export function buildLlamaCppEntrypoint(): string {
39
+ return `#!/usr/bin/env python3
40
+ """OKE llama.cpp entrypoint — Hub-pull curated ai/ model, then serve single-model."""
41
+
42
+ from __future__ import annotations
43
+
44
+ import json
45
+ import os
46
+ import subprocess
47
+ import sys
48
+ import urllib.error
49
+ import urllib.parse
50
+ import urllib.request
51
+
52
+
53
+ def cache_dir() -> str:
54
+ return os.environ.get("LLAMA_CACHE", "/root/.cache/llama.cpp")
55
+
56
+
57
+ def parse_model(model: str) -> tuple[str, str]:
58
+ raw = model.strip() or "smollm2"
59
+ if "/" not in raw.split(":", 1)[0]:
60
+ raw = f"ai/{raw}"
61
+ if ":" in raw:
62
+ repo, tag = raw.rsplit(":", 1)
63
+ else:
64
+ repo, tag = raw, "latest"
65
+ return repo, tag
66
+
67
+
68
+ def expected_gguf(repo: str, tag: str) -> str:
69
+ return f"{repo.replace('/', '_')}_{tag}.gguf"
70
+
71
+
72
+ def existing_gguf(path: str) -> str | None:
73
+ if os.path.isfile(path) and os.path.getsize(path) > 0:
74
+ return path
75
+ return None
76
+
77
+
78
+ def llama_download(model: str) -> int:
79
+ return subprocess.call(
80
+ ["/app/llama", "download", "-dr", model, "--no-mmproj"],
81
+ stdout=sys.stdout,
82
+ stderr=sys.stderr,
83
+ )
84
+
85
+
86
+ def registry_token(repo: str) -> str:
87
+ scope = urllib.parse.quote(f"repository:{repo}:pull", safe="")
88
+ url = f"https://auth.docker.io/token?service=registry.docker.io&scope={scope}"
89
+ with urllib.request.urlopen(url, timeout=60) as res:
90
+ return json.load(res)["token"]
91
+
92
+
93
+ def fetch_manifest(repo: str, tag: str, token: str) -> dict:
94
+ req = urllib.request.Request(
95
+ f"https://registry-1.docker.io/v2/{repo}/manifests/{tag}",
96
+ headers={
97
+ "Authorization": f"Bearer {token}",
98
+ "Accept": (
99
+ "application/vnd.oci.image.manifest.v1+json,"
100
+ "application/vnd.docker.distribution.manifest.v2+json,"
101
+ "application/vnd.oci.image.index.v1+json,"
102
+ "application/vnd.docker.distribution.manifest.list.v2+json"
103
+ ),
104
+ },
105
+ )
106
+ with urllib.request.urlopen(req, timeout=60) as res:
107
+ return json.load(res)
108
+
109
+
110
+ def pick_gguf_layer(manifest: dict) -> tuple[str, str]:
111
+ """Return (digest, suggested_filename) for the primary GGUF weight layer."""
112
+ layers = manifest.get("layers") or []
113
+ candidates: list[tuple[str, str, int]] = []
114
+ for layer in layers:
115
+ media = str(layer.get("mediaType") or "")
116
+ digest = str(layer.get("digest") or "")
117
+ ann = layer.get("annotations") or {}
118
+ filepath = str(ann.get("org.cncf.model.filepath") or "")
119
+ name = filepath.split("/")[-1] if filepath else ""
120
+ is_gguf = (
121
+ media == "application/vnd.docker.ai.gguf.v3"
122
+ or "gguf" in media.lower()
123
+ or name.lower().endswith(".gguf")
124
+ )
125
+ if not is_gguf or not digest:
126
+ continue
127
+ if name.lower().startswith("mmproj"):
128
+ continue
129
+ size = int(layer.get("size") or 0)
130
+ candidates.append((digest, name or "model.gguf", size))
131
+ if not candidates:
132
+ raise RuntimeError("No GGUF weight layer in Docker / CNCF model manifest")
133
+ # Prefer the largest non-mmproj GGUF (main weights vs projectors).
134
+ candidates.sort(key=lambda c: c[2], reverse=True)
135
+ digest, name, _ = candidates[0]
136
+ return digest, name
137
+
138
+
139
+ def download_blob(repo: str, digest: str, token: str, dest: str) -> None:
140
+ url = f"https://registry-1.docker.io/v2/{repo}/blobs/{digest}"
141
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
142
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
143
+ partial = f"{dest}.partial"
144
+ print(f"oke ai: downloading {digest} → {dest}", flush=True)
145
+ with urllib.request.urlopen(req, timeout=600) as res, open(partial, "wb") as out:
146
+ total = int(res.headers.get("Content-Length") or 0)
147
+ done = 0
148
+ last_pct = -1
149
+ while True:
150
+ chunk = res.read(1024 * 1024)
151
+ if not chunk:
152
+ break
153
+ out.write(chunk)
154
+ done += len(chunk)
155
+ if total:
156
+ pct = (100 * done) // total
157
+ if pct >= last_pct + 5 or done == total:
158
+ last_pct = pct
159
+ print(
160
+ f"oke ai: download {pct}% ({done // (1024 * 1024)} / {total // (1024 * 1024)} MiB)",
161
+ flush=True,
162
+ )
163
+ os.replace(partial, dest)
164
+
165
+
166
+ def hub_pull(repo: str, tag: str, dest: str) -> None:
167
+ token = registry_token(repo)
168
+ manifest = fetch_manifest(repo, tag, token)
169
+ # Index → pick first linux manifest if present.
170
+ if "manifests" in manifest:
171
+ digests = [m.get("digest") for m in manifest["manifests"] if m.get("digest")]
172
+ if not digests:
173
+ raise RuntimeError("Empty OCI index for model")
174
+ req = urllib.request.Request(
175
+ f"https://registry-1.docker.io/v2/{repo}/manifests/{digests[0]}",
176
+ headers={
177
+ "Authorization": f"Bearer {token}",
178
+ "Accept": "application/vnd.oci.image.manifest.v1+json,application/vnd.docker.distribution.manifest.v2+json",
179
+ },
180
+ )
181
+ with urllib.request.urlopen(req, timeout=60) as res:
182
+ manifest = json.load(res)
183
+ digest, _name = pick_gguf_layer(manifest)
184
+ download_blob(repo, digest, token, dest)
185
+
186
+
187
+ def ensure_model(model: str) -> str:
188
+ repo, tag = parse_model(model)
189
+ # llama download -dr expects id without forced ai/ prefix when using default org.
190
+ dr = model.strip() or "smollm2"
191
+ if dr.startswith("ai/"):
192
+ dr = dr[3:]
193
+ dest = os.path.join(cache_dir(), expected_gguf(repo, tag))
194
+ hit = existing_gguf(dest)
195
+ if hit:
196
+ print(f"oke ai: using cached {hit}", flush=True)
197
+ return hit
198
+
199
+ print(f"oke ai: ensuring Docker Hub model '{dr}' is cached…", flush=True)
200
+ rc = llama_download(dr)
201
+ hit = existing_gguf(dest)
202
+ if hit:
203
+ return hit
204
+ # Native downloader may write the same basename; also accept exact path only.
205
+ print(
206
+ f"oke ai: native download exit {rc}; trying CNCF / Hub registry pull…",
207
+ flush=True,
208
+ )
209
+ try:
210
+ hub_pull(repo, tag, dest)
211
+ except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, RuntimeError, KeyError, ValueError) as err:
212
+ print(f"oke ai: failed to download model '{dr}': {err}", file=sys.stderr, flush=True)
213
+ sys.exit(1)
214
+ hit = existing_gguf(dest)
215
+ if not hit:
216
+ print(f"oke ai: download produced no file at {dest}", file=sys.stderr, flush=True)
217
+ sys.exit(1)
218
+ return hit
219
+
220
+
221
+ def main() -> None:
222
+ model = (os.environ.get("OKE_AI_MODEL") or "smollm2").strip() or "smollm2"
223
+ gguf = ensure_model(model)
224
+ print(f"oke ai: serving {model} from {gguf}", flush=True)
225
+ os.execv(
226
+ "/app/llama-server",
227
+ [
228
+ "/app/llama-server",
229
+ "--host",
230
+ "0.0.0.0",
231
+ "--port",
232
+ "8080",
233
+ "--model",
234
+ gguf,
235
+ "--alias",
236
+ model,
237
+ ],
238
+ )
239
+
240
+
241
+ if __name__ == "__main__":
242
+ main()
243
+ `;
244
+ }
245
+
246
+ /** llama-server — OpenAI-compatible HTTP on 8080; loopback publish only. */
247
+ export const llamaCpp: ImageRecipe = {
248
+ id: "llama-cpp",
249
+ port: 8080,
250
+ match: (i) => /llama\.cpp|llamacpp/i.test(i),
251
+ apply: (s) => ({
252
+ // OKE_AI_MODEL comes from compose `env_file` (`.env.docker`). Do not
253
+ // re-declare it as `${OKE_AI_MODEL:-smollm2}` here — Compose interpolates
254
+ // that from the *host* shell and can override the stack file with the
255
+ // default. Never set LLAMA_ARG_MODELS_PRESET / LLAMA_ARG_DOCKER_REPO
256
+ // (router children inherit `LLAMA_ARG_*` and can OOM).
257
+ entrypoint: ["/usr/bin/python3", LLAMA_CPP_ENTRYPOINT_MOUNT],
258
+ volumes: [
259
+ `${s.serviceName}-models:/root/.cache`,
260
+ `./${LLAMA_CPP_ENTRYPOINT_FILE}:${LLAMA_CPP_ENTRYPOINT_MOUNT}:ro`,
261
+ ],
262
+ publishBind: "127.0.0.1",
263
+ healthcheck: {
264
+ test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/health >/dev/null || exit 1"],
265
+ interval: "5s",
266
+ timeout: "5s",
267
+ retries: 24,
268
+ // First boot may download a multi‑GB Hub model (CNCF pull) into cache.
269
+ start_period: "900s",
270
+ },
271
+ }),
272
+ url: (_s, c) => `http://${c.host}:${c.port}/v1`,
273
+ };
@@ -1,47 +1,43 @@
1
1
  /**
2
2
  * Ollama image recipe — local model server (`ollama/ollama`).
3
3
  *
4
- * Serves on 11434 and pulls the configured model after serve is up.
5
- * `OKE_AI_MODEL` defaults to `qwen3.5:9b` (balanced local-dev starting point —
6
- * override freely); models persist on a named volume under `/root/.ollama`.
4
+ * Fully supported alternative to the default llama.cpp recipe. Pin
5
+ * {@link OLLAMA_MIN_SAFE_VERSION} (CVE-2026-7482 floor). Host publish is
6
+ * loopback-only never expose `:11434` on `0.0.0.0`. Load models only from
7
+ * Ollama's library; do not feed arbitrary untrusted GGUF into `/api/create`.
8
+ *
9
+ * Model pull is **not** done here: after compose is up,
10
+ * {@link ensureOllamaModel} POSTs `/api/pull` to this container's loopback URL.
7
11
  */
8
12
 
9
13
  import type { ImageRecipe } from "../types.ts";
10
14
 
11
- /**
12
- * Start serve, pull configured model, keep serve in foreground.
13
- *
14
- * Shell `$` must be written as `$$` so Compose does not interpolate `pid` /
15
- * `i` as project env (same pattern as the redis recipe).
16
- */
17
- const OLLAMA_BOOT = [
18
- "set -e",
19
- "/bin/ollama serve &",
20
- "pid=$$!",
21
- 'i=0; until /bin/ollama list >/dev/null 2>&1; do i=$$((i+1)); [ "$$i" -lt 90 ] || exit 1; sleep 1; done',
22
- '/bin/ollama pull "$${OKE_AI_MODEL:-qwen3.5:9b}"',
23
- "wait $$pid",
24
- ].join("; ");
15
+ /** Minimum safe Ollama release (CVE-2026-7482 — own Go GGUF loader). */
16
+ export const OLLAMA_MIN_SAFE_VERSION = "0.17.1";
17
+
18
+ /** Pinned default Ollama image verified {@link OLLAMA_MIN_SAFE_VERSION}; never `latest`. */
19
+ export const OLLAMA_IMAGE = "ollama/ollama:0.32.6";
25
20
 
26
- /** Ollama local model server. API on 11434. */
21
+ /** Ollama local model server. API on 11434; loopback publish only. */
27
22
  export const ollama: ImageRecipe = {
28
23
  id: "ollama",
29
24
  port: 11434,
30
- match: (i) => /ollama/i.test(i),
25
+ match: (i) => /(?:^|\/)ollama(?:[:@/]|$)/i.test(i),
31
26
  apply: (s) => ({
32
27
  environment: {
28
+ // Listen on all interfaces *inside* the container (Docker networking).
29
+ // Host publish uses publishBind — never 0.0.0.0 on the host.
33
30
  OLLAMA_HOST: "0.0.0.0:11434",
34
31
  OKE_AI_MODEL: "${OKE_AI_MODEL:-qwen3.5:9b}",
35
32
  },
36
- entrypoint: ["/bin/sh", "-c"],
37
- command: [OLLAMA_BOOT],
38
33
  volumes: [`${s.serviceName}-data:/root/.ollama`],
34
+ publishBind: "127.0.0.1",
39
35
  healthcheck: {
40
36
  test: ["CMD-SHELL", "/bin/ollama list >/dev/null 2>&1 || exit 1"],
41
37
  interval: "5s",
42
38
  timeout: "5s",
43
- retries: 60,
44
- start_period: "20s",
39
+ retries: 24,
40
+ start_period: "10s",
45
41
  },
46
42
  }),
47
43
  url: (_s, c) => `http://${c.host}:${c.port}`,
@@ -20,8 +20,11 @@ export const PGDOG_BACKEND_SERVICE = "store-sql";
20
20
  export function buildPgDogToml(opts: {
21
21
  readonly database: string;
22
22
  readonly postgresHost?: string;
23
+ /** Backend container port (`store-sql` recipe port; default 5432). */
24
+ readonly postgresPort?: number;
23
25
  }): string {
24
26
  const host = opts.postgresHost ?? PGDOG_BACKEND_SERVICE;
27
+ const port = opts.postgresPort ?? 5432;
25
28
  const db = opts.database;
26
29
  return [
27
30
  "[general]",
@@ -32,7 +35,7 @@ export function buildPgDogToml(opts: {
32
35
  "[[databases]]",
33
36
  `name = ${tomlString(db)}`,
34
37
  `host = ${tomlString(host)}`,
35
- "port = 5432",
38
+ `port = ${port}`,
36
39
  `database_name = ${tomlString(db)}`,
37
40
  "",
38
41
  ].join("\n");