okengine 0.23.2 → 0.24.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 +5 -1
- package/manifest.v1.schema.json +53 -3
- package/package.json +2 -1
- package/site/content/docs/ai/mcp.mdx +1 -1
- package/site/content/docs/ai/skills.mdx +1 -1
- package/site/content/docs/elements/ai/decide.mdx +95 -417
- package/site/content/docs/elements/ai/deciders.mdx +154 -0
- package/site/content/docs/elements/ai/index.mdx +2 -1
- package/site/content/docs/elements/ai/meta.json +1 -1
- package/site/content/docs/reference/cli.mdx +2 -2
- package/site/content/docs/reference/configuration.mdx +2 -1
- package/site/content/docs/reference/fx.mdx +11 -9
- package/src/cli/agents-md.test.ts +8 -2
- package/src/cli/decide-certify.test.ts +166 -0
- package/src/cli/decide.ts +170 -3
- package/src/cli/decision-lock-watch.test.ts +4 -4
- package/src/cli/eval.ts +38 -59
- package/src/cli/mcp-from-console.ts +1 -1
- package/src/cli/registry.ts +45 -9
- package/src/compiler/decisions.extract.test.ts +86 -31
- package/src/compiler/effects-infer.ts +1 -0
- package/src/compiler/extract.ts +239 -24
- package/src/config/index.ts +6 -0
- package/src/console/server/decisions.test.ts +6 -1
- package/src/console/server/decisions.ts +32 -1
- package/src/console/ui-next/dist/assets/{access-page-zBMhZnFm.js → access-page-DX2dKLmq.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-CBj9f7Ju.js → agent-disclosure-CiRvA3M9.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-D3MY49h3.js → cache-glyph-CC1yFJ8A.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-L9JxdBhp.js → call-pii-button-CLXQgIDD.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-BvFaX-Zt.js → collapsible-C0AThBZg.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-DgB76m-X.js → decisions-page-Bf3AhTAa.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-l1DSJ2Vz.js → duration-tone-Bptur2gX.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-Bb9t1lu6.js → flows-page-DRZVeTZU.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-DMvsi3Ti.js → highlighted-json-BKf0PjBK.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-BwhJBTcQ.js → http-method-uhrnWrGC.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-RhV2jT_7.js → index-Duxus_sG.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-BRfUILBq.js → observability-page-B-QM8X0O.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-BsceAzIG.js → replica-lag-DuqHCANi.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-C6lTyCXp.js → request-meta-BTBZ3Ulc.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-YmE806bJ.js → store-page-fpo7VxYb.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-Di4irS49.js → trace-detail-sheet-DKtr1-K1.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-DYeL6Rye.js → tree-expand-toggle-QeTceKKI.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-07agasRA.js → units-page-ChCp8mOy.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-CHBck4n_.js → vault-page-CEYLXhUX.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/src/client.ts +8 -0
- package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +16 -0
- package/src/elements/ai/deciders/presets.ts +182 -0
- package/src/elements/ai/decisions/bind.ts +6 -5
- package/src/elements/ai/decisions/catalog.ts +93 -0
- package/src/elements/ai/decisions/certificate.test.ts +47 -19
- package/src/elements/ai/decisions/certificate.ts +107 -24
- package/src/elements/ai/decisions/certify.ts +104 -9
- package/src/elements/ai/decisions/codec.test.ts +142 -0
- package/src/elements/ai/decisions/codec.ts +302 -0
- package/src/elements/ai/decisions/decide.test.ts +108 -31
- package/src/elements/ai/decisions/decider-runtime.test.ts +415 -0
- package/src/elements/ai/decisions/decider.types.test.ts +62 -0
- package/src/elements/ai/decisions/e2e.test.ts +20 -16
- package/src/elements/ai/decisions/http.ts +15 -7
- package/src/elements/ai/decisions/labels.test.ts +6 -1
- package/src/elements/ai/decisions/labels.ts +4 -3
- package/src/elements/ai/decisions/openai.live.test.ts +42 -0
- package/src/elements/ai/decisions/openrouter.live.test.ts +5 -1
- package/src/elements/ai/decisions/provider.ts +22 -1
- package/src/elements/ai/declare.ts +208 -51
- package/src/elements/ai.ts +1 -0
- package/src/index.ts +1 -0
- package/src/kernel/app.ts +8 -0
- package/src/kernel/decision-budget-entry.ts +11 -0
- package/src/kernel/element-registries.ts +3 -0
- package/src/kernel/fx-decide.ts +276 -88
- package/src/manifest/types.ts +38 -2
- package/src/release/measure.ts +47 -8
- package/src/test/create-test-app.ts +21 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Deciders"
|
|
3
|
+
description: "One model that can answer a decision, with its protocol, secret, and certificate."
|
|
4
|
+
icon: "Cpu"
|
|
5
|
+
source: "docs/spec/unified-theory.md"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
A decider is the model behind a decision. Two presets are filled in. Every other host passes the protocol, the URL, and what it can answer.
|
|
9
|
+
|
|
10
|
+
<Callout title="The one rule">
|
|
11
|
+
Use `provider: "openrouter"` or `provider: "openai"` when that is the host. Any other host needs
|
|
12
|
+
`driverId`, `baseUrl`, `secret`, and `capabilities`.
|
|
13
|
+
</Callout>
|
|
14
|
+
|
|
15
|
+
## Quick start
|
|
16
|
+
|
|
17
|
+
<Steps>
|
|
18
|
+
|
|
19
|
+
<Step>
|
|
20
|
+
### OpenRouter
|
|
21
|
+
|
|
22
|
+
Certificates bind to the dated model id the host echoes.
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { ai } from "okengine";
|
|
26
|
+
|
|
27
|
+
export const jev = ai.decider("jev", {
|
|
28
|
+
provider: "openrouter",
|
|
29
|
+
model: "typesafe/jev-1.13-20260917",
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The request is System One: `POST https://openrouter.ai/api/alpha/decisions` with `{ model, state, questions }`. The secret is `OPENROUTER_API_KEY`.
|
|
34
|
+
|
|
35
|
+
</Step>
|
|
36
|
+
|
|
37
|
+
<Step>
|
|
38
|
+
### OpenAI
|
|
39
|
+
|
|
40
|
+
The certificate binds to the echoed id, stays unpinned, and expires 30 days after certify.
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
export const luna = ai.decider("luna", {
|
|
44
|
+
provider: "openai",
|
|
45
|
+
model: "gpt-6-luna",
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The request is `{ model, input, questions }` at `https://api.openai.com/v1/decisions`. Object state is sent as JSON text. The secret is `OPENAI_API_KEY`.
|
|
50
|
+
|
|
51
|
+
</Step>
|
|
52
|
+
|
|
53
|
+
<Step>
|
|
54
|
+
### Any other host
|
|
55
|
+
|
|
56
|
+
Name the protocol. Do not invent a preset.
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
export const local = ai.decider("local", {
|
|
60
|
+
driverId: "systemone",
|
|
61
|
+
baseUrl: "https://decisions.example.com/v1",
|
|
62
|
+
model: "local-decider",
|
|
63
|
+
secret: "DECISION_API_KEY",
|
|
64
|
+
capabilities: { boolean: true, choice: true, score: true, refusal: false },
|
|
65
|
+
region: "self",
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`region` and `zdr` are your declaration. The Manifest marks them `declared`.
|
|
70
|
+
|
|
71
|
+
</Step>
|
|
72
|
+
|
|
73
|
+
</Steps>
|
|
74
|
+
|
|
75
|
+
## Presets
|
|
76
|
+
|
|
77
|
+
| Provider | Protocol | URL | Secret | Pinning | Answers |
|
|
78
|
+
| ------------ | ------------------ | ------------------------------------------- | -------------------- | ------- | ------------------------------- |
|
|
79
|
+
| `openrouter` | `systemone` | `https://openrouter.ai/api/alpha/decisions` | `OPENROUTER_API_KEY` | `dated` | boolean, choice, score |
|
|
80
|
+
| `openai` | `openai-decisions` | `https://api.openai.com/v1/decisions` | `OPENAI_API_KEY` | `alias` | boolean, choice, score, refusal |
|
|
81
|
+
|
|
82
|
+
No other host is filled in. A dated certificate stores the echoed canonical slug. An alias certificate stores `pinned: false` and `expiresAt`. Console shows the unpinned flag. `oke decide certify` prints it.
|
|
83
|
+
|
|
84
|
+
If OpenRouter lists a dated id for the model, autonomy requires the decider to use that id.
|
|
85
|
+
|
|
86
|
+
## Capabilities
|
|
87
|
+
|
|
88
|
+
| Field | Meaning |
|
|
89
|
+
| ------------------------------------------ | -------------------------------------------------------------- |
|
|
90
|
+
| `boolean` / `choice` / `score` / `refusal` | Whether that question kind is allowed. |
|
|
91
|
+
| `maxChoices` | Enforced only when the row sets it. |
|
|
92
|
+
| `minLevels` / `maxLevels` | Enforced only when the row sets it. |
|
|
93
|
+
| `maxContext` | Estimated input tokens. Over the limit throws before the call. |
|
|
94
|
+
|
|
95
|
+
Presets do not set a numeric limit. A custom row's `false` flag rejects that question at compile time.
|
|
96
|
+
|
|
97
|
+
Both wires become one answer: `boolean`, `choice`, `score`, `refusal`, or `malformed`. A refusal carries no text.
|
|
98
|
+
|
|
99
|
+
## Options
|
|
100
|
+
|
|
101
|
+
| Option | Preset | Custom host |
|
|
102
|
+
| ---------------- | ------------------------------- | --------------------------------- |
|
|
103
|
+
| `provider` | `openrouter` or `openai` | Optional label |
|
|
104
|
+
| `driverId` | Filled in | `systemone` or `openai-decisions` |
|
|
105
|
+
| `baseUrl` | Filled in | Required |
|
|
106
|
+
| `model` | Required | Required |
|
|
107
|
+
| `secret` | Preset secret, or your override | Required |
|
|
108
|
+
| `region` / `zdr` | Stored as `declared` | Stored as `declared` |
|
|
109
|
+
| `timeout` | Optional | Optional |
|
|
110
|
+
| `concurrency` | Optional cap for this decider | Optional |
|
|
111
|
+
|
|
112
|
+
One decider has one breaker. Three HTTP 5xx responses open it for 30 seconds. The next call skips that decider and tries `backup`.
|
|
113
|
+
|
|
114
|
+
## Troubleshooting
|
|
115
|
+
|
|
116
|
+
<Accordions>
|
|
117
|
+
|
|
118
|
+
<Accordion title="autonomy requires model">
|
|
119
|
+
`oke decide certify: autonomy requires model "…" on decider "…"`. The catalog lists a dated id.
|
|
120
|
+
Put that id on the decider.
|
|
121
|
+
</Accordion>
|
|
122
|
+
|
|
123
|
+
<Accordion title="model is unpinned">
|
|
124
|
+
Certify prints `model "…" is unpinned`. An alias certificate expires 30 days later. After that,
|
|
125
|
+
answers take `otherwise` with `why: "uncertified"`.
|
|
126
|
+
</Accordion>
|
|
127
|
+
|
|
128
|
+
<Accordion title="cannot answer choice">
|
|
129
|
+
`ai.decision("…"): decider "…" cannot answer choice "…"`. The capability row has that kind set to
|
|
130
|
+
`false`.
|
|
131
|
+
</Accordion>
|
|
132
|
+
|
|
133
|
+
</Accordions>
|
|
134
|
+
|
|
135
|
+
## Learn more
|
|
136
|
+
|
|
137
|
+
- [Decisions](/docs/elements/ai/decide) — `otherwise`, certificates, and `fx.decide`
|
|
138
|
+
- [Configuration](/docs/reference/configuration) — `drivers.decide`
|
|
139
|
+
|
|
140
|
+
## Next
|
|
141
|
+
|
|
142
|
+
<Cards>
|
|
143
|
+
<Card
|
|
144
|
+
title="Decisions"
|
|
145
|
+
description="Questions, otherwise, and the lockfile."
|
|
146
|
+
href="/docs/elements/ai/decide"
|
|
147
|
+
/>
|
|
148
|
+
<Card title="Models" description="Chat and embed providers." href="/docs/elements/ai/models" />
|
|
149
|
+
<Card
|
|
150
|
+
title="CLI"
|
|
151
|
+
description="Certify, promote, labels, and models."
|
|
152
|
+
href="/docs/reference/cli"
|
|
153
|
+
/>
|
|
154
|
+
</Cards>
|
|
@@ -321,7 +321,8 @@ Compose does not pin inference — BYO URL + keys, or [OpenRouter](/docs/recipes
|
|
|
321
321
|
- [Models](/docs/elements/ai/models) — provider registry and `baseUrl` rules
|
|
322
322
|
- [Prompts](/docs/elements/ai/prompts) — `via`, budgets, versions, evals
|
|
323
323
|
- [Agents](/docs/elements/ai/agents) — `fx.run`, tools, `maxSteps`
|
|
324
|
-
- [
|
|
324
|
+
- [Deciders](/docs/elements/ai/deciders) — the model that answers, and how its certificate is pinned
|
|
325
|
+
- [Decisions](/docs/elements/ai/decide) — `fx.decide`, `otherwise`, and the lockfile
|
|
325
326
|
- [MCP](/docs/elements/ai/mcp) — inbound `mcp.tool` and outbound `ai.mcpServer`
|
|
326
327
|
- [OpenRouter](/docs/recipes/openrouter) — zero-Docker cloud path
|
|
327
328
|
- [fx](/docs/reference/fx) — `fx.ask` / `fx.run` / `fx.decide` / `fx.embed`
|
|
@@ -134,8 +134,8 @@ Override app / console / mcp listen ports in `oke.config.ts` `ports` — see
|
|
|
134
134
|
| `docker` | Derive compose · clean leftover stacks | `--prod\|-p`, `--out`, `--config`, `--manifest`; sub `clean` |
|
|
135
135
|
| `images` | List / pin digests | `list` · `pin` |
|
|
136
136
|
| `build` | Tree-shaken bundle | `--target\|-t bun\|node\|edge`, `--entry`, `--outdir` |
|
|
137
|
-
| `eval` | Prompt eval sets (CI gate) | `--manifest
|
|
138
|
-
| `decide` |
|
|
137
|
+
| `eval` | Prompt eval sets (CI gate) | `--manifest` |
|
|
138
|
+
| `decide` | Certify, promote, export labels, list models | `certify <name> --deciders a,b` · `promote <name>` (`--origin`, `--lock`) · `labels <name> --export` · `models` |
|
|
139
139
|
| `ai` | Configure AI driver + models | `setup` (`--provider`, `--chat`, `--vision`, `--embed`, `--yes`) |
|
|
140
140
|
| `branch` | Fork journaled state | `<name>`, `--at\|-a` |
|
|
141
141
|
| `replay` | Re-invoke a past Flow from Runs | `--request-id\|-r`, `--entry`, `--dry-run`, `--live` |
|
|
@@ -99,7 +99,7 @@ Built-in defaults (`DRIVER_DEFAULTS`) when a key is omitted:
|
|
|
99
99
|
| `channel.sms` | — (opt-in) | — | — |
|
|
100
100
|
| `runs` | `files` | `memory` | `files` |
|
|
101
101
|
|
|
102
|
-
`store.index`, `channel.whatsapp` / `push`, and `ai` have no single three-env default table — set them explicitly when you need them. create-oke templates pin `vault.dev: "vault"` (built-in) because the default `dev` driver is `env`.
|
|
102
|
+
`store.index`, `channel.whatsapp` / `push`, and `ai` have no single three-env default table — set them explicitly when you need them. `drivers.decide` defaults to `{ test: "mock" }`. Unset `dev` and `prod` call the decider host. create-oke templates pin `vault.dev: "vault"` (built-in) because the default `dev` driver is `env`.
|
|
103
103
|
|
|
104
104
|
## Driver keys and ids
|
|
105
105
|
|
|
@@ -118,6 +118,7 @@ Built-in defaults (`DRIVER_DEFAULTS`) when a key is omitted:
|
|
|
118
118
|
| `channel.whatsapp` | env driver map | `console` · `wa-cloud` |
|
|
119
119
|
| `channel.push` | env driver map | `console` · `webpush` · `fcm` |
|
|
120
120
|
| `ai` | env driver map | `mock` · `anthropic` · `openai-compatible` · `bedrock` · `vertex` |
|
|
121
|
+
| `decide` | env driver map | `mock` in `test` when omitted. Unset `dev` and `prod` call the decider host. |
|
|
121
122
|
| `runs` | env driver map | `memory` · `files` (Parquet + DuckDB at `.oke/runs`) · `postgres` · `clickhouse` |
|
|
122
123
|
| `prod` | `string[]` | flat protocol list for the Manifest — nested maps are preferred |
|
|
123
124
|
|
|
@@ -163,6 +163,7 @@ Read `fx.principal` for audit — it does not copy into `fx.auth`. Passing a Flo
|
|
|
163
163
|
|
|
164
164
|
`fx.store(db).transaction(async (tx) => { … })` pins one SQL connection. `fx.emit` inside the
|
|
165
165
|
callback stages on the signal outbox and publishes after commit. A throw rolls that batch back.
|
|
166
|
+
|
|
166
167
|
`fx.store(db).run(builder)` runs a Drizzle builder through `toSQL()`. Joins stay on that builder;
|
|
167
168
|
the hand-rolled chain does not grow a join API. Inference still records every joined table.
|
|
168
169
|
|
|
@@ -246,13 +247,13 @@ elements.
|
|
|
246
247
|
|
|
247
248
|
## AI
|
|
248
249
|
|
|
249
|
-
| Signature | Records | Returns
|
|
250
|
-
| ----------------------------------------------------- | ------------------------- |
|
|
251
|
-
| `fx.ask(prompt, input?, { via?, tools?, maxSteps? })` | `ask` (+ `call` per tool) | Object validated against the prompt's `out`
|
|
252
|
-
| `fx.run(agent, input?)` | `ask` | Agent result
|
|
253
|
-
| `fx.stream(model, { prompt?, data?, via? })` | `ask` | `AsyncIterable<string>` — real driver stream; cancels via ambient `fx.signal` (HTTP disconnect included)
|
|
254
|
-
| `fx.search(embed, query, { topK? })` | `read` | Matches from the index/embed
|
|
255
|
-
| `fx.decide(decision, input)` | `decide` | `$.<question>.how` is `auto`, `reviewed`, or `abstained`. `audited` is a boolean flag, not a `how`. Abstain makes the value `null`. |
|
|
250
|
+
| Signature | Records | Returns |
|
|
251
|
+
| ----------------------------------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
252
|
+
| `fx.ask(prompt, input?, { via?, tools?, maxSteps? })` | `ask` (+ `call` per tool) | Object validated against the prompt's `out` |
|
|
253
|
+
| `fx.run(agent, input?)` | `ask` | Agent result |
|
|
254
|
+
| `fx.stream(model, { prompt?, data?, via? })` | `ask` | `AsyncIterable<string>` — real driver stream; cancels via ambient `fx.signal` (HTTP disconnect included) |
|
|
255
|
+
| `fx.search(embed, query, { topK? })` | `read` | Matches from the index/embed |
|
|
256
|
+
| `fx.decide(decision, input)` | `decide` | `$.<question>.how` is `auto`, `reviewed`, or `abstained`. `by` is the decider. `why` is set only when the question is not `auto`. `audited` is a boolean flag, not a `how`. Abstain makes the value `null`. |
|
|
256
257
|
|
|
257
258
|
AI calls are nondeterministic: journaling is forced on and auto-cache disabled around them. `tools` are Flow refs — each model tool call goes through `fx.call` (same capability and Runs path).
|
|
258
259
|
|
|
@@ -289,8 +290,9 @@ Durations: `"200ms"` · `"30s"` · `"2m"` · `"1h"` · `"7d"`. A `"d"` is 86_400
|
|
|
289
290
|
|
|
290
291
|
Pure store-read flows cache by default. A send, emit, fetch, vault read, ask,
|
|
291
292
|
decide, or `fx.call` is never cached. The key includes tenant, locale, scopes,
|
|
292
|
-
and membership roles. Writes through `fx` invalidate those keys.
|
|
293
|
-
|
|
293
|
+
and membership roles. Writes through `fx` invalidate those keys.
|
|
294
|
+
|
|
295
|
+
`cache: false` always disables. `oke({ cache: { auto: false } })` turns the default off.
|
|
294
296
|
Cross-instance invalidation and out-of-band writes are not covered.
|
|
295
297
|
|
|
296
298
|
`fx.cache` is the manual (tier-3) surface:
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
import { describe, expect, test } from "bun:test";
|
|
7
|
-
import { commandNames } from "./registry.ts";
|
|
7
|
+
import { commandNames, OKE_COMMANDS } from "./registry.ts";
|
|
8
8
|
|
|
9
9
|
describe("AGENTS.md commands", () => {
|
|
10
10
|
test("every oke command mentioned in AGENTS.md is registered", () => {
|
|
@@ -17,7 +17,13 @@ describe("AGENTS.md commands", () => {
|
|
|
17
17
|
}
|
|
18
18
|
expect(mentioned.has("eval")).toBe(true);
|
|
19
19
|
expect(mentioned.has("decide")).toBe(true);
|
|
20
|
-
|
|
20
|
+
const decide = OKE_COMMANDS.find((command) => command.name === "decide");
|
|
21
|
+
expect(decide?.subcommands?.map((command) => command.name).sort()).toEqual([
|
|
22
|
+
"certify",
|
|
23
|
+
"labels",
|
|
24
|
+
"models",
|
|
25
|
+
"promote",
|
|
26
|
+
]);
|
|
21
27
|
for (const name of mentioned) {
|
|
22
28
|
expect(registered.has(name)).toBe(true);
|
|
23
29
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `oke decide certify` writes one certificate per decider.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { mkdtemp } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { ai, resetAiDecls } from "../elements/ai.ts";
|
|
10
|
+
import { resetDecisionCertificates } from "../elements/ai/decisions/certificate.ts";
|
|
11
|
+
import { ALIAS_CERTIFICATE_MS } from "../elements/ai/deciders/presets.ts";
|
|
12
|
+
import { certifyDecisionDeciders } from "./decide.ts";
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
resetAiDecls();
|
|
16
|
+
resetDecisionCertificates();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function seed(): string {
|
|
20
|
+
return Array.from({ length: 160 }, () =>
|
|
21
|
+
JSON.stringify({ input: { ticket: "1" }, expect: { team: "technical" } }),
|
|
22
|
+
).join("\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function body(model: string) {
|
|
26
|
+
return {
|
|
27
|
+
model,
|
|
28
|
+
answers: {
|
|
29
|
+
team: {
|
|
30
|
+
type: "choice" as const,
|
|
31
|
+
choice: "technical",
|
|
32
|
+
probabilities: { billing: 0.05, technical: 0.93, none_of_these: 0.02 },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
usage: { cost: 0.01 },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("oke decide certify", () => {
|
|
40
|
+
test("two mock deciders each get a certificate and an alias is unpinned", async () => {
|
|
41
|
+
const question = ai.choice("which team", { billing: "Billing", technical: "Technical" });
|
|
42
|
+
ai.decider("jev", { provider: "openrouter", model: "typesafe/jev-1.13.0" });
|
|
43
|
+
ai.decider("luna", { provider: "openai", model: "gpt-6-luna" });
|
|
44
|
+
ai.decision("triage", {
|
|
45
|
+
decider: ai.decider("jev", { provider: "openrouter", model: "typesafe/jev-1.13.0" }),
|
|
46
|
+
backup: [ai.decider("luna", { provider: "openai", model: "gpt-6-luna" })],
|
|
47
|
+
otherwise: "abstain",
|
|
48
|
+
autonomy: { maxError: 0.05, audit: 0 },
|
|
49
|
+
ask: { team: question },
|
|
50
|
+
});
|
|
51
|
+
const root = await mkdtemp(join(tmpdir(), "oke-certify-"));
|
|
52
|
+
const evals = join(root, "seed.jsonl");
|
|
53
|
+
await Bun.write(evals, seed());
|
|
54
|
+
const lines: string[] = [];
|
|
55
|
+
const now = 1_700_000_000_000;
|
|
56
|
+
const lock = await certifyDecisionDeciders({
|
|
57
|
+
root,
|
|
58
|
+
decision: "triage",
|
|
59
|
+
deciders: ["jev", "luna"],
|
|
60
|
+
now: () => now,
|
|
61
|
+
fetcher: async () => new Response("offline", { status: 404 }),
|
|
62
|
+
write: (line) => {
|
|
63
|
+
lines.push(line);
|
|
64
|
+
},
|
|
65
|
+
evaluate: {
|
|
66
|
+
jev: async () => body("typesafe/jev-1.13.0"),
|
|
67
|
+
luna: async () => body("gpt-6-luna"),
|
|
68
|
+
},
|
|
69
|
+
manifest: {
|
|
70
|
+
oke: "1.0",
|
|
71
|
+
app: "cert",
|
|
72
|
+
ai: {
|
|
73
|
+
deciders: {
|
|
74
|
+
jev: {
|
|
75
|
+
driverId: "systemone",
|
|
76
|
+
baseUrl: "https://openrouter.ai/api/alpha/decisions",
|
|
77
|
+
model: "typesafe/jev-1.13.0",
|
|
78
|
+
secret: "OPENROUTER_API_KEY",
|
|
79
|
+
pinning: "dated",
|
|
80
|
+
capabilities: { boolean: true, choice: true, score: true, refusal: false },
|
|
81
|
+
},
|
|
82
|
+
luna: {
|
|
83
|
+
driverId: "openai-decisions",
|
|
84
|
+
baseUrl: "https://api.openai.com/v1/decisions",
|
|
85
|
+
model: "gpt-6-luna",
|
|
86
|
+
secret: "OPENAI_API_KEY",
|
|
87
|
+
pinning: "alias",
|
|
88
|
+
capabilities: { boolean: true, choice: true, score: true, refusal: true },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
decisions: {
|
|
92
|
+
triage: {
|
|
93
|
+
mode: "abstain",
|
|
94
|
+
decider: "jev",
|
|
95
|
+
backup: ["luna"],
|
|
96
|
+
otherwise: "abstain",
|
|
97
|
+
questions: ["team"],
|
|
98
|
+
evals,
|
|
99
|
+
autonomy: { maxError: 0.05, audit: 0 },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
expect(lock.version).toBe(2);
|
|
106
|
+
expect(lock.decisions.triage?.deciders.jev?.pinned).toBe(true);
|
|
107
|
+
expect(lock.decisions.triage?.deciders.jev?.model).toBe("typesafe/jev-1.13.0");
|
|
108
|
+
expect(lock.decisions.triage?.deciders.luna).toMatchObject({
|
|
109
|
+
model: "gpt-6-luna",
|
|
110
|
+
pinned: false,
|
|
111
|
+
expiresAt: now + ALIAS_CERTIFICATE_MS,
|
|
112
|
+
});
|
|
113
|
+
expect(lines.some((line) => line.includes('decider "luna"') && line.includes("unpinned"))).toBe(
|
|
114
|
+
true,
|
|
115
|
+
);
|
|
116
|
+
expect(lines.some((line) => line.startsWith("jev\tteam\taccuracy"))).toBe(true);
|
|
117
|
+
expect(lines.some((line) => line.startsWith("luna\tteam\taccuracy"))).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a dated catalog slug is required when it differs from the configured model", async () => {
|
|
121
|
+
ai.decider("jev", { provider: "openrouter", model: "typesafe/jev-1.13" });
|
|
122
|
+
ai.decision("triage", {
|
|
123
|
+
decider: ai.decider("jev", { provider: "openrouter", model: "typesafe/jev-1.13" }),
|
|
124
|
+
otherwise: "abstain",
|
|
125
|
+
ask: { team: ai.choice("which", { a: "A" }) },
|
|
126
|
+
});
|
|
127
|
+
const root = await mkdtemp(join(tmpdir(), "oke-dated-"));
|
|
128
|
+
const evals = join(root, "seed.jsonl");
|
|
129
|
+
await Bun.write(evals, seed());
|
|
130
|
+
await expect(
|
|
131
|
+
certifyDecisionDeciders({
|
|
132
|
+
root,
|
|
133
|
+
decision: "triage",
|
|
134
|
+
deciders: ["jev"],
|
|
135
|
+
evaluate: { jev: async () => body("typesafe/jev-1.13") },
|
|
136
|
+
fetcher: async () =>
|
|
137
|
+
new Response(
|
|
138
|
+
JSON.stringify({
|
|
139
|
+
data: [
|
|
140
|
+
{
|
|
141
|
+
id: "typesafe/jev-1.13",
|
|
142
|
+
canonical_slug: "typesafe/jev-1.13-20260917",
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
}),
|
|
146
|
+
{ status: 200 },
|
|
147
|
+
),
|
|
148
|
+
manifest: {
|
|
149
|
+
oke: "1.0",
|
|
150
|
+
app: "cert",
|
|
151
|
+
ai: {
|
|
152
|
+
decisions: {
|
|
153
|
+
triage: {
|
|
154
|
+
mode: "abstain",
|
|
155
|
+
decider: "jev",
|
|
156
|
+
otherwise: "abstain",
|
|
157
|
+
questions: ["team"],
|
|
158
|
+
evals,
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
).rejects.toThrow(/autonomy requires model "typesafe\/jev-1.13-20260917"/);
|
|
165
|
+
});
|
|
166
|
+
});
|
package/src/cli/decide.ts
CHANGED
|
@@ -11,6 +11,10 @@ import {
|
|
|
11
11
|
type DecisionLockfile,
|
|
12
12
|
} from "../elements/ai/decisions/certificate.ts";
|
|
13
13
|
import { DECISION_EXPORT_WARNING } from "../elements/ai/decisions/export.ts";
|
|
14
|
+
import { ALIAS_CERTIFICATE_MS } from "../elements/ai/deciders/presets.ts";
|
|
15
|
+
import { requiredDatedModel } from "../elements/ai/decisions/catalog.ts";
|
|
16
|
+
import type { Manifest } from "../manifest/types.ts";
|
|
17
|
+
import type { DecisionEvaluate } from "../elements/ai/decisions/certify.ts";
|
|
14
18
|
|
|
15
19
|
/** Options for {@link promoteDecision}. */
|
|
16
20
|
export interface PromoteDecisionOptions {
|
|
@@ -137,22 +141,115 @@ async function exportLabelsCommand(name: string | undefined, rest: string[]): Pr
|
|
|
137
141
|
*
|
|
138
142
|
* @param args - Remaining argv after `decide`
|
|
139
143
|
*/
|
|
144
|
+
/**
|
|
145
|
+
* Certify one decision for each named decider and write lockfile version 2.
|
|
146
|
+
*
|
|
147
|
+
* @param options - Decision, deciders, and injected evaluators
|
|
148
|
+
*/
|
|
149
|
+
export async function certifyDecisionDeciders(options: {
|
|
150
|
+
readonly root: string;
|
|
151
|
+
readonly manifest: Manifest;
|
|
152
|
+
readonly decision: string;
|
|
153
|
+
readonly deciders: readonly string[];
|
|
154
|
+
readonly evaluate: Readonly<Record<string, DecisionEvaluate>>;
|
|
155
|
+
readonly now?: () => number;
|
|
156
|
+
readonly fetcher?: (input: string, init?: RequestInit) => Promise<Response>;
|
|
157
|
+
readonly write?: (text: string) => void;
|
|
158
|
+
}): Promise<DecisionLockfile> {
|
|
159
|
+
const { certifySeed } = await import("../elements/ai/decisions/certify.ts");
|
|
160
|
+
const { aiDecisionRegistry, aiDeciderRegistry } = await import("../kernel/element-registries.ts");
|
|
161
|
+
const decl = aiDecisionRegistry.find((item) => item.name === options.decision);
|
|
162
|
+
const decision = options.manifest.ai?.decisions?.[options.decision];
|
|
163
|
+
if (!decl || !decision) {
|
|
164
|
+
throw new Error(`oke decide certify: decision "${options.decision}" is not loaded`);
|
|
165
|
+
}
|
|
166
|
+
if (!decision.evals) {
|
|
167
|
+
throw new Error(`oke decide certify: decision "${options.decision}" has no evals`);
|
|
168
|
+
}
|
|
169
|
+
const text = await Bun.file(resolve(options.root, decision.evals)).text();
|
|
170
|
+
const lockPath = resolve(options.root, DECISION_LOCK_FILENAME);
|
|
171
|
+
const existing = Bun.file(lockPath);
|
|
172
|
+
let current: DecisionLockfile | undefined;
|
|
173
|
+
if (await existing.exists()) current = parseDecisionLockfile(await existing.json());
|
|
174
|
+
const write = options.write ?? ((line: string) => process.stdout.write(line));
|
|
175
|
+
const deciders: Record<string, DecisionLockfile["decisions"][string]["deciders"][string]> = {
|
|
176
|
+
...(current?.decisions[options.decision]?.deciders ?? {}),
|
|
177
|
+
};
|
|
178
|
+
for (const name of options.deciders) {
|
|
179
|
+
const decider = aiDeciderRegistry.find((item) => item.name === name) ?? undefined;
|
|
180
|
+
const manifestDecider = options.manifest.ai?.deciders?.[name];
|
|
181
|
+
if (!decider && !manifestDecider) {
|
|
182
|
+
throw new Error(`oke decide certify: decider "${name}" is not loaded`);
|
|
183
|
+
}
|
|
184
|
+
const model = decider?.model ?? manifestDecider?.model ?? "";
|
|
185
|
+
const pinning = decider?.pinning ?? manifestDecider?.pinning ?? "dated";
|
|
186
|
+
const baseUrl = decider?.baseUrl ?? manifestDecider?.baseUrl ?? "";
|
|
187
|
+
const required = await requiredDatedModel(model, baseUrl, pinning, options.fetcher);
|
|
188
|
+
if (required && required !== model) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`oke decide certify: autonomy requires model "${required}" on decider "${name}"`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const evaluate = options.evaluate[name];
|
|
194
|
+
if (!evaluate) throw new Error(`oke decide certify: no evaluator for decider "${name}"`);
|
|
195
|
+
const alias = pinning === "alias";
|
|
196
|
+
const now = options.now ?? Date.now;
|
|
197
|
+
const result = await certifySeed({
|
|
198
|
+
model,
|
|
199
|
+
pinned: !alias,
|
|
200
|
+
...(alias ? { expiresAt: now() + ALIAS_CERTIFICATE_MS } : {}),
|
|
201
|
+
maxError: decision.autonomy?.maxError ?? decl.autonomy?.maxError ?? 0.05,
|
|
202
|
+
delta: decision.autonomy?.risk ?? decl.autonomy?.risk ?? 0.1,
|
|
203
|
+
jsonl: text,
|
|
204
|
+
ask: decl.ask,
|
|
205
|
+
evaluate,
|
|
206
|
+
now,
|
|
207
|
+
});
|
|
208
|
+
deciders[name] = result.cert;
|
|
209
|
+
if (result.unpinned) {
|
|
210
|
+
write(`oke decide certify: decider "${name}" model "${result.cert.model}" is unpinned\n`);
|
|
211
|
+
}
|
|
212
|
+
for (const [question, row] of Object.entries(result.report)) {
|
|
213
|
+
write(
|
|
214
|
+
`${name}\t${question}\taccuracy ${row.accuracy.toFixed(3)}\tECE ${row.ece.toFixed(3)}\tcoverage ${row.coverage.toFixed(3)}\tp50 ${row.p50Ms}ms\tp95 ${row.p95Ms}ms\tcost ${row.cost}\n`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const next: DecisionLockfile = {
|
|
219
|
+
version: 2,
|
|
220
|
+
decisions: { ...(current?.decisions ?? {}), [options.decision]: { deciders } },
|
|
221
|
+
};
|
|
222
|
+
await Bun.write(lockPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
223
|
+
const { setDecisionDrift } = await import("../elements/ai/decisions/certificate.ts");
|
|
224
|
+
const { persistDecisionDrift } = await import("../elements/ai/decisions/labels.ts");
|
|
225
|
+
setDecisionDrift(options.decision, false);
|
|
226
|
+
persistDecisionDrift(options.decision, false);
|
|
227
|
+
return next;
|
|
228
|
+
}
|
|
229
|
+
|
|
140
230
|
export async function decideCli(args: string[]): Promise<number> {
|
|
141
231
|
const [sub, name, ...rest] = args;
|
|
142
232
|
if (sub === "--help" || sub === "-h" || !sub) {
|
|
143
233
|
console.log(`oke decide promote <name> [--origin URL] [--lock path]
|
|
144
234
|
oke decide labels <name> --export [--out file] [--origin URL]
|
|
235
|
+
oke decide certify <name> --deciders a,b
|
|
236
|
+
oke decide models
|
|
145
237
|
|
|
146
238
|
promote fetches the operator candidate and writes oke-decisions.lock.json.
|
|
147
239
|
labels --export writes reviewed labels as seed JSONL. The file contains production data.
|
|
240
|
+
certify writes one certificate per decider. models lists decision models.
|
|
148
241
|
`);
|
|
149
242
|
return sub ? 0 : 1;
|
|
150
243
|
}
|
|
151
244
|
if (sub === "labels") return exportLabelsCommand(name, rest);
|
|
245
|
+
if (sub === "models") {
|
|
246
|
+
const { fetchDecisionModels } = await import("../elements/ai/decisions/catalog.ts");
|
|
247
|
+
process.stdout.write(await fetchDecisionModels());
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
if (sub === "certify") return certifyCommand(name, rest);
|
|
152
251
|
if (sub !== "promote" || !name) {
|
|
153
|
-
console.error(
|
|
154
|
-
"oke decide: expected `oke decide promote <name>` or `oke decide labels <name> --export`",
|
|
155
|
-
);
|
|
252
|
+
console.error("oke decide: expected promote, labels, certify, or models");
|
|
156
253
|
return 1;
|
|
157
254
|
}
|
|
158
255
|
let origin = process.env.OKE_ORIGIN ?? "http://127.0.0.1:6530";
|
|
@@ -172,3 +269,73 @@ labels --export writes reviewed labels as seed JSONL. The file contains producti
|
|
|
172
269
|
});
|
|
173
270
|
return 0;
|
|
174
271
|
}
|
|
272
|
+
|
|
273
|
+
async function certifyCommand(name: string | undefined, rest: string[]): Promise<number> {
|
|
274
|
+
if (!name) {
|
|
275
|
+
console.error("oke decide: expected `oke decide certify <name> --deciders a,b`");
|
|
276
|
+
return 1;
|
|
277
|
+
}
|
|
278
|
+
const flag = rest.indexOf("--deciders");
|
|
279
|
+
const list = flag >= 0 ? rest[flag + 1] : undefined;
|
|
280
|
+
if (!list) {
|
|
281
|
+
console.error("oke decide certify: --deciders is required");
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
284
|
+
const deciders = list
|
|
285
|
+
.split(",")
|
|
286
|
+
.map((item) => item.trim())
|
|
287
|
+
.filter((item) => item.length > 0);
|
|
288
|
+
const { resolveStartEntry } = await import("./start.ts");
|
|
289
|
+
const root = resolve(process.env["OKE_ROOT_DIR"] ?? ".");
|
|
290
|
+
try {
|
|
291
|
+
await import(await resolveStartEntry(root));
|
|
292
|
+
} catch (error) {
|
|
293
|
+
console.error(
|
|
294
|
+
`oke decide certify: failed to load the app: ${error instanceof Error ? error.message : String(error)}`,
|
|
295
|
+
);
|
|
296
|
+
return 1;
|
|
297
|
+
}
|
|
298
|
+
const manifestPath = resolve(root, "oke.manifest.json");
|
|
299
|
+
const file = Bun.file(manifestPath);
|
|
300
|
+
if (!(await file.exists())) {
|
|
301
|
+
console.error(`oke decide certify: manifest not found: ${manifestPath}`);
|
|
302
|
+
return 1;
|
|
303
|
+
}
|
|
304
|
+
const manifest = (await file.json()) as Manifest;
|
|
305
|
+
const { aiDeciderRegistry, aiDecisionRegistry } = await import("../kernel/element-registries.ts");
|
|
306
|
+
const decl = aiDecisionRegistry.find((item) => item.name === name);
|
|
307
|
+
if (!decl) {
|
|
308
|
+
console.error(`oke decide certify: decision "${name}" is not loaded`);
|
|
309
|
+
return 1;
|
|
310
|
+
}
|
|
311
|
+
const evaluate: Record<string, DecisionEvaluate> = {};
|
|
312
|
+
for (const id of deciders) {
|
|
313
|
+
const decider = aiDeciderRegistry.find((item) => item.name === id);
|
|
314
|
+
if (!decider) {
|
|
315
|
+
console.error(`oke decide certify: decider "${id}" is not loaded`);
|
|
316
|
+
return 1;
|
|
317
|
+
}
|
|
318
|
+
const apiKey = process.env[decider.secret];
|
|
319
|
+
if (!apiKey) {
|
|
320
|
+
console.error(`oke decide certify: secret "${decider.secret}" is not configured`);
|
|
321
|
+
return 1;
|
|
322
|
+
}
|
|
323
|
+
evaluate[id] = async (input) => {
|
|
324
|
+
const { decisionHttp } = await import("../elements/ai/decisions/http.ts");
|
|
325
|
+
const { encodeDecisionRequest, normalizeDecisionResponse, wireQuestions } =
|
|
326
|
+
await import("../elements/ai/decisions/codec.ts");
|
|
327
|
+
const request = { model: decider.model, state: input, questions: wireQuestions(decl.ask) };
|
|
328
|
+
const raw = await decisionHttp({
|
|
329
|
+
url: decider.baseUrl,
|
|
330
|
+
apiKey,
|
|
331
|
+
request,
|
|
332
|
+
body: encodeDecisionRequest(decider.protocol, request),
|
|
333
|
+
timeoutMs: 30_000,
|
|
334
|
+
breakerKey: decider.name,
|
|
335
|
+
});
|
|
336
|
+
return normalizeDecisionResponse(decider.protocol, raw);
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
await certifyDecisionDeciders({ root, manifest, decision: name, deciders, evaluate });
|
|
340
|
+
return 0;
|
|
341
|
+
}
|