okengine 0.22.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +7 -0
- package/manifest.v1.schema.json +44 -0
- package/package.json +32 -19
- package/site/content/docs/ai/mcp.mdx +14 -10
- package/site/content/docs/ai/skills.mdx +1 -1
- package/site/content/docs/elements/ai/agents.mdx +61 -13
- package/site/content/docs/elements/ai/decide.mdx +536 -0
- package/site/content/docs/elements/ai/events.mdx +373 -0
- package/site/content/docs/elements/ai/index.mdx +9 -3
- package/site/content/docs/elements/ai/meta.json +1 -1
- package/site/content/docs/elements/ai/prompts.mdx +4 -2
- package/site/content/docs/elements/channel/index.mdx +4 -4
- package/site/content/docs/elements/channel/receipts.mdx +7 -5
- package/site/content/docs/elements/channel/sms.mdx +2 -1
- package/site/content/docs/elements/flow/index.mdx +22 -18
- package/site/content/docs/elements/gate/auth.mdx +4 -1
- package/site/content/docs/elements/signal/index.mdx +15 -14
- package/site/content/docs/elements/store/index.mdx +1 -1
- package/site/content/docs/elements/store/kv.mdx +5 -5
- package/site/content/docs/plugins/otp.mdx +3 -3
- package/site/content/docs/recipes/dragonfly.mdx +5 -5
- package/site/content/docs/recipes/index.mdx +6 -6
- package/site/content/docs/recipes/postgres.mdx +12 -8
- package/site/content/docs/recipes/redis.mdx +4 -4
- package/site/content/docs/reference/cli.mdx +2 -1
- package/site/content/docs/reference/configuration.mdx +9 -7
- package/site/content/docs/reference/fx.mdx +19 -10
- package/src/auth/api-key-sql.test.ts +44 -0
- package/src/auth/api-key-sql.ts +1 -0
- package/src/auth/api-keys.ts +30 -12
- package/src/auth/config.ts +6 -2
- package/src/auth/gate-auth.test.ts +20 -0
- package/src/auth/tenants.ts +17 -0
- package/src/cli/agents-md.test.ts +25 -0
- package/src/cli/ask-vault-gaps.test.ts +38 -1
- package/src/cli/ask-vault-gaps.ts +35 -2
- package/src/cli/decide.ts +174 -0
- package/src/cli/decision-lock-watch.test.ts +45 -0
- package/src/cli/decision-lock-watch.ts +51 -0
- package/src/cli/dev-app-runner.ts +34 -8
- package/src/cli/dev-auth-secret.test.ts +49 -0
- package/src/cli/dev-auth-secret.ts +49 -0
- package/src/cli/dev-hot.test.ts +41 -0
- package/src/cli/dev-hot.ts +48 -0
- package/src/cli/dev.test.ts +1 -1
- package/src/cli/dev.ts +24 -6
- package/src/cli/doctor-diff.test.ts +29 -1
- package/src/cli/doctor-diff.ts +35 -0
- package/src/cli/eval.ts +142 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/load-config.images.test.ts +2 -2
- package/src/cli/load-config.ts +1 -1
- package/src/cli/manifest-pr-diff.ts +16 -0
- package/src/cli/mcp-from-console.ts +48 -0
- package/src/cli/registry.ts +35 -1
- package/src/client/agent.ts +307 -0
- package/src/client/transport.test.ts +20 -0
- package/src/client/transport.ts +2 -1
- package/src/client/types.ts +1 -1
- package/src/client-react/index.ts +7 -1
- package/src/client-react/use-agent-run.test.ts +271 -0
- package/src/client-react/use-agent-run.ts +193 -0
- package/src/compiler/ai-approval.test.ts +36 -0
- package/src/compiler/ai-repair.test.ts +22 -0
- package/src/compiler/decisions.extract.test.ts +164 -0
- package/src/compiler/effects-fetch.test.ts +77 -0
- package/src/compiler/effects-infer.ts +114 -17
- package/src/compiler/effects-join.test.ts +48 -0
- package/src/compiler/extract.test.ts +28 -0
- package/src/compiler/extract.ts +279 -13
- package/src/compiler/fx-index.ts +58 -4
- package/src/compiler/response.ts +35 -2
- package/src/config/index.ts +21 -3
- package/src/console/index.ts +1 -1
- package/src/console/server/ai-runs-flows.ts +269 -0
- package/src/console/server/ai-runs.test.ts +323 -0
- package/src/console/server/ai-runs.ts +562 -0
- package/src/console/server/ai.ts +12 -1
- package/src/console/server/app.ts +10 -1
- package/src/console/server/decisions-flows.ts +157 -0
- package/src/console/server/decisions.test.ts +66 -0
- package/src/console/server/decisions.ts +229 -0
- package/src/console/server/flows.ts +50 -2
- package/src/console/server/invoke-user-flow.ts +9 -0
- package/src/console/server/runs-ingest.ts +16 -0
- package/src/console/server/serve.ts +5 -0
- package/src/console/server/state.ts +25 -1
- package/src/console/server/store-stats.test.ts +47 -0
- package/src/console/server/store-stats.ts +92 -18
- package/src/console/ui-next/dist/assets/access-page-C_N4qvai.js +4 -0
- package/src/console/ui-next/dist/assets/{agent-disclosure-Bkohc_8X.js → agent-disclosure-Cq1TxniW.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-rL0lHxat.js → cache-glyph-BaI4uxZz.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-Deo4PP18.js → call-pii-button-DNyK7IIY.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-CoJ6amHf.js → collapsible-CRhBJMZR.js} +1 -1
- package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
- package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
- package/src/console/ui-next/dist/assets/decisions-page-D9twj8Xy.js +1 -0
- package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
- package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
- package/src/console/ui-next/dist/assets/duration-tone-CsHWlVPU.js +9 -0
- package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-ZaTJAhR9.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-CPYBzDh0.js → highlighted-json-DQ-EMAfz.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-DTPevKmM.js → http-method-Cn_Fl79s.js} +1 -1
- package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
- package/src/console/ui-next/dist/assets/index-CmRFeWdT.js +58 -0
- package/src/console/ui-next/dist/assets/observability-page-Cn6R_dXz.js +8 -0
- package/src/console/ui-next/dist/assets/{replica-lag-BM27lXld.js → replica-lag-Bx6EZhSe.js} +7 -7
- package/src/console/ui-next/dist/assets/request-meta-DmdiUrfh.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-8P6vGXnn.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-BfGvpFSv.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-CWdUHbc0.js +55 -0
- package/src/console/ui-next/dist/assets/units-page-CZOFZzay.js +1 -0
- package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
- package/src/console/ui-next/dist/assets/vault-page-ki_ishmX.js +2 -0
- package/src/console/ui-next/dist/index.html +4 -3
- package/src/console/ui-next/seed-parked-approval.ts +126 -0
- package/src/console/ui-next/src/client.ts +224 -1
- package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
- package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
- package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
- package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
- package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
- package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
- package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
- package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
- package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
- package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
- package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
- package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
- package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
- package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
- package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
- package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
- package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
- package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
- package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
- package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
- package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
- package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
- package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
- package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
- package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
- package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
- package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
- package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
- package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
- package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +6 -2
- package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
- package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
- package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
- package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
- package/src/console/ui-next/src/router.tsx +17 -0
- package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
- package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
- package/src/console/ui-next/vite.config.ts +9 -0
- package/src/docker/recipes/dragonfly.ts +3 -2
- package/src/docker/recipes/redis.ts +1 -1
- package/src/drivers/ai-anthropic.ts +212 -21
- package/src/drivers/ai-mock.ts +34 -1
- package/src/drivers/ai-openai-compatible.ts +67 -26
- package/src/drivers/ai-preconnect.ts +18 -0
- package/src/drivers/ai-stream.test.ts +62 -0
- package/src/drivers/ai-types.ts +20 -0
- package/src/drivers/journal-postgres.test.ts +22 -1
- package/src/drivers/journal-postgres.ts +511 -13
- package/src/drivers/memory-ddl.test.ts +18 -0
- package/src/drivers/memory.ts +21 -1
- package/src/drivers/pg-rls.test.ts +31 -0
- package/src/drivers/pg-rls.ts +15 -0
- package/src/drivers/postgres.test.ts +9 -0
- package/src/drivers/postgres.ts +49 -11
- package/src/drivers/signal-compete.test.ts +234 -0
- package/src/drivers/signal-postgres.ts +39 -19
- package/src/drivers/signal-redis.ts +167 -11
- package/src/drivers/signal-types.ts +12 -0
- package/src/elements/ai/agent-event-slot.ts +27 -0
- package/src/elements/ai/approval-http.ts +207 -0
- package/src/elements/ai/approval.test.ts +741 -0
- package/src/elements/ai/approval.ts +228 -0
- package/src/elements/ai/decisions/bind.ts +163 -0
- package/src/elements/ai/decisions/certificate.test.ts +291 -0
- package/src/elements/ai/decisions/certificate.ts +377 -0
- package/src/elements/ai/decisions/certify.ts +314 -0
- package/src/elements/ai/decisions/decide.test.ts +953 -0
- package/src/elements/ai/decisions/e2e.test.ts +399 -0
- package/src/elements/ai/decisions/export.test.ts +120 -0
- package/src/elements/ai/decisions/export.ts +146 -0
- package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
- package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
- package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
- package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
- package/src/elements/ai/decisions/http.test.ts +154 -0
- package/src/elements/ai/decisions/http.ts +240 -0
- package/src/elements/ai/decisions/labels.test.ts +282 -0
- package/src/elements/ai/decisions/labels.ts +257 -0
- package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
- package/src/elements/ai/decisions/openrouter.ts +36 -0
- package/src/elements/ai/decisions/provider.ts +102 -0
- package/src/elements/ai/decisions/typesafe.ts +36 -0
- package/src/elements/ai/declare.ts +252 -1
- package/src/elements/ai/events.test.ts +379 -0
- package/src/elements/ai/events.ts +138 -0
- package/src/elements/ai/mcp-protocol.ts +5 -6
- package/src/elements/ai/run-events.test.ts +461 -0
- package/src/elements/ai/run-events.ts +522 -0
- package/src/elements/ai/runtime.ts +1187 -105
- package/src/elements/ai/stream-turn.ts +214 -0
- package/src/elements/ai/stream.live.test.ts +76 -0
- package/src/elements/ai/subagent.test.ts +136 -0
- package/src/elements/ai.test.ts +497 -3
- package/src/elements/ai.ts +16 -1
- package/src/elements/channel/runtime.ts +11 -0
- package/src/elements/channel/sql-ledger.ts +251 -0
- package/src/elements/clock/durable.ts +27 -1
- package/src/elements/signal/runtime.ts +9 -0
- package/src/elements/signal.test.ts +1 -0
- package/src/elements/store/cache.test.ts +120 -15
- package/src/elements/store/cache.ts +200 -39
- package/src/elements/store/prepare-row.test.ts +6 -2
- package/src/elements/store/sql-session.test.ts +53 -0
- package/src/elements/store/sql-session.ts +206 -10
- package/src/elements/store.ts +1 -0
- package/src/i18n/catalogs/ar.ts +5 -0
- package/src/i18n/catalogs/en.ts +5 -0
- package/src/index.ts +2 -0
- package/src/kernel/agent-event-store.ts +448 -0
- package/src/kernel/app.ts +135 -17
- package/src/kernel/auto-cache.test.ts +228 -0
- package/src/kernel/boot-bind/ai.ts +3 -0
- package/src/kernel/boot-bind/channel.ts +63 -2
- package/src/kernel/boot-bind/honor-config.test.ts +40 -10
- package/src/kernel/boot-bind/signal.ts +61 -25
- package/src/kernel/boot-bind/store.test.ts +26 -0
- package/src/kernel/boot-bind/store.ts +82 -6
- package/src/kernel/boot.ts +31 -6
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/capability.ts +3 -0
- package/src/kernel/decision-label-store.ts +441 -0
- package/src/kernel/effects.test.ts +2 -1
- package/src/kernel/effects.ts +13 -1
- package/src/kernel/element-registries.ts +3 -0
- package/src/kernel/errors-compiler.ts +11 -0
- package/src/kernel/errors-text.ts +20 -0
- package/src/kernel/errors.ts +10 -0
- package/src/kernel/flow.test.ts +2 -3
- package/src/kernel/flow.ts +33 -3
- package/src/kernel/fx-call-types.test.ts +32 -0
- package/src/kernel/fx-decide.ts +765 -0
- package/src/kernel/fx-sql-handle.ts +46 -7
- package/src/kernel/fx.test.ts +13 -8
- package/src/kernel/fx.ts +339 -45
- package/src/kernel/http-frame.test.ts +94 -0
- package/src/kernel/http-frame.ts +203 -0
- package/src/kernel/idempotency.test.ts +2 -1
- package/src/kernel/journal.test.ts +114 -0
- package/src/kernel/journal.ts +471 -51
- package/src/kernel/json-result.ts +10 -0
- package/src/kernel/pipeline-tenant.ts +5 -1
- package/src/kernel/pipeline.test.ts +45 -0
- package/src/kernel/pipeline.ts +5 -0
- package/src/kernel/signal-tx.ts +28 -0
- package/src/kernel/sse-id.ts +32 -0
- package/src/kernel/store-transaction.test.ts +95 -0
- package/src/manifest/diff.ts +1 -0
- package/src/manifest/types.ts +29 -2
- package/src/mcp/ai-tools.test.ts +202 -0
- package/src/mcp/authorization.ts +66 -0
- package/src/mcp/docs-server.ts +19 -2
- package/src/mcp/mcp.test.ts +83 -0
- package/src/mcp/protocol.ts +8 -2
- package/src/mcp/server.ts +23 -2
- package/src/mcp/session.ts +3 -0
- package/src/mcp/tools.ts +168 -0
- package/src/mcp/versions.ts +60 -0
- package/src/plugins/index.ts +1 -0
- package/src/plugins/mena.ts +56 -0
- package/src/release/http-graph.test.ts +2 -0
- package/src/release/http-graph.ts +5 -0
- package/src/release/measure.ts +39 -14
- package/src/runs/collect.ts +4 -1
- package/src/runs/parquet.test.ts +27 -1
- package/src/runs/parquet.ts +19 -0
- package/src/runs/types.ts +31 -0
- package/src/runtime/json-code-block.test.ts +106 -47
- package/src/runtime/json-code-block.ts +1209 -46
- package/src/term.test.ts +13 -0
- package/src/term.ts +81 -49
- package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
- package/src/console/ui-next/dist/assets/access-page-Ze6ckjdd.js +0 -4
- package/src/console/ui-next/dist/assets/copy-inline-button-CWG5_ZOh.js +0 -1
- package/src/console/ui-next/dist/assets/detail-header-b_ZzIsmU.js +0 -1
- package/src/console/ui-next/dist/assets/dropdown-menu-nwXEO1Ac.js +0 -1
- package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +0 -9
- package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +0 -1
- package/src/console/ui-next/dist/assets/explorer-empty-C8sSCqYT.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-D2E7BtKK.js +0 -1
- package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
- package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +0 -58
- package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +0 -4
- package/src/console/ui-next/dist/assets/request-meta-BjFT7DNl.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +0 -41
- package/src/console/ui-next/dist/assets/trace-detail-sheet-Cmitq3da.js +0 -2
- package/src/console/ui-next/dist/assets/tree-expand-toggle-CbDIB-7x.js +0 -55
- package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +0 -1
- package/src/console/ui-next/dist/assets/use-vault-list-Cs45Czkt.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-BOMr0go9.js +0 -2
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "gen-fixture",
|
|
3
|
+
"model": "typesafe/jev-1.13-20260917",
|
|
4
|
+
"provider": "TypeSafe",
|
|
5
|
+
"answers": {
|
|
6
|
+
"team": {
|
|
7
|
+
"type": "choice",
|
|
8
|
+
"choice": "technical",
|
|
9
|
+
"probabilities": { "billing": 0.46, "technical": 0.52, "none_of_these": 0.02 },
|
|
10
|
+
"confidence": 0.29
|
|
11
|
+
},
|
|
12
|
+
"severity": {
|
|
13
|
+
"type": "score",
|
|
14
|
+
"score": 1.82,
|
|
15
|
+
"legend": { "0": "low", "1": "medium", "2": "high" },
|
|
16
|
+
"probabilities": { "0": 0.01, "1": 0.16, "2": 0.83 },
|
|
17
|
+
"confidence": 0.73
|
|
18
|
+
},
|
|
19
|
+
"urgent": { "type": "noul", "noul": 0.57 }
|
|
20
|
+
},
|
|
21
|
+
"usage": { "input_tokens": 384, "output_tokens": 71, "cost": 0.000016128 }
|
|
22
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"state": { "ticket": "Checkout is blank after Pay." },
|
|
3
|
+
"model": "jev-1.13.0",
|
|
4
|
+
"questions": {
|
|
5
|
+
"team": {
|
|
6
|
+
"type": "choice",
|
|
7
|
+
"instructions": "Which team should own this ticket?",
|
|
8
|
+
"criteria": {
|
|
9
|
+
"billing": "Payments and refunds",
|
|
10
|
+
"technical": "Bugs and outages",
|
|
11
|
+
"none_of_these": "None of the listed options fit"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"urgent": {
|
|
15
|
+
"type": "noul",
|
|
16
|
+
"instructions": "Does this convey urgency?"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"model": "jev-1.13.0",
|
|
3
|
+
"answers": {
|
|
4
|
+
"team": {
|
|
5
|
+
"type": "choice",
|
|
6
|
+
"choice": "technical",
|
|
7
|
+
"probabilities": { "billing": 0.08, "technical": 0.9, "none_of_these": 0.02 },
|
|
8
|
+
"confidence": 0.85
|
|
9
|
+
},
|
|
10
|
+
"urgent": { "type": "noul", "noul": 0.22 }
|
|
11
|
+
},
|
|
12
|
+
"usage": { "input_tokens": 180, "output_tokens": 24 }
|
|
13
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision HTTP: fixtures parse, and 422 does not open the breaker.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import openrouterResponse from "./fixtures/openrouter-response.json";
|
|
7
|
+
import typesafeResponse from "./fixtures/typesafe-response.json";
|
|
8
|
+
import {
|
|
9
|
+
decisionBreakerOpen,
|
|
10
|
+
decisionHttp,
|
|
11
|
+
parseDecisionResponse,
|
|
12
|
+
resetDecisionBreakers,
|
|
13
|
+
} from "./http.ts";
|
|
14
|
+
import { DecisionOutageError, DecisionRequestError } from "./provider.ts";
|
|
15
|
+
|
|
16
|
+
const request = {
|
|
17
|
+
model: "typesafe/jev-1.13",
|
|
18
|
+
state: { ticket: "Checkout is blank after Pay." },
|
|
19
|
+
questions: {
|
|
20
|
+
urgent: { type: "noul" as const, instructions: "Does this convey urgency?" },
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("decision fixtures", () => {
|
|
25
|
+
test("typesafe and openrouter bodies parse", () => {
|
|
26
|
+
expect(parseDecisionResponse(typesafeResponse).model).toBe("jev-1.13.0");
|
|
27
|
+
expect(parseDecisionResponse(openrouterResponse).provider).toBe("TypeSafe");
|
|
28
|
+
expect(parseDecisionResponse(openrouterResponse).usage.cost).toBe(0.000016128);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("decision breaker", () => {
|
|
33
|
+
test("422 fails fast and leaves the breaker closed", async () => {
|
|
34
|
+
resetDecisionBreakers();
|
|
35
|
+
const fetchImpl = async () => new Response("bad question", { status: 422 });
|
|
36
|
+
await expect(
|
|
37
|
+
decisionHttp({
|
|
38
|
+
url: "https://openrouter.ai/api/alpha/decisions",
|
|
39
|
+
apiKey: "test",
|
|
40
|
+
request,
|
|
41
|
+
timeoutMs: 1000,
|
|
42
|
+
breakerKey: "openrouter-422",
|
|
43
|
+
fetch: fetchImpl,
|
|
44
|
+
}),
|
|
45
|
+
).rejects.toBeInstanceOf(DecisionRequestError);
|
|
46
|
+
expect(decisionBreakerOpen("openrouter-422")).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("429 retries without opening the breaker", async () => {
|
|
50
|
+
resetDecisionBreakers();
|
|
51
|
+
let calls = 0;
|
|
52
|
+
const fetchImpl = async () => {
|
|
53
|
+
calls += 1;
|
|
54
|
+
if (calls === 1)
|
|
55
|
+
return new Response("slow", { status: 429, headers: { "retry-after": "0" } });
|
|
56
|
+
return Response.json({ model: "jev", answers: { urgent: { noul: 0.9 } }, usage: {} });
|
|
57
|
+
};
|
|
58
|
+
const result = await decisionHttp({
|
|
59
|
+
url: "https://example.test/decisions",
|
|
60
|
+
apiKey: "test",
|
|
61
|
+
request,
|
|
62
|
+
timeoutMs: 1000,
|
|
63
|
+
breakerKey: "openrouter-429",
|
|
64
|
+
fetch: fetchImpl,
|
|
65
|
+
});
|
|
66
|
+
expect(result.model).toBe("jev");
|
|
67
|
+
expect(calls).toBe(2);
|
|
68
|
+
expect(decisionBreakerOpen("openrouter-429")).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("500 counts toward the breaker and is not retried", async () => {
|
|
72
|
+
resetDecisionBreakers();
|
|
73
|
+
let calls = 0;
|
|
74
|
+
const fetchImpl = async () => {
|
|
75
|
+
calls += 1;
|
|
76
|
+
return new Response("down", { status: 500 });
|
|
77
|
+
};
|
|
78
|
+
await expect(
|
|
79
|
+
decisionHttp({
|
|
80
|
+
url: "https://example.test/decisions",
|
|
81
|
+
apiKey: "test",
|
|
82
|
+
request,
|
|
83
|
+
timeoutMs: 1000,
|
|
84
|
+
breakerKey: "openrouter-500",
|
|
85
|
+
fetch: fetchImpl,
|
|
86
|
+
}),
|
|
87
|
+
).rejects.toBeInstanceOf(DecisionOutageError);
|
|
88
|
+
expect(calls).toBe(1);
|
|
89
|
+
await decisionHttp({
|
|
90
|
+
url: "https://example.test/decisions",
|
|
91
|
+
apiKey: "test",
|
|
92
|
+
request,
|
|
93
|
+
timeoutMs: 1000,
|
|
94
|
+
breakerKey: "openrouter-500",
|
|
95
|
+
fetch: fetchImpl,
|
|
96
|
+
}).catch(() => undefined);
|
|
97
|
+
await decisionHttp({
|
|
98
|
+
url: "https://example.test/decisions",
|
|
99
|
+
apiKey: "test",
|
|
100
|
+
request,
|
|
101
|
+
timeoutMs: 1000,
|
|
102
|
+
breakerKey: "openrouter-500",
|
|
103
|
+
fetch: fetchImpl,
|
|
104
|
+
}).catch(() => undefined);
|
|
105
|
+
expect(decisionBreakerOpen("openrouter-500")).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("an aborted call is not retried", async () => {
|
|
109
|
+
resetDecisionBreakers();
|
|
110
|
+
let calls = 0;
|
|
111
|
+
const controller = new AbortController();
|
|
112
|
+
controller.abort();
|
|
113
|
+
const fetchImpl = async () => {
|
|
114
|
+
calls += 1;
|
|
115
|
+
return new Response("nope", { status: 429, headers: { "retry-after": "0" } });
|
|
116
|
+
};
|
|
117
|
+
await expect(
|
|
118
|
+
decisionHttp({
|
|
119
|
+
url: "https://example.test/decisions",
|
|
120
|
+
apiKey: "test",
|
|
121
|
+
request: { ...request, signal: controller.signal },
|
|
122
|
+
timeoutMs: 1000,
|
|
123
|
+
breakerKey: "openrouter-abort",
|
|
124
|
+
fetch: fetchImpl,
|
|
125
|
+
}),
|
|
126
|
+
).rejects.toThrow();
|
|
127
|
+
expect(calls).toBeLessThanOrEqual(1);
|
|
128
|
+
expect(decisionBreakerOpen("openrouter-abort")).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("invalid JSON is not retried", async () => {
|
|
132
|
+
resetDecisionBreakers();
|
|
133
|
+
let calls = 0;
|
|
134
|
+
const fetchImpl = async () => {
|
|
135
|
+
calls += 1;
|
|
136
|
+
return new Response("not-json", {
|
|
137
|
+
status: 200,
|
|
138
|
+
headers: { "content-type": "application/json" },
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
await expect(
|
|
142
|
+
decisionHttp({
|
|
143
|
+
url: "https://example.test/decisions",
|
|
144
|
+
apiKey: "test",
|
|
145
|
+
request,
|
|
146
|
+
timeoutMs: 1000,
|
|
147
|
+
breakerKey: "openrouter-json",
|
|
148
|
+
fetch: fetchImpl,
|
|
149
|
+
}),
|
|
150
|
+
).rejects.toBeInstanceOf(DecisionRequestError);
|
|
151
|
+
expect(calls).toBe(1);
|
|
152
|
+
expect(decisionBreakerOpen("openrouter-json")).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Raw decision HTTP: deadline, 429/529 retry, and a breaker that ignores 401/402/422.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DecisionOutageError,
|
|
7
|
+
DecisionRequestError,
|
|
8
|
+
type DecisionRequest,
|
|
9
|
+
type DecisionResponse,
|
|
10
|
+
type DecisionUsage,
|
|
11
|
+
} from "./provider.ts";
|
|
12
|
+
|
|
13
|
+
/** Fetch used by {@link decisionHttp}. Tests inject this. */
|
|
14
|
+
export type DecisionFetch = (input: string, init: RequestInit) => Promise<Response>;
|
|
15
|
+
|
|
16
|
+
/** Options for one decision POST. */
|
|
17
|
+
export interface DecisionHttpOptions {
|
|
18
|
+
readonly url: string;
|
|
19
|
+
readonly apiKey: string;
|
|
20
|
+
readonly request: DecisionRequest;
|
|
21
|
+
readonly timeoutMs: number;
|
|
22
|
+
readonly fetch?: DecisionFetch;
|
|
23
|
+
/** Max attempts including the first. Default 3. */
|
|
24
|
+
readonly attempts?: number;
|
|
25
|
+
/** Breaker key. Calls that share it share the open/closed state. */
|
|
26
|
+
readonly breakerKey: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface BreakerState {
|
|
30
|
+
failures: number;
|
|
31
|
+
openUntil: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const breakers = new Map<string, BreakerState>();
|
|
35
|
+
|
|
36
|
+
const FAILURES_TO_OPEN = 3;
|
|
37
|
+
const OPEN_MS = 30_000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reset breaker state. Tests only.
|
|
41
|
+
*/
|
|
42
|
+
export function resetDecisionBreakers(): void {
|
|
43
|
+
breakers.clear();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether `key` is currently open.
|
|
48
|
+
*
|
|
49
|
+
* @param key - Breaker key
|
|
50
|
+
* @param now - Clock
|
|
51
|
+
*/
|
|
52
|
+
export function decisionBreakerOpen(key: string, now: number = Date.now()): boolean {
|
|
53
|
+
const state = breakers.get(key);
|
|
54
|
+
return state !== undefined && state.openUntil > now;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function noteFailure(key: string, now: number): void {
|
|
58
|
+
const state = breakers.get(key) ?? { failures: 0, openUntil: 0 };
|
|
59
|
+
state.failures += 1;
|
|
60
|
+
if (state.failures >= FAILURES_TO_OPEN) state.openUntil = now + OPEN_MS;
|
|
61
|
+
breakers.set(key, state);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function noteSuccess(key: string): void {
|
|
65
|
+
breakers.delete(key);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const REQUEST_STATUSES = new Set([400, 401, 402, 403, 404, 422]);
|
|
69
|
+
|
|
70
|
+
/** Waits longer than this take the outage path. */
|
|
71
|
+
const RETRY_CAP_MS = 60_000;
|
|
72
|
+
|
|
73
|
+
function retryAfterMs(res: Response, now = Date.now()): number | "outage" {
|
|
74
|
+
const header = res.headers.get("retry-after");
|
|
75
|
+
if (!header) return 250;
|
|
76
|
+
const seconds = Number(header);
|
|
77
|
+
const ms =
|
|
78
|
+
Number.isFinite(seconds) && seconds >= 0
|
|
79
|
+
? seconds * 1000
|
|
80
|
+
: Number.isFinite(Date.parse(header))
|
|
81
|
+
? Math.max(0, Date.parse(header) - now)
|
|
82
|
+
: 250;
|
|
83
|
+
if (ms > RETRY_CAP_MS) return "outage";
|
|
84
|
+
return ms;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function abortError(signal: AbortSignal): Error {
|
|
88
|
+
return signal.reason instanceof Error
|
|
89
|
+
? signal.reason
|
|
90
|
+
: new DOMException("The operation was aborted", "AbortError");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function waitForRetry(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
|
94
|
+
if (signal?.aborted) throw abortError(signal);
|
|
95
|
+
await new Promise<void>((resolve, reject) => {
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
signal?.removeEventListener("abort", onAbort);
|
|
98
|
+
resolve();
|
|
99
|
+
}, ms);
|
|
100
|
+
const onAbort = () => {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
reject(abortError(signal!));
|
|
103
|
+
};
|
|
104
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function usageFrom(raw: unknown): DecisionUsage {
|
|
109
|
+
if (!raw || typeof raw !== "object") return {};
|
|
110
|
+
const usage = raw as {
|
|
111
|
+
input_tokens?: unknown;
|
|
112
|
+
output_tokens?: unknown;
|
|
113
|
+
cost?: unknown;
|
|
114
|
+
inputTokens?: unknown;
|
|
115
|
+
outputTokens?: unknown;
|
|
116
|
+
};
|
|
117
|
+
const input = usage.input_tokens ?? usage.inputTokens;
|
|
118
|
+
const output = usage.output_tokens ?? usage.outputTokens;
|
|
119
|
+
return {
|
|
120
|
+
...(typeof input === "number" ? { inputTokens: input } : {}),
|
|
121
|
+
...(typeof output === "number" ? { outputTokens: output } : {}),
|
|
122
|
+
...(typeof usage.cost === "number" ? { cost: usage.cost } : {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Parse a provider JSON body into {@link DecisionResponse}.
|
|
128
|
+
*
|
|
129
|
+
* @param body - Parsed JSON
|
|
130
|
+
*/
|
|
131
|
+
export function parseDecisionResponse(body: unknown): DecisionResponse {
|
|
132
|
+
if (!body || typeof body !== "object") {
|
|
133
|
+
throw new DecisionRequestError(422, "decision response was not an object");
|
|
134
|
+
}
|
|
135
|
+
const record = body as {
|
|
136
|
+
model?: unknown;
|
|
137
|
+
provider?: unknown;
|
|
138
|
+
answers?: unknown;
|
|
139
|
+
usage?: unknown;
|
|
140
|
+
};
|
|
141
|
+
if (typeof record.model !== "string" || !record.answers || typeof record.answers !== "object") {
|
|
142
|
+
throw new DecisionRequestError(422, "decision response missing model or answers");
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
model: record.model,
|
|
146
|
+
...(typeof record.provider === "string" ? { provider: record.provider } : {}),
|
|
147
|
+
answers: record.answers as Readonly<Record<string, unknown>>,
|
|
148
|
+
usage: usageFrom(record.usage),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* POST one decision request.
|
|
154
|
+
*
|
|
155
|
+
* @param options - URL, key, body, deadline, breaker
|
|
156
|
+
*/
|
|
157
|
+
export async function decisionHttp(options: DecisionHttpOptions): Promise<DecisionResponse> {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
if (decisionBreakerOpen(options.breakerKey, now)) {
|
|
160
|
+
throw new DecisionOutageError(`decision breaker open for ${options.breakerKey}`);
|
|
161
|
+
}
|
|
162
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
163
|
+
const attempts = options.attempts ?? 3;
|
|
164
|
+
let lastError: unknown;
|
|
165
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
166
|
+
const local = new AbortController();
|
|
167
|
+
const timer = setTimeout(() => local.abort(), options.timeoutMs);
|
|
168
|
+
const parent = options.request.signal;
|
|
169
|
+
const onParent = () => local.abort();
|
|
170
|
+
parent?.addEventListener("abort", onParent);
|
|
171
|
+
try {
|
|
172
|
+
const res = await fetchImpl(options.url, {
|
|
173
|
+
method: "POST",
|
|
174
|
+
headers: {
|
|
175
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
176
|
+
"content-type": "application/json",
|
|
177
|
+
},
|
|
178
|
+
body: JSON.stringify({
|
|
179
|
+
model: options.request.model,
|
|
180
|
+
state: options.request.state,
|
|
181
|
+
questions: options.request.questions,
|
|
182
|
+
}),
|
|
183
|
+
signal: local.signal,
|
|
184
|
+
});
|
|
185
|
+
if (REQUEST_STATUSES.has(res.status)) {
|
|
186
|
+
const text = await res.text();
|
|
187
|
+
throw new DecisionRequestError(
|
|
188
|
+
res.status as 400 | 401 | 402 | 403 | 404 | 422,
|
|
189
|
+
text.slice(0, 500),
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
if (res.status === 429) {
|
|
193
|
+
lastError = new Error(`decision HTTP ${res.status}`);
|
|
194
|
+
const wait = retryAfterMs(res);
|
|
195
|
+
if (wait === "outage") throw new DecisionOutageError(`decision HTTP ${res.status}`);
|
|
196
|
+
if (attempt < attempts) {
|
|
197
|
+
await waitForRetry(wait, parent);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
throw new DecisionOutageError(`decision HTTP ${res.status}`);
|
|
201
|
+
}
|
|
202
|
+
if (res.status === 529 || res.status >= 500) {
|
|
203
|
+
noteFailure(options.breakerKey, Date.now());
|
|
204
|
+
lastError = new Error(`decision HTTP ${res.status}`);
|
|
205
|
+
const wait = retryAfterMs(res);
|
|
206
|
+
if (wait === "outage") throw new DecisionOutageError(`decision HTTP ${res.status}`);
|
|
207
|
+
if (res.status === 529 && attempt < attempts) {
|
|
208
|
+
await waitForRetry(wait, parent);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
throw new DecisionOutageError(`decision HTTP ${res.status}`);
|
|
212
|
+
}
|
|
213
|
+
if (!res.ok) {
|
|
214
|
+
throw new DecisionOutageError(`decision HTTP ${res.status}`);
|
|
215
|
+
}
|
|
216
|
+
let body: unknown;
|
|
217
|
+
try {
|
|
218
|
+
body = await res.json();
|
|
219
|
+
} catch {
|
|
220
|
+
throw new DecisionRequestError(422, "decision response was not JSON");
|
|
221
|
+
}
|
|
222
|
+
const parsed = parseDecisionResponse(body);
|
|
223
|
+
noteSuccess(options.breakerKey);
|
|
224
|
+
return parsed;
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (parent?.aborted) throw abortError(parent);
|
|
227
|
+
if (err instanceof DecisionRequestError) throw err;
|
|
228
|
+
if (err instanceof DecisionOutageError) throw err;
|
|
229
|
+
noteFailure(options.breakerKey, Date.now());
|
|
230
|
+
lastError = err;
|
|
231
|
+
throw new DecisionOutageError(
|
|
232
|
+
err instanceof Error ? err.message : "decision transport failed",
|
|
233
|
+
);
|
|
234
|
+
} finally {
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
parent?.removeEventListener("abort", onParent);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
throw new DecisionOutageError(lastError instanceof Error ? lastError.message : "decision failed");
|
|
240
|
+
}
|