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,88 @@
1
+ /**
2
+ * Error-pattern aggregation over Runs (console §9.11).
3
+ *
4
+ * Reuses {@link groupByDimension} on `error` — no parallel tracking store.
5
+ */
6
+
7
+ import { groupByDimension } from "./group.ts";
8
+ import type { GroupAggregate, RunRecord } from "./types.ts";
9
+
10
+ /** Default lookback for "errors in the last hour". */
11
+ export const ERROR_WINDOW_1H_MS = 60 * 60 * 1000;
12
+
13
+ /**
14
+ * Runs that failed inside `[now - windowMs, now]`.
15
+ *
16
+ * @param runs - Population
17
+ * @param now - Epoch-ms
18
+ * @param windowMs - Lookback window
19
+ */
20
+ export function failedRunsInWindow(
21
+ runs: readonly RunRecord[],
22
+ now: number,
23
+ windowMs: number,
24
+ ): RunRecord[] {
25
+ const from = now - Math.max(0, windowMs);
26
+ return runs.filter(
27
+ (r) => r.startedAt >= from && r.startedAt <= now && r.error != null && r.error !== "",
28
+ );
29
+ }
30
+
31
+ /**
32
+ * Aggregate "this error occurred N times in the window".
33
+ *
34
+ * @param runs - Population
35
+ * @param now - Epoch-ms
36
+ * @param windowMs - Lookback (default 1h)
37
+ */
38
+ export function errorPatterns(
39
+ runs: readonly RunRecord[],
40
+ now: number,
41
+ windowMs: number = ERROR_WINDOW_1H_MS,
42
+ ): GroupAggregate[] {
43
+ return groupByDimension(failedRunsInWindow(runs, now, windowMs), "error");
44
+ }
45
+
46
+ /**
47
+ * Filter a population to runs started at or after `sinceMs`.
48
+ *
49
+ * @param runs - Population
50
+ * @param sinceMs - Inclusive lower bound (epoch-ms); omitted = no filter
51
+ */
52
+ export function filterRunsSince(
53
+ runs: readonly RunRecord[],
54
+ sinceMs: number | undefined,
55
+ ): RunRecord[] {
56
+ if (sinceMs === undefined) return [...runs];
57
+ return runs.filter((r) => r.startedAt >= sinceMs);
58
+ }
59
+
60
+ /**
61
+ * Parse a lookback duration (`1h`, `5m`, `30m`, `24h`) or epoch-ms string.
62
+ *
63
+ * @param raw - Search param value
64
+ */
65
+ export function parseSinceWindowMs(raw: string | undefined): number | undefined {
66
+ if (!raw || !raw.trim()) return undefined;
67
+ const trimmed = raw.trim();
68
+ if (/^\d+$/.test(trimmed)) {
69
+ const n = Number(trimmed);
70
+ return Number.isFinite(n) && n > 0 ? n : undefined;
71
+ }
72
+ const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/i.exec(trimmed);
73
+ if (!match) return undefined;
74
+ const n = Number(match[1]);
75
+ if (!Number.isFinite(n) || n <= 0) return undefined;
76
+ const unit = match[2]!.toLowerCase();
77
+ const mult =
78
+ unit === "ms"
79
+ ? 1
80
+ : unit === "s"
81
+ ? 1_000
82
+ : unit === "m"
83
+ ? 60_000
84
+ : unit === "h"
85
+ ? 3_600_000
86
+ : 86_400_000;
87
+ return Math.round(n * mult);
88
+ }
@@ -5,6 +5,13 @@
5
5
  * without mounting the full SPA.
6
6
  */
7
7
 
8
+ export {
9
+ ERROR_WINDOW_1H_MS,
10
+ errorPatterns,
11
+ failedRunsInWindow,
12
+ filterRunsSince,
13
+ parseSinceWindowMs,
14
+ } from "./errors.ts";
8
15
  export { explainDurationOutliers, type OutlierFinding } from "./explain.ts";
9
16
  export { RUNS_CHAIN_FIXTURE, RUNS_FIXTURE_T0, runsOutlierFixture } from "./fixture.ts";
10
17
  export { discoverDimensions, groupByDimension } from "./group.ts";
@@ -38,6 +45,7 @@ export {
38
45
  serializeRunsSearch,
39
46
  setDurationRange,
40
47
  setGroup,
48
+ setSince,
41
49
  setWhere,
42
50
  type RunsSearch,
43
51
  } from "./search.ts";
@@ -20,6 +20,11 @@ export const RunsSearchSchema = z.object({
20
20
  durMin: z.coerce.number().optional(),
21
21
  /** Duration brush upper bound (ms). */
22
22
  durMax: z.coerce.number().optional(),
23
+ /**
24
+ * Lookback window: duration (`1h`, `5m`, `24h`) or epoch-ms lower bound.
25
+ * Filters the population by `startedAt`.
26
+ */
27
+ since: z.string().optional(),
23
28
  });
24
29
 
25
30
  /** Parsed, typed Runs search state. */
@@ -48,9 +53,20 @@ export function serializeRunsSearch(search: RunsSearch): Record<string, string |
48
53
  if (search.run) out.run = search.run;
49
54
  if (search.durMin !== undefined) out.durMin = search.durMin;
50
55
  if (search.durMax !== undefined) out.durMax = search.durMax;
56
+ if (search.since) out.since = search.since;
51
57
  return out;
52
58
  }
53
59
 
60
+ /**
61
+ * Set or clear the lookback window (`1h`, `5m`, …).
62
+ *
63
+ * @param prev - Current search
64
+ * @param since - Window string or null to clear
65
+ */
66
+ export function setSince(prev: RunsSearch, since: string | null): RunsSearch {
67
+ return { ...prev, since: since ?? undefined };
68
+ }
69
+
54
70
  /**
55
71
  * Dimension query derived from search.
56
72
  *
@@ -13,18 +13,22 @@ import {
13
13
  discoverDimensions,
14
14
  durationHistogram,
15
15
  durationRangeOf,
16
+ errorPatterns,
16
17
  explainDurationOutliers,
17
18
  filterRuns,
19
+ filterRunsSince,
18
20
  formatDurationMs,
19
21
  groupByDimension,
20
22
  normalizeRange,
21
23
  openRun,
22
24
  parseDimensionQuery,
25
+ parseSinceWindowMs,
23
26
  removeClause,
24
27
  rowToRun,
25
28
  serializeRunsSearch,
26
29
  setDurationRange,
27
30
  setGroup,
31
+ setSince,
28
32
  setWhere,
29
33
  rootIdOf,
30
34
  shouldOfferTracesLink,
@@ -63,7 +67,16 @@ export function RunsPanel() {
63
67
 
64
68
  const allRuns = runsQuery.data ?? [];
65
69
  const query = dimensionQueryOf(search);
66
- const filtered = useMemo(() => filterRuns(allRuns, query), [allRuns, query]);
70
+ const sinceParam = search.since ?? "1h";
71
+ const windowMs = parseSinceWindowMs(sinceParam === "" ? undefined : sinceParam);
72
+ const sinceMs =
73
+ windowMs === undefined
74
+ ? undefined
75
+ : /^\d+$/.test(sinceParam.trim())
76
+ ? windowMs
77
+ : Date.now() - windowMs;
78
+ const windowed = useMemo(() => filterRunsSince(allRuns, sinceMs), [allRuns, sinceMs]);
79
+ const filtered = useMemo(() => filterRuns(windowed, query), [windowed, query]);
67
80
  const range = durationRangeOf(search);
68
81
  const buckets = useMemo(() => durationHistogram(filtered), [filtered]);
69
82
  const maxBucket = Math.max(1, ...buckets.map((b) => b.count));
@@ -71,6 +84,10 @@ export function RunsPanel() {
71
84
  () => (search.group ? groupByDimension(filtered, search.group) : []),
72
85
  [filtered, search.group],
73
86
  );
87
+ const patterns = useMemo(
88
+ () => errorPatterns(windowed, Date.now(), windowMs ?? 60 * 60 * 1000),
89
+ [windowed, windowMs],
90
+ );
74
91
  const findings = useMemo(
75
92
  () => (range ? explainDurationOutliers(filtered, range) : []),
76
93
  [filtered, range],
@@ -89,6 +106,23 @@ export function RunsPanel() {
89
106
  </p>
90
107
  </div>
91
108
  <QueryBuilder search={search} dimensions={dimensions} onChange={setSearch} />
109
+ <label className="flex flex-col gap-1 text-sm">
110
+ <span className="text-[var(--oke-muted)]">Since</span>
111
+ <select
112
+ aria-label="Lookback window"
113
+ className="min-h-8 border border-[var(--oke-line)] bg-transparent px-2 text-sm"
114
+ value={sinceParam}
115
+ onChange={(e) => {
116
+ const v = e.target.value;
117
+ setSearch(setSince(search, v === "" ? "" : v));
118
+ }}
119
+ >
120
+ <option value="5m">Last 5m</option>
121
+ <option value="1h">Last 1h</option>
122
+ <option value="24h">Last 24h</option>
123
+ <option value="">All time</option>
124
+ </select>
125
+ </label>
92
126
  <label className="flex flex-col gap-1 text-sm">
93
127
  <span className="text-[var(--oke-muted)]">Group by</span>
94
128
  <select
@@ -162,6 +196,43 @@ export function RunsPanel() {
162
196
  </section>
163
197
  ) : null}
164
198
 
199
+ <section aria-label="Error patterns" className="mb-8">
200
+ <h2 className="mb-2 text-sm font-medium">Error patterns</h2>
201
+ <p className="mb-3 text-xs text-[var(--oke-muted)]">
202
+ Failed runs in the selected window, grouped by error code.
203
+ </p>
204
+ {patterns.length === 0 ? (
205
+ <p className="text-sm text-[var(--oke-muted)]">No errors in this window.</p>
206
+ ) : (
207
+ <table className="w-full border-collapse text-left text-sm">
208
+ <thead>
209
+ <tr className="border-b border-[var(--oke-line)] text-[var(--oke-muted)]">
210
+ <th scope="col" className="py-2 pr-3 font-medium">
211
+ Error
212
+ </th>
213
+ <th scope="col" className="py-2 pr-3 font-medium">
214
+ Count
215
+ </th>
216
+ <th scope="col" className="py-2 font-medium">
217
+ p99
218
+ </th>
219
+ </tr>
220
+ </thead>
221
+ <tbody className="divide-y divide-[var(--oke-line)]">
222
+ {patterns.map((p) => (
223
+ <tr key={p.key}>
224
+ <td className="py-2 pr-3 font-mono text-xs text-[var(--oke-danger)]">
225
+ {p.key}
226
+ </td>
227
+ <td className="py-2 pr-3">{p.count}</td>
228
+ <td className="py-2">{formatDurationMs(p.p99DurationMs)}</td>
229
+ </tr>
230
+ ))}
231
+ </tbody>
232
+ </table>
233
+ )}
234
+ </section>
235
+
165
236
  {search.group ? (
166
237
  <section aria-label="Group aggregates" className="mb-8">
167
238
  <h2 className="mb-2 text-sm font-medium">Group by {search.group}</h2>
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ aiModelIdsMatch,
4
+ formatAiModelStatusMessage,
5
+ normalizeAiProbeUrl,
6
+ probeAiModelStatus,
7
+ startAiModelWatch,
8
+ statusFromOllamaTags,
9
+ statusFromOpenAiModels,
10
+ } from "./ai-model-status.ts";
11
+
12
+ describe("ai-model-status", () => {
13
+ test("normalizeAiProbeUrl keeps a single /v1 for openai-compatible", () => {
14
+ expect(normalizeAiProbeUrl("http://127.0.0.1:8080", "openai-compatible")).toBe(
15
+ "http://127.0.0.1:8080/v1",
16
+ );
17
+ expect(normalizeAiProbeUrl("http://127.0.0.1:8080/v1/", "openai-compatible")).toBe(
18
+ "http://127.0.0.1:8080/v1",
19
+ );
20
+ expect(normalizeAiProbeUrl("http://127.0.0.1:11434/v1", "ollama")).toBe(
21
+ "http://127.0.0.1:11434",
22
+ );
23
+ });
24
+
25
+ test("aiModelIdsMatch tolerates llama.cpp quant aliasing", () => {
26
+ expect(aiModelIdsMatch("gemma4:e4b-q4_K_M", "gemma4:Q4_K_M")).toBe(true);
27
+ expect(aiModelIdsMatch("smollm2", "smollm2")).toBe(true);
28
+ expect(aiModelIdsMatch("gemma4:e4b-q4_K_M", "qwen3:8b")).toBe(false);
29
+ });
30
+
31
+ test("statusFromOpenAiModels maps loading → ready", () => {
32
+ const loading = statusFromOpenAiModels("gemma4:e4b-q4_K_M", {
33
+ data: [{ id: "gemma4:Q4_K_M", status: { value: "loading" } }],
34
+ });
35
+ expect(loading.phase).toBe("loading");
36
+ expect(loading.reportedId).toBe("gemma4:Q4_K_M");
37
+
38
+ const ready = statusFromOpenAiModels("smollm2", {
39
+ data: [{ id: "smollm2", status: { value: "ready" } }],
40
+ });
41
+ expect(ready.phase).toBe("ready");
42
+ });
43
+
44
+ test("statusFromOpenAiModels treats empty list as starting", () => {
45
+ expect(statusFromOpenAiModels("smollm2", { data: [] }).phase).toBe("starting");
46
+ });
47
+
48
+ test("statusFromOllamaTags ready when present", () => {
49
+ expect(statusFromOllamaTags("qwen3.5:9b", { models: [{ name: "qwen3.5:9b" }] }).phase).toBe(
50
+ "ready",
51
+ );
52
+ expect(statusFromOllamaTags("qwen3.5:9b", { models: [] }).phase).toBe("loading");
53
+ });
54
+
55
+ test("probeAiModelStatus uses injectable fetch", async () => {
56
+ const status = await probeAiModelStatus({
57
+ url: "http://127.0.0.1:9/v1",
58
+ model: "smollm2",
59
+ kind: "openai-compatible",
60
+ fetch: async () =>
61
+ new Response(JSON.stringify({ data: [{ id: "smollm2", status: { value: "ready" } }] }), {
62
+ status: 200,
63
+ headers: { "content-type": "application/json" },
64
+ }),
65
+ });
66
+ expect(status.phase).toBe("ready");
67
+ expect(formatAiModelStatusMessage(status)).toBe("AI smollm2 — ready");
68
+ });
69
+
70
+ test("startAiModelWatch stops on ready and cancels cleanly", async () => {
71
+ const messages: string[] = [];
72
+ let calls = 0;
73
+ const phases: string[] = [];
74
+ const stop = startAiModelWatch({
75
+ url: "http://127.0.0.1:9/v1",
76
+ model: "smollm2",
77
+ kind: "openai-compatible",
78
+ intervalMs: 1,
79
+ timeoutMs: 5_000,
80
+ onStatus: (m, status) => {
81
+ messages.push(m);
82
+ phases.push(status.phase);
83
+ },
84
+ sleep: async () => {},
85
+ fetch: async () => {
86
+ calls += 1;
87
+ const phase = calls < 2 ? "loading" : "ready";
88
+ return new Response(
89
+ JSON.stringify({ data: [{ id: "smollm2", status: { value: phase } }] }),
90
+ { status: 200, headers: { "content-type": "application/json" } },
91
+ );
92
+ },
93
+ });
94
+ await Bun.sleep(20);
95
+ expect(messages).toContain("AI smollm2 — loading…");
96
+ expect(messages).toContain("AI smollm2 — ready");
97
+ expect(phases).toContain("loading");
98
+ expect(phases).toContain("ready");
99
+ stop();
100
+ });
101
+ });
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Probe local AI servers for configured-model readiness (llama.cpp / Ollama /
3
+ * OpenAI-compatible). Used by `oke dev` to show model id + phase without
4
+ * blocking boot.
5
+ */
6
+
7
+ /** Normalized readiness phase for a configured model. */
8
+ export type AiModelPhase = "unreachable" | "starting" | "loading" | "ready" | "error";
9
+
10
+ /** Snapshot of one model’s readiness. */
11
+ export type AiModelStatus = {
12
+ /** Requested model id (`OKE_AI_MODEL`). */
13
+ readonly model: string;
14
+ readonly phase: AiModelPhase;
15
+ /** Optional short detail (error text, reported id). */
16
+ readonly detail?: string;
17
+ /** Id reported by the server when it differs from {@link model}. */
18
+ readonly reportedId?: string;
19
+ };
20
+
21
+ /** Which HTTP surface to probe. */
22
+ export type AiModelProbeKind = "openai-compatible" | "ollama";
23
+
24
+ /** Fetch contract (injectable for tests). */
25
+ export type AiModelFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
26
+
27
+ /** Options for {@link probeAiModelStatus}. */
28
+ export type ProbeAiModelStatusOptions = {
29
+ /** Base URL — with or without trailing `/v1` for OpenAI-compatible. */
30
+ readonly url: string;
31
+ readonly model: string;
32
+ readonly kind: AiModelProbeKind;
33
+ readonly fetch?: AiModelFetch;
34
+ };
35
+
36
+ /**
37
+ * Strip trailing slashes; for OpenAI-compatible keep a single `/v1` suffix.
38
+ *
39
+ * @param url - Raw base URL
40
+ * @param kind - Probe kind
41
+ */
42
+ export function normalizeAiProbeUrl(url: string, kind: AiModelProbeKind): string {
43
+ let base = url.trim().replace(/\/+$/, "");
44
+ if (kind === "openai-compatible") {
45
+ if (!base.endsWith("/v1")) base = `${base}/v1`;
46
+ } else if (base.endsWith("/v1")) {
47
+ base = base.slice(0, -3);
48
+ }
49
+ return base;
50
+ }
51
+
52
+ /**
53
+ * Whether a reported model id covers the requested `OKE_AI_MODEL`.
54
+ *
55
+ * Tolerates llama.cpp alias normalization (`gemma4:e4b-q4_K_M` ↔ `gemma4:Q4_K_M`).
56
+ *
57
+ * @param want - Requested id
58
+ * @param reported - Server id
59
+ */
60
+ export function aiModelIdsMatch(want: string, reported: string): boolean {
61
+ const a = want.trim();
62
+ const b = reported.trim();
63
+ if (!a || !b) return false;
64
+ if (a === b) return true;
65
+ const aLower = a.toLowerCase();
66
+ const bLower = b.toLowerCase();
67
+ if (aLower === bLower) return true;
68
+ const aBase = aLower.split(":")[0] ?? aLower;
69
+ const bBase = bLower.split(":")[0] ?? bLower;
70
+ if (aBase !== bBase) return false;
71
+ const aTag = aLower.slice(aBase.length + 1);
72
+ const bTag = bLower.slice(bBase.length + 1);
73
+ if (!aTag || !bTag) return true;
74
+ if (aTag === bTag) return true;
75
+ // Quant suffixes: e4b-q4_k_m vs q4_k_m
76
+ if (aTag.endsWith(bTag) || bTag.endsWith(aTag)) return true;
77
+ const norm = (t: string) => t.replace(/[^a-z0-9]/g, "");
78
+ return norm(aTag).includes(norm(bTag)) || norm(bTag).includes(norm(aTag));
79
+ }
80
+
81
+ /**
82
+ * Parse OpenAI-compatible `GET /v1/models` body into a status for `model`.
83
+ *
84
+ * @param model - Requested id
85
+ * @param json - Parsed JSON
86
+ */
87
+ export function statusFromOpenAiModels(model: string, json: unknown): AiModelStatus {
88
+ if (!json || typeof json !== "object") {
89
+ return { model, phase: "starting", detail: "invalid /v1/models body" };
90
+ }
91
+ const data = (json as { data?: unknown }).data;
92
+ if (!Array.isArray(data)) {
93
+ return { model, phase: "starting", detail: "no model list" };
94
+ }
95
+ if (data.length === 0) {
96
+ return { model, phase: "starting", detail: "no models registered yet" };
97
+ }
98
+ for (const row of data) {
99
+ if (!row || typeof row !== "object") continue;
100
+ const id = (row as { id?: unknown }).id;
101
+ if (typeof id !== "string" || !aiModelIdsMatch(model, id)) continue;
102
+ const status = (row as { status?: unknown }).status;
103
+ const value =
104
+ typeof status === "string"
105
+ ? status
106
+ : status &&
107
+ typeof status === "object" &&
108
+ typeof (status as { value?: unknown }).value === "string"
109
+ ? (status as { value: string }).value
110
+ : undefined;
111
+ const phase = mapServerPhase(value);
112
+ return {
113
+ model,
114
+ phase,
115
+ reportedId: id,
116
+ ...(phase === "error" && value ? { detail: value } : {}),
117
+ };
118
+ }
119
+ return {
120
+ model,
121
+ phase: "starting",
122
+ detail: `waiting for ${model} (server has ${data.length} other model(s))`,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Parse Ollama `GET /api/tags` into a status for `model`.
128
+ *
129
+ * @param model - Requested id
130
+ * @param json - Parsed JSON
131
+ */
132
+ export function statusFromOllamaTags(model: string, json: unknown): AiModelStatus {
133
+ if (!json || typeof json !== "object") {
134
+ return { model, phase: "starting", detail: "invalid /api/tags body" };
135
+ }
136
+ const models = (json as { models?: unknown }).models;
137
+ if (!Array.isArray(models)) {
138
+ return { model, phase: "starting" };
139
+ }
140
+ for (const row of models) {
141
+ if (!row || typeof row !== "object") continue;
142
+ const name = (row as { name?: unknown }).name;
143
+ if (typeof name === "string" && aiModelIdsMatch(model, name)) {
144
+ return { model, phase: "ready", reportedId: name };
145
+ }
146
+ }
147
+ return { model, phase: "loading", detail: "not in /api/tags yet" };
148
+ }
149
+
150
+ /**
151
+ * Map a server-native status string to {@link AiModelPhase}.
152
+ *
153
+ * @param value - Raw status
154
+ */
155
+ function mapServerPhase(value: string | undefined): AiModelPhase {
156
+ if (!value) return "ready";
157
+ const v = value.trim().toLowerCase();
158
+ if (v === "ready" || v === "running" || v === "loaded" || v === "idle") return "ready";
159
+ if (v === "loading" || v === "pulling" || v === "downloading" || v === "starting") {
160
+ return v === "starting" ? "starting" : "loading";
161
+ }
162
+ if (v === "error" || v === "failed" || v === "unhealthy") return "error";
163
+ return "loading";
164
+ }
165
+
166
+ /**
167
+ * Probe the AI HTTP API once.
168
+ *
169
+ * @param opts - URL + model + kind
170
+ */
171
+ export async function probeAiModelStatus(opts: ProbeAiModelStatusOptions): Promise<AiModelStatus> {
172
+ const model = opts.model.trim();
173
+ if (!model) return { model: "", phase: "error", detail: "OKE_AI_MODEL is empty" };
174
+ const fetchFn = opts.fetch ?? globalThis.fetch;
175
+ const base = normalizeAiProbeUrl(opts.url, opts.kind);
176
+ const path = opts.kind === "ollama" ? `${base}/api/tags` : `${base}/models`;
177
+ try {
178
+ const res = await fetchFn(path, { method: "GET" });
179
+ if (!res.ok) {
180
+ return {
181
+ model,
182
+ phase: res.status >= 500 ? "starting" : "error",
183
+ detail: `HTTP ${res.status}`,
184
+ };
185
+ }
186
+ const json: unknown = await res.json().catch(() => null);
187
+ return opts.kind === "ollama"
188
+ ? statusFromOllamaTags(model, json)
189
+ : statusFromOpenAiModels(model, json);
190
+ } catch (err) {
191
+ const msg = err instanceof Error ? err.message : String(err);
192
+ return { model, phase: "unreachable", detail: msg };
193
+ }
194
+ }
195
+
196
+ /**
197
+ * One-line status for `oke dev` logs.
198
+ *
199
+ * @param status - Probe result
200
+ */
201
+ export function formatAiModelStatusMessage(status: AiModelStatus): string {
202
+ const id =
203
+ status.reportedId && status.reportedId !== status.model
204
+ ? `${status.model} (${status.reportedId})`
205
+ : status.model || "(unset)";
206
+ switch (status.phase) {
207
+ case "unreachable":
208
+ return `AI ${id} — waiting for server…`;
209
+ case "starting":
210
+ return `AI ${id} — starting…`;
211
+ case "loading":
212
+ return `AI ${id} — loading…`;
213
+ case "ready":
214
+ return `AI ${id} — ready`;
215
+ case "error":
216
+ return `AI ${id} — error${status.detail ? ` (${status.detail})` : ""}`;
217
+ }
218
+ }
219
+
220
+ /** Options for {@link startAiModelWatch}. */
221
+ export type StartAiModelWatchOptions = ProbeAiModelStatusOptions & {
222
+ /** Called when the phase (or message) changes. */
223
+ readonly onStatus: (message: string, status: AiModelStatus) => void;
224
+ /** Poll interval (ms). Default 2000. */
225
+ readonly intervalMs?: number;
226
+ /** Give up after this many ms. Default 30 minutes. */
227
+ readonly timeoutMs?: number;
228
+ /** Injectable clock (tests). */
229
+ readonly now?: () => number;
230
+ /** Injectable sleep (tests). */
231
+ readonly sleep?: (ms: number) => Promise<void>;
232
+ };
233
+
234
+ /**
235
+ * Poll model readiness in the background until ready, error, timeout, or stop.
236
+ *
237
+ * @param opts - Probe + callbacks
238
+ * @returns Stop function
239
+ */
240
+ export function startAiModelWatch(opts: StartAiModelWatchOptions): () => void {
241
+ const intervalMs = opts.intervalMs ?? 2_000;
242
+ const timeoutMs = opts.timeoutMs ?? 30 * 60_000;
243
+ const now = opts.now ?? Date.now;
244
+ const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms));
245
+ let stopped = false;
246
+ let lastMessage = "";
247
+ const startedAt = now();
248
+
249
+ const tick = async (): Promise<void> => {
250
+ while (!stopped) {
251
+ const status = await probeAiModelStatus(opts);
252
+ if (stopped) return;
253
+ const message = formatAiModelStatusMessage(status);
254
+ if (message !== lastMessage) {
255
+ lastMessage = message;
256
+ opts.onStatus(message, status);
257
+ }
258
+ if (status.phase === "ready" || status.phase === "error") return;
259
+ if (now() - startedAt >= timeoutMs) {
260
+ const timed: AiModelStatus = {
261
+ model: opts.model,
262
+ phase: "error",
263
+ detail: `still ${status.phase} after ${Math.round(timeoutMs / 60_000)}m`,
264
+ };
265
+ opts.onStatus(formatAiModelStatusMessage(timed), timed);
266
+ return;
267
+ }
268
+ await sleep(intervalMs);
269
+ }
270
+ };
271
+
272
+ void tick();
273
+ return () => {
274
+ stopped = true;
275
+ };
276
+ }