jev-layer 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +45 -34
  2. package/CONTRIBUTING.md +146 -146
  3. package/LICENSE +21 -21
  4. package/README.md +123 -126
  5. package/README.ru.md +138 -117
  6. package/README.zh-CN.md +137 -117
  7. package/RELEASE.md +53 -53
  8. package/SECURITY.md +56 -56
  9. package/bin/jev.mjs +213 -213
  10. package/config/codex.mcp.toml +8 -8
  11. package/config/generic-mcp.json +13 -13
  12. package/config/hermes.mcp.yaml +9 -9
  13. package/config/jev.example.json +22 -22
  14. package/config/omp.mcp.json +14 -14
  15. package/config/providers.env.example +13 -13
  16. package/docs/AGENT-IMPLEMENTATION.md +250 -0
  17. package/docs/COMPATIBILITY.md +15 -15
  18. package/docs/HERMES-LOCAL-AGENT-HANDOFF.ru.md +177 -0
  19. package/docs/PROVIDERS.md +67 -0
  20. package/docs/SCHEMA-VERSIONING.md +96 -94
  21. package/docs/architecture.svg +77 -0
  22. package/docs/jev-layer-banner.svg +38 -0
  23. package/examples/capabilities.json +39 -39
  24. package/examples/route-request.json +28 -28
  25. package/integrations/codex/.codex-plugin/plugin.json +19 -19
  26. package/integrations/codex/.mcp.json +11 -11
  27. package/integrations/codex/AGENTS.md +1 -1
  28. package/integrations/codex/run-mcp.mjs +9 -9
  29. package/integrations/codex/skills/jev-route/SKILL.md +17 -17
  30. package/integrations/hermes/__init__.py +150 -51
  31. package/integrations/hermes/plugin.yaml +10 -5
  32. package/integrations/hermes/schemas.py +89 -12
  33. package/integrations/omp/extension.js +105 -105
  34. package/integrations/template/README.md +10 -10
  35. package/integrations/template/adapter.mjs +87 -87
  36. package/package.json +60 -59
  37. package/scripts/benchmark.mjs +35 -35
  38. package/scripts/browser-benchmark.mjs +92 -92
  39. package/scripts/browser-e2e.mjs +117 -117
  40. package/scripts/capability-e2e.mjs +31 -31
  41. package/scripts/clean-install-smoke.mjs +162 -162
  42. package/scripts/codex-mcp-smoke.mjs +119 -119
  43. package/scripts/context-filter-e2e.mjs +45 -45
  44. package/scripts/fail-open-smoke.mjs +120 -120
  45. package/scripts/feature-flags-smoke.mjs +46 -46
  46. package/scripts/mcp-receipt-smoke.mjs +113 -98
  47. package/scripts/model-route-report.mjs +15 -0
  48. package/scripts/openrouter-choice.mjs +59 -59
  49. package/scripts/replay-eval.mjs +124 -124
  50. package/scripts/smoke.mjs +34 -34
  51. package/scripts/supervision-e2e.mjs +138 -109
  52. package/src/browser.mjs +571 -569
  53. package/src/cli.mjs +33 -33
  54. package/src/config.mjs +85 -75
  55. package/src/context-filter.mjs +56 -56
  56. package/src/contract.mjs +72 -72
  57. package/src/discovery.mjs +65 -65
  58. package/src/hermes-adapter.mjs +123 -0
  59. package/src/mcp-server.mjs +267 -210
  60. package/src/model-route-metrics.mjs +89 -0
  61. package/src/model-routing.mjs +87 -0
  62. package/src/providers/demo.mjs +52 -52
  63. package/src/providers/index.mjs +12 -0
  64. package/src/providers/typesafe.mjs +126 -126
  65. package/src/receipts.mjs +227 -226
  66. package/src/registry.mjs +109 -109
  67. package/src/relevance-filter.mjs +99 -99
  68. package/src/route.mjs +219 -221
  69. package/src/shadow-compaction.mjs +170 -0
  70. package/src/supervision.mjs +268 -244
  71. package/test/browser.test.mjs +199 -199
  72. package/test/capability.test.mjs +54 -54
  73. package/test/context-filter.test.mjs +65 -65
  74. package/test/hermes-adapter.test.mjs +62 -0
  75. package/test/model-route-metrics.test.mjs +61 -0
  76. package/test/model-routing.test.mjs +59 -0
  77. package/test/openrouter-provider.test.mjs +55 -55
  78. package/test/provider-transports.test.mjs +205 -0
  79. package/test/providers.test.mjs +126 -0
  80. package/test/receipts.test.mjs +71 -71
  81. package/test/route.test.mjs +74 -74
  82. package/test/shadow-compaction.test.mjs +67 -0
  83. package/test/supervision.test.mjs +117 -99
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Read-only analysis for model-route shadow cases. Recommendations stay
3
+ * advisory: this module joins evidence; it never changes host model settings.
4
+ */
5
+ export function buildModelRouteReport(records = []) {
6
+ const routes = new Map();
7
+ const executions = new Map();
8
+ for (const record of Array.isArray(records) ? records : []) {
9
+ if (!record || typeof record !== "object" || typeof record.correlation_id !== "string") continue;
10
+ if (record.record_type === "routing_case" && record.request?.context?.model_route?.mode === "shadow") routes.set(record.correlation_id, record);
11
+ if (record.record_type === "execution_receipt") executions.set(record.correlation_id, record);
12
+ }
13
+
14
+ const rows = [...routes.values()].map((route) => toRow(route, executions.get(route.correlation_id))).sort((a, b) => a.recommended_model_id.localeCompare(b.recommended_model_id));
15
+ return {
16
+ schema_version: 1,
17
+ mode: "shadow",
18
+ generated_at: new Date().toISOString(),
19
+ summary: summarize(rows),
20
+ models: groupByRecommendation(rows),
21
+ };
22
+ }
23
+
24
+ function toRow(route, execution) {
25
+ const result = execution?.host?.result?.model_route;
26
+ const recommended = string(route.decision?.selected, "unrecommended");
27
+ const actual = string(result?.actual_model_id, null);
28
+ const outcome = string(result?.outcome, execution ? string(execution.host?.status, "unknown") : null);
29
+ return {
30
+ correlation_id: route.correlation_id,
31
+ recommended_model_id: recommended,
32
+ actual_model_id: actual,
33
+ recommendation_match: actual !== null && actual === recommended,
34
+ outcome,
35
+ retry_count: boundedInteger(result?.retry_count),
36
+ host_duration_ms: finite(execution?.host?.duration_ms),
37
+ jev_latency_ms: finite(route.decision?.receipt?.latency_ms),
38
+ jev_cost_usd: finite(route.decision?.receipt?.cost_usd),
39
+ };
40
+ }
41
+
42
+ function summarize(rows) {
43
+ const matched = rows.filter((row) => row.recommendation_match).length;
44
+ const actual = rows.filter((row) => row.actual_model_id !== null).length;
45
+ return {
46
+ decisions: rows.length,
47
+ with_host_outcome: rows.filter((row) => row.outcome !== null).length,
48
+ with_actual_model: actual,
49
+ recommendation_matches: matched,
50
+ recommendation_match_rate: actual ? matched / actual : null,
51
+ verified_outcomes: rows.filter((row) => row.outcome === "verified" || row.outcome === "completed").length,
52
+ failed_outcomes: rows.filter((row) => row.outcome === "failed").length,
53
+ total_retry_count: rows.reduce((sum, row) => sum + (row.retry_count ?? 0), 0),
54
+ total_jev_cost_usd: sum(rows.map((row) => row.jev_cost_usd)),
55
+ average_jev_latency_ms: average(rows.map((row) => row.jev_latency_ms)),
56
+ average_host_duration_ms: average(rows.map((row) => row.host_duration_ms)),
57
+ };
58
+ }
59
+
60
+ function groupByRecommendation(rows) {
61
+ const grouped = new Map();
62
+ for (const row of rows) {
63
+ const group = grouped.get(row.recommended_model_id) ?? {
64
+ recommended_model_id: row.recommended_model_id,
65
+ decisions: 0,
66
+ with_host_outcome: 0,
67
+ recommendation_matches: 0,
68
+ verified_outcomes: 0,
69
+ failed_outcomes: 0,
70
+ total_retry_count: 0,
71
+ host_actual_model_ids: {},
72
+ };
73
+ group.decisions += 1;
74
+ if (row.outcome !== null) group.with_host_outcome += 1;
75
+ if (row.recommendation_match) group.recommendation_matches += 1;
76
+ if (row.outcome === "verified" || row.outcome === "completed") group.verified_outcomes += 1;
77
+ if (row.outcome === "failed") group.failed_outcomes += 1;
78
+ group.total_retry_count += row.retry_count ?? 0;
79
+ if (row.actual_model_id) group.host_actual_model_ids[row.actual_model_id] = (group.host_actual_model_ids[row.actual_model_id] ?? 0) + 1;
80
+ grouped.set(row.recommended_model_id, group);
81
+ }
82
+ return [...grouped.values()].sort((a, b) => a.recommended_model_id.localeCompare(b.recommended_model_id));
83
+ }
84
+
85
+ function string(value, fallback) { return typeof value === "string" && value.trim() ? value.trim() : fallback; }
86
+ function finite(value) { return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null; }
87
+ function boundedInteger(value) { return Number.isInteger(value) && value >= 0 ? value : null; }
88
+ function sum(values) { return values.reduce((total, value) => total + (value ?? 0), 0); }
89
+ function average(values) { const present = values.filter((value) => value !== null); return present.length ? sum(present) / present.length : null; }
@@ -0,0 +1,87 @@
1
+ import { routeRequest } from "./route.mjs";
2
+
3
+ /**
4
+ * Recommend one host-declared model profile for the next model call.
5
+ * This is a shadow decision: it never mutates the host model/provider setting.
6
+ */
7
+ export async function recommendModelRoute({
8
+ intent,
9
+ context = {},
10
+ models,
11
+ harness = "unknown",
12
+ policy = {},
13
+ provider = "demo",
14
+ config,
15
+ } = {}) {
16
+ if (!Array.isArray(models) || models.length === 0) throw new TypeError("models[] is required");
17
+ const profiles = models.map(normalizeProfile);
18
+ const profileById = new Map(profiles.map((profile) => [profile.id, profile]));
19
+ const decision = await routeRequest({
20
+ harness,
21
+ intent,
22
+ context: { ...context, model_route: { mode: "shadow", profiles: profiles.map(publicProfile) } },
23
+ capabilities: profiles.map(toCapability),
24
+ policy: {
25
+ ...policy,
26
+ // A recommendation must never create an approval boundary by itself.
27
+ confirmation_risk_levels: [],
28
+ },
29
+ }, { provider, config });
30
+ return {
31
+ ...decision,
32
+ route_mode: "shadow",
33
+ recommended_model: decision.selected ? publicProfile(profileById.get(decision.selected)) : null,
34
+ };
35
+ }
36
+
37
+ function normalizeProfile(value, index) {
38
+ if (!value || typeof value !== "object") throw new TypeError(`models[${index}] must be an object`);
39
+ const id = required(value.id, `models[${index}].id`);
40
+ const model = required(value.model ?? value.id, `models[${index}].model`);
41
+ const provider = string(value.provider, "unknown");
42
+ const reasoning_effort = string(value.reasoning_effort, "default");
43
+ const description = string(value.description, `${provider}/${model}; reasoning=${reasoning_effort}`);
44
+ return {
45
+ id,
46
+ provider,
47
+ model,
48
+ reasoning_effort,
49
+ description,
50
+ available: value.available !== false && value.availability?.available !== false,
51
+ availability_reason: value.availability?.reason ?? null,
52
+ };
53
+ }
54
+
55
+ function toCapability(profile) {
56
+ return {
57
+ id: profile.id,
58
+ kind: "model",
59
+ name: profile.model,
60
+ description: profile.description,
61
+ risk: "low",
62
+ available: profile.available,
63
+ availability: { available: profile.available, reason: profile.availability_reason },
64
+ source: profile.provider,
65
+ metadata: { model: profile.model },
66
+ };
67
+ }
68
+
69
+ function publicProfile(profile) {
70
+ if (!profile) return null;
71
+ return {
72
+ id: profile.id,
73
+ provider: profile.provider,
74
+ model: profile.model,
75
+ reasoning_effort: profile.reasoning_effort,
76
+ available: profile.available,
77
+ };
78
+ }
79
+
80
+ function required(value, label) {
81
+ if (typeof value !== "string" || !value.trim()) throw new TypeError(`${label} is required`);
82
+ return value.trim();
83
+ }
84
+
85
+ function string(value, fallback) {
86
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
87
+ }
@@ -1,52 +1,52 @@
1
- import { stableJson } from "../contract.mjs";
2
-
3
- const TOKEN_RE = /[\p{L}\p{N}_-]+/gu;
4
-
5
- export class DemoProvider {
6
- name = "jev-demo";
7
-
8
- async decide({ state, candidates }) {
9
- const requestTokens = tokens(`${state.intent} ${stableJson(state.context ?? {})}`);
10
- const scored = candidates.map((candidate) => {
11
- const candidateTokens = tokens(`${candidate.id} ${candidate.name} ${candidate.description}`);
12
- const overlap = [...requestTokens].filter((token) => candidateTokens.has(token)).length;
13
- return { id: candidate.id, score: overlap + 0.01 };
14
- });
15
- const total = scored.reduce((sum, item) => sum + item.score, 0);
16
- const probabilities = Object.fromEntries(scored.map(({ id, score }) => [id, score / total]));
17
- const choice = [...scored].sort((left, right) => right.score - left.score || left.id.localeCompare(right.id))[0]?.id ?? "";
18
- const top = choice ? probabilities[choice] : 0;
19
- const confidence = scored.length <= 1
20
- ? 1
21
- : Math.max(0, Math.min(1, (top - 1 / scored.length) / Math.max(1 - 1 / scored.length, 0.0001)));
22
- return {
23
- model: "jev-demo",
24
- answers: {
25
- tool: {
26
- type: "choice",
27
- choice,
28
- probabilities,
29
- confidence,
30
- },
31
- },
32
- usage: { input_bytes: Buffer.byteLength(stableJson(state), "utf8"), output_bytes: 0 },
33
- };
34
- }
35
-
36
- async evaluate({ state, questions }) {
37
- const supplied = state.context?.supervision?.judgments ?? {};
38
- const answers = Object.fromEntries(Object.keys(questions).map((id) => {
39
- const value = typeof supplied[id] === "number" ? Math.max(0, Math.min(1, supplied[id])) : 0.5;
40
- return [id, { type: "noul", probability: value, confidence: 1 }];
41
- }));
42
- return {
43
- model: "jev-demo",
44
- answers,
45
- usage: { input_bytes: Buffer.byteLength(stableJson(state), "utf8"), output_bytes: 0 },
46
- };
47
- }
48
- }
49
-
50
- function tokens(value) {
51
- return new Set(value.toLowerCase().match(TOKEN_RE) ?? []);
52
- }
1
+ import { stableJson } from "../contract.mjs";
2
+
3
+ const TOKEN_RE = /[\p{L}\p{N}_-]+/gu;
4
+
5
+ export class DemoProvider {
6
+ name = "jev-demo";
7
+
8
+ async decide({ state, candidates }) {
9
+ const requestTokens = tokens(`${state.intent} ${stableJson(state.context ?? {})}`);
10
+ const scored = candidates.map((candidate) => {
11
+ const candidateTokens = tokens(`${candidate.id} ${candidate.name} ${candidate.description}`);
12
+ const overlap = [...requestTokens].filter((token) => candidateTokens.has(token)).length;
13
+ return { id: candidate.id, score: overlap + 0.01 };
14
+ });
15
+ const total = scored.reduce((sum, item) => sum + item.score, 0);
16
+ const probabilities = Object.fromEntries(scored.map(({ id, score }) => [id, score / total]));
17
+ const choice = [...scored].sort((left, right) => right.score - left.score || left.id.localeCompare(right.id))[0]?.id ?? "";
18
+ const top = choice ? probabilities[choice] : 0;
19
+ const confidence = scored.length <= 1
20
+ ? 1
21
+ : Math.max(0, Math.min(1, (top - 1 / scored.length) / Math.max(1 - 1 / scored.length, 0.0001)));
22
+ return {
23
+ model: "jev-demo",
24
+ answers: {
25
+ tool: {
26
+ type: "choice",
27
+ choice,
28
+ probabilities,
29
+ confidence,
30
+ },
31
+ },
32
+ usage: { input_bytes: Buffer.byteLength(stableJson(state), "utf8"), output_bytes: 0 },
33
+ };
34
+ }
35
+
36
+ async evaluate({ state, questions }) {
37
+ const supplied = state.context?.supervision?.judgments ?? {};
38
+ const answers = Object.fromEntries(Object.keys(questions).map((id) => {
39
+ const value = typeof supplied[id] === "number" ? Math.max(0, Math.min(1, supplied[id])) : 0.5;
40
+ return [id, { type: "noul", probability: value, confidence: 1 }];
41
+ }));
42
+ return {
43
+ model: "jev-demo",
44
+ answers,
45
+ usage: { input_bytes: Buffer.byteLength(stableJson(state), "utf8"), output_bytes: 0 },
46
+ };
47
+ }
48
+ }
49
+
50
+ function tokens(value) {
51
+ return new Set(value.toLowerCase().match(TOKEN_RE) ?? []);
52
+ }
@@ -0,0 +1,12 @@
1
+ import { configuredProviderOptions } from "../config.mjs";
2
+ import { DemoProvider } from "./demo.mjs";
3
+ import { OpenRouterDecisionsProvider, TypeSafeProvider } from "./typesafe.mjs";
4
+
5
+ export function resolveProvider(provider, options = {}) {
6
+ if (provider && typeof provider === "object") return provider;
7
+ if (provider === "demo") return new DemoProvider();
8
+ const settings = { ...configuredProviderOptions(options.config, provider), ...options[provider] };
9
+ if (provider === "openrouter") return new OpenRouterDecisionsProvider(settings);
10
+ if (provider === "typesafe") return new TypeSafeProvider(settings);
11
+ throw new Error(`unsupported provider: ${provider}`);
12
+ }
@@ -1,126 +1,126 @@
1
- import { stableJson } from "../contract.mjs";
2
-
3
- export class TypeSafeProvider {
4
- name = "typesafe";
5
-
6
- constructor({ apiKey = process.env.TYPESAFE_API_KEY, endpoint = process.env.TYPESAFE_ENDPOINT ?? "https://api.typesafe.ai/v1/systemone", model = process.env.TYPESAFE_MODEL ?? "jev-latest", timeoutMs = 2_000 } = {}) {
7
- this.apiKey = apiKey;
8
- this.endpoint = endpoint;
9
- this.model = model;
10
- this.timeoutMs = timeoutMs;
11
- }
12
-
13
- async decide({ state, candidates }) {
14
- const criteria = Object.fromEntries(candidates.map((candidate) => [candidate.id, {
15
- name: candidate.name,
16
- kind: candidate.kind,
17
- description: candidate.description,
18
- risk: candidate.risk,
19
- }]));
20
- return this.evaluate({
21
- state,
22
- questions: {
23
- tool: {
24
- type: "choice",
25
- instructions: "Which single capability should handle this request? Choose only from the supplied options.",
26
- criteria,
27
- },
28
- },
29
- });
30
- }
31
-
32
- async evaluate({ state, questions }) {
33
- if (!this.apiKey) throw new Error("TYPESAFE_API_KEY is not configured");
34
- const response = await fetch(this.endpoint, {
35
- method: "POST",
36
- headers: {
37
- Authorization: `Bearer ${this.apiKey}`,
38
- "Content-Type": "application/json",
39
- },
40
- body: JSON.stringify({ state, model: this.model, questions }),
41
- signal: AbortSignal.timeout(this.timeoutMs),
42
- }).catch((error) => {
43
- if (error?.name === "TimeoutError") throw new Error(`TypeSafe request timed out after ${this.timeoutMs}ms`);
44
- throw error;
45
- });
46
-
47
- if (!response.ok) {
48
- const body = await response.text();
49
- throw new Error(`TypeSafe returned HTTP ${response.status}: ${body.slice(0, 240)}`);
50
- }
51
- const raw = await response.json();
52
- if (!raw || typeof raw !== "object" || !raw.answers || typeof raw.answers !== "object") {
53
- throw new Error("TypeSafe response has no answers object");
54
- }
55
- return raw;
56
- }
57
- }
58
-
59
- export class OpenRouterDecisionsProvider {
60
- name = "openrouter:typesafe/jev-1.13";
61
-
62
- constructor({
63
- apiKey = process.env.OPENROUTER_API_KEY,
64
- endpoint = process.env.OPENROUTER_DECISIONS_ENDPOINT ?? "https://openrouter.ai/api/alpha/decisions",
65
- model = process.env.OPENROUTER_DECISIONS_MODEL ?? "typesafe/jev-1.13",
66
- timeoutMs = 5_000,
67
- fetchImpl = globalThis.fetch,
68
- httpReferer = process.env.OPENROUTER_HTTP_REFERER,
69
- appTitle = process.env.OPENROUTER_APP_TITLE,
70
- } = {}) {
71
- this.apiKey = apiKey;
72
- this.endpoint = endpoint;
73
- this.model = model;
74
- this.timeoutMs = timeoutMs;
75
- this.fetchImpl = fetchImpl;
76
- this.httpReferer = httpReferer;
77
- }
78
- async decide({ state, candidates }) {
79
- const criteria = Object.fromEntries(candidates.map((candidate) => [candidate.id, {
80
- name: candidate.name,
81
- kind: candidate.kind,
82
- description: candidate.description,
83
- risk: candidate.risk,
84
- }]));
85
- return this.evaluate({
86
- state,
87
- questions: {
88
- tool: {
89
- type: "choice",
90
- instructions: "Which single capability should handle this request? Choose only from the supplied options.",
91
- criteria,
92
- },
93
- },
94
- });
95
- }
96
-
97
- async evaluate({ state, questions }) {
98
- if (!this.apiKey) throw new Error("OPENROUTER_API_KEY is not configured");
99
- if (typeof this.fetchImpl !== "function") throw new Error("fetch is unavailable");
100
-
101
- const response = await this.fetchImpl(this.endpoint, {
102
- method: "POST",
103
- headers: {
104
- Authorization: `Bearer ${this.apiKey}`,
105
- "Content-Type": "application/json",
106
- ...(this.httpReferer ? { "HTTP-Referer": this.httpReferer } : {}),
107
- ...(this.appTitle ? { "X-Title": this.appTitle } : {}),
108
- },
109
- body: JSON.stringify({ model: this.model, questions, state }),
110
- signal: AbortSignal.timeout(this.timeoutMs),
111
- }).catch((error) => {
112
- if (error?.name === "TimeoutError") throw new Error(`OpenRouter Decisions request timed out after ${this.timeoutMs}ms`);
113
- throw error;
114
- });
115
-
116
- if (!response.ok) {
117
- const body = await response.text();
118
- throw new Error(`OpenRouter Decisions returned HTTP ${response.status}: ${body.slice(0, 240)}`);
119
- }
120
- const raw = await response.json();
121
- if (!raw || typeof raw !== "object" || !raw.answers || typeof raw.answers !== "object") {
122
- throw new Error("OpenRouter Decisions response has no answers object");
123
- }
124
- return raw;
125
- }
126
- }
1
+ export class TypeSafeProvider {
2
+ name = "typesafe";
3
+
4
+ constructor({ apiKey = process.env.TYPESAFE_API_KEY, endpoint = process.env.TYPESAFE_ENDPOINT ?? "https://api.typesafe.ai/v1/systemone", model = process.env.TYPESAFE_MODEL ?? "jev-latest", timeoutMs = 2_000, fetchImpl = globalThis.fetch } = {}) {
5
+ this.apiKey = apiKey;
6
+ this.endpoint = endpoint;
7
+ this.model = model;
8
+ this.timeoutMs = timeoutMs;
9
+ this.fetchImpl = fetchImpl;
10
+ }
11
+
12
+ async decide({ state, candidates }) {
13
+ const criteria = Object.fromEntries(candidates.map((candidate) => [candidate.id, {
14
+ name: candidate.name,
15
+ kind: candidate.kind,
16
+ description: candidate.description,
17
+ risk: candidate.risk,
18
+ }]));
19
+ return this.evaluate({
20
+ state,
21
+ questions: {
22
+ tool: {
23
+ type: "choice",
24
+ instructions: "Which single capability should handle this request? Choose only from the supplied options.",
25
+ criteria,
26
+ },
27
+ },
28
+ });
29
+ }
30
+
31
+ async evaluate({ state, questions }) {
32
+ if (!this.apiKey) throw new Error("TYPESAFE_API_KEY is not configured");
33
+ if (typeof this.fetchImpl !== "function") throw new Error("fetch is unavailable");
34
+ const response = await this.fetchImpl(this.endpoint, {
35
+ method: "POST",
36
+ headers: {
37
+ Authorization: `Bearer ${this.apiKey}`,
38
+ "Content-Type": "application/json",
39
+ },
40
+ body: JSON.stringify({ state, model: this.model, questions }),
41
+ signal: AbortSignal.timeout(this.timeoutMs),
42
+ }).catch((error) => {
43
+ if (error?.name === "TimeoutError") throw new Error(`TypeSafe request timed out after ${this.timeoutMs}ms`);
44
+ throw error;
45
+ });
46
+
47
+ if (!response.ok) {
48
+ const body = await response.text();
49
+ throw new Error(`TypeSafe returned HTTP ${response.status}: ${body.slice(0, 240)}`);
50
+ }
51
+ const raw = await response.json();
52
+ if (!raw || typeof raw !== "object" || !raw.answers || typeof raw.answers !== "object") {
53
+ throw new Error("TypeSafe response has no answers object");
54
+ }
55
+ return raw;
56
+ }
57
+ }
58
+
59
+ export class OpenRouterDecisionsProvider {
60
+ constructor({
61
+ apiKey = process.env.OPENROUTER_API_KEY,
62
+ endpoint = process.env.OPENROUTER_DECISIONS_ENDPOINT ?? "https://openrouter.ai/api/alpha/decisions",
63
+ model = process.env.OPENROUTER_DECISIONS_MODEL ?? "typesafe/jev-1.13",
64
+ timeoutMs = 5_000,
65
+ fetchImpl = globalThis.fetch,
66
+ httpReferer = process.env.OPENROUTER_HTTP_REFERER,
67
+ appTitle = process.env.OPENROUTER_APP_TITLE,
68
+ } = {}) {
69
+ this.name = `openrouter:${model}`;
70
+ this.apiKey = apiKey;
71
+ this.endpoint = endpoint;
72
+ this.model = model;
73
+ this.timeoutMs = timeoutMs;
74
+ this.fetchImpl = fetchImpl;
75
+ this.httpReferer = httpReferer;
76
+ this.appTitle = appTitle;
77
+ }
78
+ async decide({ state, candidates }) {
79
+ const criteria = Object.fromEntries(candidates.map((candidate) => [candidate.id, {
80
+ name: candidate.name,
81
+ kind: candidate.kind,
82
+ description: candidate.description,
83
+ risk: candidate.risk,
84
+ }]));
85
+ return this.evaluate({
86
+ state,
87
+ questions: {
88
+ tool: {
89
+ type: "choice",
90
+ instructions: "Which single capability should handle this request? Choose only from the supplied options.",
91
+ criteria,
92
+ },
93
+ },
94
+ });
95
+ }
96
+
97
+ async evaluate({ state, questions }) {
98
+ if (!this.apiKey) throw new Error("OPENROUTER_API_KEY is not configured");
99
+ if (typeof this.fetchImpl !== "function") throw new Error("fetch is unavailable");
100
+
101
+ const response = await this.fetchImpl(this.endpoint, {
102
+ method: "POST",
103
+ headers: {
104
+ Authorization: `Bearer ${this.apiKey}`,
105
+ "Content-Type": "application/json",
106
+ ...(this.httpReferer ? { "HTTP-Referer": this.httpReferer } : {}),
107
+ ...(this.appTitle ? { "X-Title": this.appTitle } : {}),
108
+ },
109
+ body: JSON.stringify({ model: this.model, questions, state }),
110
+ signal: AbortSignal.timeout(this.timeoutMs),
111
+ }).catch((error) => {
112
+ if (error?.name === "TimeoutError") throw new Error(`OpenRouter Decisions request timed out after ${this.timeoutMs}ms`);
113
+ throw error;
114
+ });
115
+
116
+ if (!response.ok) {
117
+ const body = await response.text();
118
+ throw new Error(`OpenRouter Decisions returned HTTP ${response.status}: ${body.slice(0, 240)}`);
119
+ }
120
+ const raw = await response.json();
121
+ if (!raw || typeof raw !== "object" || !raw.answers || typeof raw.answers !== "object") {
122
+ throw new Error("OpenRouter Decisions response has no answers object");
123
+ }
124
+ return raw;
125
+ }
126
+ }