okengine 0.9.1 → 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.
- package/AGENTS.md +1 -1
- package/manifest.v1.schema.json +9 -1
- package/package.json +3 -3
- package/site/content/docs/console/runs.mdx +10 -0
- package/site/content/docs/console/traces.mdx +4 -0
- package/site/content/docs/elements/ai.mdx +54 -32
- package/site/content/docs/elements/channel.mdx +97 -9
- package/site/content/docs/elements/clock.mdx +1 -0
- package/site/content/docs/elements/flow.mdx +42 -8
- package/site/content/docs/get-started/basic-usage.mdx +5 -1
- package/site/content/docs/get-started/installation.mdx +9 -5
- package/site/content/docs/meta.json +2 -0
- package/site/content/docs/providers/azure-redis.mdx +85 -0
- package/site/content/docs/providers/cockroachdb.mdx +86 -0
- package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
- package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
- package/site/content/docs/providers/elasticache.mdx +92 -0
- package/site/content/docs/providers/index.mdx +101 -0
- package/site/content/docs/providers/memorystore.mdx +89 -0
- package/site/content/docs/providers/meta.json +20 -0
- package/site/content/docs/providers/neon.mdx +115 -0
- package/site/content/docs/providers/redis-cloud.mdx +93 -0
- package/site/content/docs/providers/supabase.mdx +117 -0
- package/site/content/docs/providers/upstash.mdx +91 -0
- package/site/content/docs/providers/yugabytedb.mdx +95 -0
- package/site/content/docs/recipes/caddy.mdx +128 -0
- package/site/content/docs/recipes/cockroachdb.mdx +127 -0
- package/site/content/docs/recipes/dragonfly.mdx +125 -0
- package/site/content/docs/recipes/index.mdx +146 -0
- package/site/content/docs/recipes/llama-cpp.mdx +153 -0
- package/site/content/docs/recipes/mailpit.mdx +136 -0
- package/site/content/docs/recipes/meilisearch.mdx +139 -0
- package/site/content/docs/recipes/meta.json +31 -0
- package/site/content/docs/recipes/ollama.mdx +142 -0
- package/site/content/docs/recipes/openbao.mdx +141 -0
- package/site/content/docs/recipes/pgdog.mdx +137 -0
- package/site/content/docs/recipes/postgres.mdx +136 -0
- package/site/content/docs/recipes/redis.mdx +130 -0
- package/site/content/docs/recipes/rustfs.mdx +148 -0
- package/site/content/docs/recipes/sglang.mdx +105 -0
- package/site/content/docs/recipes/supabase-docker.mdx +145 -0
- package/site/content/docs/recipes/timescale.mdx +129 -0
- package/site/content/docs/recipes/traefik.mdx +136 -0
- package/site/content/docs/recipes/valkey.mdx +121 -0
- package/site/content/docs/recipes/vllm.mdx +106 -0
- package/site/content/docs/recipes/yugabytedb.mdx +135 -0
- package/site/content/docs/reference/cli.md +42 -6
- package/site/content/docs/reference/configuration.mdx +5 -3
- package/site/content/docs/reference/environment-variables.mdx +12 -12
- package/site/content/docs/reference/fx.mdx +38 -4
- package/src/cli/ai-setup/ai-setup.test.ts +32 -12
- package/src/cli/ai-setup/apply.ts +65 -23
- package/src/cli/ai-setup/catalog.ts +1316 -35
- package/src/cli/ai-setup/detect-ollama.ts +47 -0
- package/src/cli/ai-setup/index.ts +44 -7
- package/src/cli/ai-setup/prompts.ts +430 -486
- package/src/cli/ai-setup/recommend.test.ts +150 -50
- package/src/cli/ai-setup/recommend.ts +118 -101
- package/src/cli/ask-seed.test.ts +68 -0
- package/src/cli/ask-seed.ts +79 -0
- package/src/cli/db-seed-redact.test.ts +25 -0
- package/src/cli/db-seed.ts +36 -7
- package/src/cli/db.test.ts +1 -1
- package/src/cli/db.ts +2 -2
- package/src/cli/dev-controls.test.ts +107 -0
- package/src/cli/dev-controls.ts +354 -0
- package/src/cli/dev-schema-sync.ts +12 -4
- package/src/cli/dev.test.ts +50 -30
- package/src/cli/dev.ts +607 -66
- package/src/cli/drizzle-env.test.ts +40 -6
- package/src/cli/drizzle-env.ts +22 -1
- package/src/cli/ensure-drizzle-config.ts +6 -4
- package/src/cli/hero-meta.test.ts +13 -1
- package/src/cli/hero-meta.ts +28 -6
- package/src/cli/index.ts +5 -0
- package/src/cli/registry.ts +24 -0
- package/src/cli/replay.test.ts +105 -0
- package/src/cli/replay.ts +206 -0
- package/src/compiler/extract.ts +3 -3
- package/src/config/index.ts +3 -3
- package/src/console/server/claim.ts +3 -1
- package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
- package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
- package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
- package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
- package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
- package/src/console/ui/dist/index.html +1 -1
- package/src/console/ui/overview/slo.ts +31 -3
- package/src/console/ui/overview/types.ts +6 -0
- package/src/console/ui/runs/errors.test.ts +53 -0
- package/src/console/ui/runs/errors.ts +88 -0
- package/src/console/ui/runs/index.ts +8 -0
- package/src/console/ui/runs/search.ts +16 -0
- package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
- package/src/docker/ai-model-status.test.ts +101 -0
- package/src/docker/ai-model-status.ts +276 -0
- package/src/docker/compose-health.test.ts +115 -0
- package/src/docker/compose-health.ts +234 -0
- package/src/docker/compose.ts +24 -8
- package/src/docker/derive.ts +35 -9
- package/src/docker/docker.test.ts +304 -12
- package/src/docker/helpers.ts +56 -0
- package/src/docker/index.ts +52 -0
- package/src/docker/ollama-pull.ts +232 -0
- package/src/docker/recipes/cockroach.ts +24 -0
- package/src/docker/recipes/index.ts +32 -0
- package/src/docker/recipes/llama-cpp.ts +273 -0
- package/src/docker/recipes/ollama.ts +19 -23
- package/src/docker/recipes/pgdog.ts +4 -1
- package/src/docker/recipes/postgres.ts +5 -18
- package/src/docker/recipes/sglang.ts +55 -0
- package/src/docker/recipes/supabase.ts +21 -0
- package/src/docker/recipes/timescale.ts +18 -0
- package/src/docker/recipes/vllm.ts +44 -0
- package/src/docker/recipes/yugabyte.ts +23 -0
- package/src/docker/stack-id.test.ts +22 -0
- package/src/docker/stack-id.ts +38 -4
- package/src/docker/types.ts +15 -0
- package/src/drivers/ai-ollama.integration.test.ts +7 -4
- package/src/drivers/signal-engine.ts +10 -0
- package/src/drivers/signal-postgres.ts +24 -2
- package/src/drivers/signal-types.ts +10 -0
- package/src/elements/channel/fallback.test.ts +91 -0
- package/src/elements/channel/hard-bounce.test.ts +82 -0
- package/src/elements/channel/injection.test.ts +153 -0
- package/src/elements/channel/locale-ar.test.ts +102 -0
- package/src/elements/channel/otp-delivery.ts +5 -2
- package/src/elements/channel/receipts-ledger.test.ts +100 -0
- package/src/elements/channel/runtime.ts +24 -1
- package/src/elements/channel/test-helpers.ts +60 -0
- package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
- package/src/elements/store/cache.ts +1 -1
- package/src/elements/store/emit-drizzle.ts +43 -9
- package/src/elements/store/runtime.ts +1 -1
- package/src/elements/store/schema-decl.test.ts +2 -0
- package/src/elements/store/schema-decl.ts +15 -0
- package/src/elements/store/seed.ts +1 -1
- package/src/index.ts +1 -0
- package/src/kernel/app.ts +117 -10
- package/src/kernel/boot-bind/signal.ts +6 -2
- package/src/kernel/boot-bind/store.ts +2 -1
- package/src/kernel/boot.test.ts +6 -5
- package/src/kernel/boot.ts +8 -3
- package/src/kernel/compensate.test.ts +131 -0
- package/src/kernel/correlation.test.ts +108 -0
- package/src/kernel/effect-timing.test.ts +82 -0
- package/src/kernel/flow.ts +38 -0
- package/src/kernel/fx-runs.test.ts +49 -0
- package/src/kernel/fx.test.ts +14 -0
- package/src/kernel/fx.ts +111 -1
- package/src/manifest/types.ts +8 -2
- package/src/runs/collect.ts +3 -0
- package/src/runs/export-otlp.ts +64 -0
- package/src/runs/index.ts +16 -0
- package/src/runs/parquet.ts +9 -0
- package/src/runs/types.ts +5 -0
- package/src/runs/window.test.ts +68 -0
- package/src/runs/window.ts +148 -0
- package/src/runtime/boot-warn.test.ts +45 -0
- package/src/runtime/boot-warn.ts +17 -0
- package/src/runtime/dev-request-log.ts +5 -5
- package/src/term.test.ts +95 -10
- package/src/term.ts +369 -14
- package/src/console/ui/dist/assets/panel-overview-DONDzBd2.js +0 -1
- 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
|
|
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
|
+
}
|