jev-layer 0.1.0 → 0.3.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/CHANGELOG.md +73 -34
- package/CONTRIBUTING.md +146 -146
- package/LICENSE +21 -21
- package/README.md +125 -126
- package/README.ru.md +140 -117
- package/README.zh-CN.md +139 -117
- package/RELEASE.md +53 -53
- package/SECURITY.md +64 -56
- package/bin/jev.mjs +215 -213
- package/config/codex.mcp.toml +8 -8
- package/config/generic-mcp.json +13 -13
- package/config/hermes.mcp.yaml +9 -9
- package/config/jev.example.json +23 -22
- package/config/omp.mcp.json +14 -14
- package/config/providers.env.example +13 -13
- package/docs/AGENT-IMPLEMENTATION.md +306 -0
- package/docs/COMPATIBILITY.md +24 -15
- package/docs/HERMES-LOCAL-AGENT-HANDOFF.ru.md +187 -0
- package/docs/PROVIDERS.md +109 -0
- package/docs/SCHEMA-VERSIONING.md +101 -94
- package/docs/architecture.svg +77 -0
- package/docs/jev-layer-banner.svg +38 -0
- package/examples/capabilities.json +39 -39
- package/examples/route-request.json +28 -28
- package/integrations/codex/.codex-plugin/plugin.json +19 -19
- package/integrations/codex/.mcp.json +11 -11
- package/integrations/codex/AGENTS.md +1 -1
- package/integrations/codex/run-mcp.mjs +9 -9
- package/integrations/codex/skills/jev-route/SKILL.md +17 -17
- package/integrations/hermes/__init__.py +156 -51
- package/integrations/hermes/plugin.yaml +11 -5
- package/integrations/hermes/schemas.py +120 -12
- package/integrations/omp/extension.js +106 -105
- package/integrations/template/README.md +10 -10
- package/integrations/template/adapter.mjs +87 -87
- package/package.json +60 -59
- package/scripts/benchmark.mjs +35 -35
- package/scripts/browser-benchmark.mjs +92 -92
- package/scripts/browser-e2e.mjs +117 -117
- package/scripts/capability-e2e.mjs +31 -31
- package/scripts/clean-install-smoke.mjs +176 -162
- package/scripts/codex-mcp-smoke.mjs +119 -119
- package/scripts/context-filter-e2e.mjs +45 -45
- package/scripts/fail-open-smoke.mjs +120 -120
- package/scripts/feature-flags-smoke.mjs +46 -46
- package/scripts/mcp-receipt-smoke.mjs +115 -98
- package/scripts/model-route-report.mjs +15 -0
- package/scripts/openrouter-choice.mjs +59 -59
- package/scripts/replay-eval.mjs +124 -124
- package/scripts/smoke.mjs +34 -34
- package/scripts/supervision-e2e.mjs +138 -109
- package/src/browser.mjs +583 -569
- package/src/cli.mjs +33 -33
- package/src/computer.mjs +196 -0
- package/src/config.mjs +86 -75
- package/src/context-filter.mjs +56 -56
- package/src/contract.mjs +74 -72
- package/src/discovery.mjs +65 -65
- package/src/hermes-adapter.mjs +134 -0
- package/src/images.mjs +98 -0
- package/src/mcp-server.mjs +313 -210
- package/src/model-route-metrics.mjs +89 -0
- package/src/model-routing.mjs +87 -0
- package/src/providers/demo.mjs +52 -52
- package/src/providers/index.mjs +12 -0
- package/src/providers/typesafe.mjs +206 -126
- package/src/receipts.mjs +228 -226
- package/src/registry.mjs +109 -109
- package/src/relevance-filter.mjs +99 -99
- package/src/route.mjs +228 -221
- package/src/shadow-compaction.mjs +170 -0
- package/src/supervision.mjs +268 -244
- package/test/browser.test.mjs +222 -199
- package/test/capability.test.mjs +54 -54
- package/test/computer.test.mjs +195 -0
- package/test/context-filter.test.mjs +65 -65
- package/test/hermes-adapter.test.mjs +63 -0
- package/test/image-fixture.mjs +2 -0
- package/test/images.test.mjs +85 -0
- package/test/model-route-metrics.test.mjs +61 -0
- package/test/model-routing.test.mjs +59 -0
- package/test/openrouter-provider.test.mjs +55 -55
- package/test/provider-transports.test.mjs +205 -0
- package/test/providers.test.mjs +125 -0
- package/test/receipts.test.mjs +71 -71
- package/test/route.test.mjs +86 -74
- package/test/shadow-compaction.test.mjs +67 -0
- package/test/supervision.test.mjs +117 -99
|
@@ -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
|
+
}
|
package/src/providers/demo.mjs
CHANGED
|
@@ -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,206 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
throw
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
1
|
+
import { normalizeImageAssets } from "../images.mjs";
|
|
2
|
+
|
|
3
|
+
function containsReference(value, reference) {
|
|
4
|
+
if (typeof value === "string") return value.includes(reference);
|
|
5
|
+
return value !== null && typeof value === "object"
|
|
6
|
+
&& Object.values(value).some((child) => containsReference(child, reference));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function openRouterImageInput(state, questions, images) {
|
|
10
|
+
const parts = Object.fromEntries(Object.entries(images).map(([reference, image]) => [reference, {
|
|
11
|
+
type: "image_url",
|
|
12
|
+
image_url: { url: `data:image/${image.format === "png" ? "png" : "jpeg"};base64,${image.data_base64}` },
|
|
13
|
+
}]));
|
|
14
|
+
const pattern = new RegExp(Object.keys(parts).sort((a, b) => b.length - a.length).map((key) => key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "g");
|
|
15
|
+
const used = new Set();
|
|
16
|
+
function expand(value) {
|
|
17
|
+
if (typeof value === "string") {
|
|
18
|
+
if (Object.hasOwn(parts, value)) {
|
|
19
|
+
used.add(value);
|
|
20
|
+
return parts[value];
|
|
21
|
+
}
|
|
22
|
+
const content = [];
|
|
23
|
+
let offset = 0;
|
|
24
|
+
for (const match of value.matchAll(pattern)) {
|
|
25
|
+
if (match.index > offset) content.push(value.slice(offset, match.index));
|
|
26
|
+
content.push(parts[match[0]]);
|
|
27
|
+
used.add(match[0]);
|
|
28
|
+
offset = match.index + match[0].length;
|
|
29
|
+
}
|
|
30
|
+
if (!content.length) return value;
|
|
31
|
+
if (offset < value.length) content.push(value.slice(offset));
|
|
32
|
+
return content;
|
|
33
|
+
}
|
|
34
|
+
if (Array.isArray(value)) return value.map(expand);
|
|
35
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, expand(child)]));
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
const input = {
|
|
39
|
+
state: expand(state),
|
|
40
|
+
questions: Object.fromEntries(Object.entries(questions).map(([key, question]) => [key, {
|
|
41
|
+
...question,
|
|
42
|
+
instructions: expand(question.instructions),
|
|
43
|
+
...(question.criteria !== undefined ? { criteria: expand(question.criteria) } : {}),
|
|
44
|
+
}])),
|
|
45
|
+
};
|
|
46
|
+
if (used.size !== Object.keys(parts).length) throw new Error("every image asset must be referenced in state, instructions, or criteria");
|
|
47
|
+
return input;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class TypeSafeProvider {
|
|
51
|
+
name = "typesafe";
|
|
52
|
+
supportsImages = true;
|
|
53
|
+
|
|
54
|
+
constructor({ apiKey = process.env.TYPESAFE_API_KEY, endpoint = process.env.TYPESAFE_ENDPOINT ?? "https://api.typesafe.ai/v1/systemone", model = process.env.TYPESAFE_MODEL ?? "jev-1.14.0", timeoutMs = 2_000, fetchImpl = globalThis.fetch } = {}) {
|
|
55
|
+
this.apiKey = apiKey;
|
|
56
|
+
this.endpoint = endpoint;
|
|
57
|
+
this.model = model;
|
|
58
|
+
this.timeoutMs = timeoutMs;
|
|
59
|
+
this.fetchImpl = fetchImpl;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async decide({ state, candidates, assets }) {
|
|
63
|
+
const criteria = Object.fromEntries(candidates.map((candidate) => [candidate.id, {
|
|
64
|
+
name: candidate.name,
|
|
65
|
+
kind: candidate.kind,
|
|
66
|
+
description: candidate.description,
|
|
67
|
+
risk: candidate.risk,
|
|
68
|
+
}]));
|
|
69
|
+
return this.evaluate({
|
|
70
|
+
state,
|
|
71
|
+
assets,
|
|
72
|
+
questions: {
|
|
73
|
+
tool: {
|
|
74
|
+
type: "choice",
|
|
75
|
+
instructions: "Which single capability should handle this request? Choose only from the supplied options.",
|
|
76
|
+
criteria,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async evaluate({ state, questions, assets }) {
|
|
83
|
+
if (!this.apiKey) throw new Error("TYPESAFE_API_KEY is not configured");
|
|
84
|
+
if (typeof this.fetchImpl !== "function") throw new Error("fetch is unavailable");
|
|
85
|
+
const images = normalizeImageAssets(assets);
|
|
86
|
+
const hasImages = Object.keys(images).length > 0;
|
|
87
|
+
const hasBox = Object.values(questions).some((question) => question?.type === "box");
|
|
88
|
+
for (const reference of Object.keys(images)) {
|
|
89
|
+
if (!containsReference(state, reference)
|
|
90
|
+
&& !Object.values(questions).some((question) => containsReference(question?.instructions, reference) || containsReference(question?.criteria, reference))) {
|
|
91
|
+
throw new Error("every image asset must be referenced in state, instructions, or criteria");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const body = JSON.stringify({ state, model: this.model, questions, ...(hasImages ? { assets: images } : {}) });
|
|
95
|
+
if (Buffer.byteLength(body) > 32 * 1024 * 1024) throw new Error("TypeSafe request exceeds the 32 MiB body limit");
|
|
96
|
+
const response = await this.fetchImpl(this.endpoint, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: {
|
|
99
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
100
|
+
"Content-Type": "application/json",
|
|
101
|
+
...(hasImages || hasBox ? { "TypeSafe-Danger-Zone-Images": "true" } : {}),
|
|
102
|
+
},
|
|
103
|
+
body,
|
|
104
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
105
|
+
}).catch((error) => {
|
|
106
|
+
if (error?.name === "TimeoutError") throw new Error(`TypeSafe request timed out after ${this.timeoutMs}ms`);
|
|
107
|
+
if (hasImages) throw new Error("TypeSafe image request failed");
|
|
108
|
+
throw error;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
// Image requests must not copy an upstream echo of image data into receipts.
|
|
113
|
+
const detail = hasImages ? "" : `: ${(await response.text()).slice(0, 240)}`;
|
|
114
|
+
throw new Error(`TypeSafe returned HTTP ${response.status}${detail}`);
|
|
115
|
+
}
|
|
116
|
+
const raw = await response.json().catch((error) => {
|
|
117
|
+
if (hasImages) throw new Error("TypeSafe image response is not valid JSON");
|
|
118
|
+
throw error;
|
|
119
|
+
});
|
|
120
|
+
if (!raw || typeof raw !== "object" || !raw.answers || typeof raw.answers !== "object") {
|
|
121
|
+
throw new Error("TypeSafe response has no answers object");
|
|
122
|
+
}
|
|
123
|
+
return raw;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export class OpenRouterDecisionsProvider {
|
|
128
|
+
supportsImages = true;
|
|
129
|
+
|
|
130
|
+
constructor({
|
|
131
|
+
apiKey = process.env.OPENROUTER_API_KEY,
|
|
132
|
+
endpoint = process.env.OPENROUTER_DECISIONS_ENDPOINT ?? "https://openrouter.ai/api/alpha/decisions",
|
|
133
|
+
model = process.env.OPENROUTER_DECISIONS_MODEL ?? "typesafe/jev-1.14",
|
|
134
|
+
timeoutMs = 5_000,
|
|
135
|
+
fetchImpl = globalThis.fetch,
|
|
136
|
+
httpReferer = process.env.OPENROUTER_HTTP_REFERER,
|
|
137
|
+
appTitle = process.env.OPENROUTER_APP_TITLE,
|
|
138
|
+
} = {}) {
|
|
139
|
+
this.name = `openrouter:${model}`;
|
|
140
|
+
this.apiKey = apiKey;
|
|
141
|
+
this.endpoint = endpoint;
|
|
142
|
+
this.model = model;
|
|
143
|
+
this.timeoutMs = timeoutMs;
|
|
144
|
+
this.fetchImpl = fetchImpl;
|
|
145
|
+
this.httpReferer = httpReferer;
|
|
146
|
+
this.appTitle = appTitle;
|
|
147
|
+
}
|
|
148
|
+
async decide({ state, candidates, assets }) {
|
|
149
|
+
const criteria = Object.fromEntries(candidates.map((candidate) => [candidate.id, {
|
|
150
|
+
name: candidate.name,
|
|
151
|
+
kind: candidate.kind,
|
|
152
|
+
description: candidate.description,
|
|
153
|
+
risk: candidate.risk,
|
|
154
|
+
}]));
|
|
155
|
+
return this.evaluate({
|
|
156
|
+
state,
|
|
157
|
+
assets,
|
|
158
|
+
questions: {
|
|
159
|
+
tool: {
|
|
160
|
+
type: "choice",
|
|
161
|
+
instructions: "Which single capability should handle this request? Choose only from the supplied options.",
|
|
162
|
+
criteria,
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async evaluate({ state, questions, assets }) {
|
|
169
|
+
if (!this.apiKey) throw new Error("OPENROUTER_API_KEY is not configured");
|
|
170
|
+
if (typeof this.fetchImpl !== "function") throw new Error("fetch is unavailable");
|
|
171
|
+
const images = normalizeImageAssets(assets);
|
|
172
|
+
const hasImages = Object.keys(images).length > 0;
|
|
173
|
+
const input = hasImages ? openRouterImageInput(state, questions, images) : { state, questions };
|
|
174
|
+
const body = JSON.stringify({ model: this.model, ...input });
|
|
175
|
+
if (Buffer.byteLength(body) > 32 * 1024 * 1024) throw new Error("OpenRouter Decisions request exceeds the 32 MiB body limit");
|
|
176
|
+
|
|
177
|
+
const response = await this.fetchImpl(this.endpoint, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: {
|
|
180
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
181
|
+
"Content-Type": "application/json",
|
|
182
|
+
...(this.httpReferer ? { "HTTP-Referer": this.httpReferer } : {}),
|
|
183
|
+
...(this.appTitle ? { "X-Title": this.appTitle } : {}),
|
|
184
|
+
},
|
|
185
|
+
body,
|
|
186
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
187
|
+
}).catch((error) => {
|
|
188
|
+
if (error?.name === "TimeoutError") throw new Error(`OpenRouter Decisions request timed out after ${this.timeoutMs}ms`);
|
|
189
|
+
if (hasImages) throw new Error("OpenRouter Decisions image request failed");
|
|
190
|
+
throw error;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (!response.ok) {
|
|
194
|
+
const detail = hasImages ? "" : `: ${(await response.text()).slice(0, 240)}`;
|
|
195
|
+
throw new Error(`OpenRouter Decisions returned HTTP ${response.status}${detail}`);
|
|
196
|
+
}
|
|
197
|
+
const raw = await response.json().catch((error) => {
|
|
198
|
+
if (hasImages) throw new Error("OpenRouter Decisions image response is not valid JSON");
|
|
199
|
+
throw error;
|
|
200
|
+
});
|
|
201
|
+
if (!raw || typeof raw !== "object" || !raw.answers || typeof raw.answers !== "object") {
|
|
202
|
+
throw new Error("OpenRouter Decisions response has no answers object");
|
|
203
|
+
}
|
|
204
|
+
return raw;
|
|
205
|
+
}
|
|
206
|
+
}
|