jev-gateway 0.1.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/dist/decide.js ADDED
@@ -0,0 +1,149 @@
1
+ import { argKey, buildQuestions, buildShortlistQuestions, MAX_TOOLS, NEEDS_TOOL_KEY, NO_TOOL, NONE_OF_THESE, shardKey, SHORTLIST_PER_SHARD, statedKey, TOOL_KEY, } from "./questions.js";
2
+ import { buildState } from "./state.js";
3
+ /** Why a request is not Jev's to decide, or undefined when it is. */
4
+ function skipReason(input) {
5
+ if (input.turns.length === 0)
6
+ return "no_messages";
7
+ if (input.tools.length === 0)
8
+ return "no_tools";
9
+ if (input.tools.length > MAX_TOOLS * 255)
10
+ return "too_many_tools";
11
+ if (new Set(input.tools.map((tool) => tool.name)).size !== input.tools.length)
12
+ return "duplicate_tool_names";
13
+ if (input.tools.some((tool) => tool.name === NO_TOOL))
14
+ return "reserved_tool_name";
15
+ if (input.toolChoice === "decided")
16
+ return "tool_choice_already_decided";
17
+ return undefined;
18
+ }
19
+ /** Fill every argument from Jev's answers; the weakest judgement bounds the whole call. */
20
+ function resolveArgs(plan, toolIndex, answers, minCertainty) {
21
+ const args = {};
22
+ let certainty = 1;
23
+ for (const param of plan.closedParams ?? []) {
24
+ if (param.kind === "const") {
25
+ if (param.required)
26
+ args[param.name] = param.value;
27
+ continue;
28
+ }
29
+ if (!param.required) {
30
+ const stated = answers[statedKey(toolIndex, param.name)];
31
+ if (stated?.type !== "noul")
32
+ return undefined;
33
+ certainty = Math.min(certainty, Math.max(stated.noul, 1 - stated.noul));
34
+ if (stated.noul < 0.5)
35
+ continue;
36
+ }
37
+ const answer = answers[argKey(toolIndex, param.name)];
38
+ if (param.kind === "boolean" && answer?.type === "noul") {
39
+ args[param.name] = answer.noul >= 0.5;
40
+ certainty = Math.min(certainty, Math.max(answer.noul, 1 - answer.noul));
41
+ }
42
+ else if (param.kind === "enum" && answer?.type === "choice" && param.values.has(answer.choice)) {
43
+ args[param.name] = param.values.get(answer.choice);
44
+ certainty = Math.min(certainty, answer.confidence);
45
+ }
46
+ else {
47
+ return undefined;
48
+ }
49
+ }
50
+ return certainty >= minCertainty ? { args, certainty } : undefined;
51
+ }
52
+ /** The strongest few tools of every shard, ranked in a single Jev call. */
53
+ async function shortlist(tools, state, config, askJev) {
54
+ const { questions, shards } = buildShortlistQuestions(tools);
55
+ const result = await askJev({ state, questions, model: config.jevModel });
56
+ const kept = shards.flatMap((shard, index) => {
57
+ const answer = result.answers[shardKey(index)];
58
+ if (answer?.type !== "choice")
59
+ return [];
60
+ const ranked = Object.entries(answer.probabilities)
61
+ .filter(([name]) => name !== NONE_OF_THESE)
62
+ .sort(([, a], [, b]) => b - a)
63
+ .slice(0, SHORTLIST_PER_SHARD)
64
+ .map(([name]) => name);
65
+ return shard.filter((tool) => ranked.includes(tool.name));
66
+ });
67
+ return { tools: kept, inputTokens: result.usage.input_tokens };
68
+ }
69
+ export async function decide(input, config, askJev) {
70
+ const skip = skipReason(input);
71
+ if (skip)
72
+ return { mode: "passthrough", reason: skip };
73
+ const startedAt = performance.now();
74
+ const state = buildState(input, config);
75
+ let tools = input.tools;
76
+ let shortlistTokens = 0;
77
+ let result;
78
+ let plans;
79
+ try {
80
+ if (tools.length > MAX_TOOLS) {
81
+ ({ tools, inputTokens: shortlistTokens } = await shortlist(tools, state, config, askJev));
82
+ if (tools.length === 0)
83
+ return { mode: "passthrough", reason: "jev_unexpected_answer" };
84
+ }
85
+ const built = buildQuestions(tools, {
86
+ allowNone: input.toolChoice !== "required",
87
+ withArgs: config.directCalls,
88
+ });
89
+ plans = built.plans;
90
+ result = await askJev({ state, questions: built.questions, model: config.jevModel });
91
+ }
92
+ catch (error) {
93
+ // Fail open: a Jev outage must never take the gateway down with it.
94
+ return { mode: "passthrough", reason: `jev_error: ${error instanceof Error ? error.message : String(error)}` };
95
+ }
96
+ const picked = result.answers[TOOL_KEY];
97
+ const needs = result.answers[NEEDS_TOOL_KEY];
98
+ if (picked?.type !== "choice" || needs?.type !== "noul") {
99
+ return { mode: "passthrough", reason: "jev_unexpected_answer" };
100
+ }
101
+ const jev = {
102
+ choice: picked.choice,
103
+ confidence: picked.confidence,
104
+ needsTool: needs.noul,
105
+ topProbabilities: Object.fromEntries(Object.entries(picked.probabilities)
106
+ .sort(([, a], [, b]) => b - a)
107
+ .slice(0, 3)),
108
+ inputTokens: result.usage.input_tokens + shortlistTokens,
109
+ latencyMs: Math.round(performance.now() - startedAt),
110
+ ...(tools === input.tools ? {} : { shortlist: tools.map((tool) => tool.name) }),
111
+ };
112
+ if (picked.confidence < config.minConfidence)
113
+ return { mode: "passthrough", reason: "low_confidence", jev };
114
+ // Two independent questions must agree before the router overrides the LLM.
115
+ const wantsTool = picked.choice !== NO_TOOL;
116
+ if (wantsTool ? needs.noul < 0.3 : needs.noul > 0.7) {
117
+ return { mode: "passthrough", reason: "jev_answers_disagree", jev };
118
+ }
119
+ if (!wantsTool) {
120
+ // A hint can suggest a tool; suggesting silence would only risk ending an agent's turn early.
121
+ return config.onNone === "force_none" && input.steer !== "hint"
122
+ ? { mode: "none", confidence: picked.confidence, jev }
123
+ : { mode: "passthrough", reason: "no_tool_needed", jev };
124
+ }
125
+ const toolIndex = plans.findIndex((plan) => plan.name === picked.choice);
126
+ const plan = plans[toolIndex];
127
+ const tool = tools[toolIndex];
128
+ if (!plan || !tool)
129
+ return { mode: "passthrough", reason: "jev_unknown_tool", jev };
130
+ // Provider-run tools can't be forced by name; knowing Jev wants one is still worth logging.
131
+ if (tool.kind === "hosted")
132
+ return { mode: "passthrough", reason: "hosted_tool_selected", jev };
133
+ // Neither can namespaced ones: backends reject both `tool_choice.namespace` and the bare name.
134
+ if (tool.namespace)
135
+ return { mode: "passthrough", reason: "namespaced_tool_selected", jev };
136
+ const resolved = plan.closedParams && resolveArgs(plan, toolIndex, result.answers, config.argMinCertainty);
137
+ if (resolved) {
138
+ return {
139
+ mode: "direct",
140
+ tool: plan.name,
141
+ args: resolved.args,
142
+ confidence: Math.min(picked.confidence, resolved.certainty),
143
+ jev,
144
+ };
145
+ }
146
+ if (input.steer === "hint")
147
+ return { mode: "hint", tool: plan.name, confidence: picked.confidence, jev };
148
+ return { mode: "forced", tool: plan.name, kind: tool.kind, confidence: picked.confidence, jev };
149
+ }
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ import { serve } from "@hono/node-server";
2
+ import { TypeSafeClient } from "@typesafe-ai/sdk";
3
+ import { createApp } from "./app.js";
4
+ import { loadConfig } from "./config.js";
5
+ import { createDump } from "./debug.js";
6
+ const config = loadConfig();
7
+ // Reads TYPESAFE_API_KEY. One fast retry only: past that, failing open to the LLM is quicker.
8
+ const jev = new TypeSafeClient({
9
+ defaultModel: config.jevModel,
10
+ timeout: config.jevTimeoutMs,
11
+ retry: { maxRetries: 1, backoffInitialMs: 100 },
12
+ });
13
+ const app = createApp({
14
+ config,
15
+ askJev: (request) => jev.systemOne(request),
16
+ dump: createDump(config.debugDumpDir),
17
+ log: (entry) => console.log(JSON.stringify({ time: new Date().toISOString(), ...entry })),
18
+ });
19
+ serve({ fetch: app.fetch, port: config.port }, ({ port }) => {
20
+ console.log(`jev-gateway listening on http://localhost:${port} → ${config.upstreamBaseUrl} (jev: ${config.jevModel})`);
21
+ });
@@ -0,0 +1,147 @@
1
+ import { truncate } from "./state.js";
2
+ /** Choice label meaning "reply in text, call nothing". */
3
+ export const NO_TOOL = "no_tool_needed";
4
+ /** Choice label a shard uses to say "the right tool is not in this group". */
5
+ export const NONE_OF_THESE = "none_of_these";
6
+ /**
7
+ * Most tools one tool question may offer. A Choice accepts 255 options, but state plus the longest
8
+ * question must also fit Jev's 32k-token window, and below ~400 characters a description stops
9
+ * telling similar tools apart — so big rosters (Claude Code sends ~280) are shortlisted first.
10
+ */
11
+ export const MAX_TOOLS = 120;
12
+ export const SHORTLIST_PER_SHARD = 3;
13
+ /** Cap on speculative argument questions fanned out in the same Jev call. */
14
+ const MAX_ARG_QUESTIONS = 96;
15
+ const MAX_DESCRIPTION_CHARS = 1024;
16
+ /** Characters one tool question may spend on descriptions (~12k tokens). */
17
+ const QUESTION_CHAR_BUDGET = 48_000;
18
+ export const TOOL_KEY = "tool";
19
+ export const NEEDS_TOOL_KEY = "needs_tool";
20
+ function closedParam(name, schema, required) {
21
+ if ("const" in schema)
22
+ return { name, required, kind: "const", value: schema.const };
23
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) {
24
+ if (schema.enum.length === 1)
25
+ return { name, required, kind: "const", value: schema.enum[0] };
26
+ const values = new Map();
27
+ for (const value of schema.enum) {
28
+ if (value !== null && typeof value === "object")
29
+ return undefined;
30
+ values.set(String(value), value);
31
+ }
32
+ // Labels are what Jev sees; colliding labels ("1" vs 1) can't be mapped back.
33
+ if (values.size !== schema.enum.length || values.size > 255)
34
+ return undefined;
35
+ return { name, required, kind: "enum", description: schema.description, values };
36
+ }
37
+ if (schema.type === "boolean")
38
+ return { name, required, kind: "boolean", description: schema.description };
39
+ return undefined;
40
+ }
41
+ export function planTool(tool) {
42
+ const schema = tool.parameters;
43
+ // Only function tools take JSON arguments, and without a recognizable object schema
44
+ // there is nothing safe to infer about them.
45
+ if (tool.kind !== "function")
46
+ return { name: tool.name };
47
+ if (schema && schema.type !== undefined && schema.type !== "object")
48
+ return { name: tool.name };
49
+ const required = new Set(schema?.required ?? []);
50
+ const closedParams = [];
51
+ for (const [name, property] of Object.entries(schema?.properties ?? {})) {
52
+ const param = closedParam(name, property, required.has(name));
53
+ if (!param)
54
+ return { name: tool.name };
55
+ closedParams.push(param);
56
+ }
57
+ return { name: tool.name, closedParams };
58
+ }
59
+ export const argKey = (toolIndex, param) => `arg:${toolIndex}:${param}`;
60
+ export const statedKey = (toolIndex, param) => `stated:${toolIndex}:${param}`;
61
+ function toolCriteria(tools) {
62
+ const limit = Math.min(MAX_DESCRIPTION_CHARS, Math.floor(QUESTION_CHAR_BUDGET / tools.length));
63
+ const criteria = {};
64
+ for (const tool of tools) {
65
+ const params = Object.keys(tool.parameters?.properties ?? {});
66
+ // Descriptions lead with what the tool is for; the tail is usage detail Jev doesn't need.
67
+ const description = tool.description?.trim().slice(0, limit);
68
+ criteria[tool.name] = description || (params.length ? `Parameters: ${params.join(", ")}` : null);
69
+ }
70
+ return criteria;
71
+ }
72
+ export const shardKey = (index) => `shard:${index}`;
73
+ /**
74
+ * First pass over a roster too big for one question: every shard is ranked in the same Jev call,
75
+ * and the best few of each go on to the real decision — ranking wide, then judging a shortlist.
76
+ */
77
+ export function buildShortlistQuestions(tools) {
78
+ const shardCount = Math.ceil(tools.length / MAX_TOOLS);
79
+ const size = Math.ceil(tools.length / shardCount);
80
+ const shards = Array.from({ length: shardCount }, (_, index) => tools.slice(index * size, (index + 1) * size));
81
+ const questions = {};
82
+ shards.forEach((shard, index) => {
83
+ questions[shardKey(index)] = {
84
+ type: "choice",
85
+ instructions: "Given the conversation, which of these tools would best advance the user's latest request " +
86
+ "if the assistant called it next?",
87
+ criteria: { ...toolCriteria(shard), [NONE_OF_THESE]: "None of the tools in this list fits the next step." },
88
+ };
89
+ });
90
+ return { questions, shards };
91
+ }
92
+ /**
93
+ * One Jev request decides everything: which tool (if any), whether a tool is needed at all,
94
+ * and — speculatively, for every tool Jev could fully answer — each closed-set argument.
95
+ * Extra questions barely change latency, so code picks the relevant answers afterwards.
96
+ */
97
+ export function buildQuestions(tools, options) {
98
+ const plans = tools.map(planTool);
99
+ const criteria = toolCriteria(tools);
100
+ if (options.allowNone) {
101
+ criteria[NO_TOOL] =
102
+ "No tool call is needed right now: the assistant should reply to the user in plain text " +
103
+ "(answer directly, ask a clarifying question, or report results that tools already returned).";
104
+ }
105
+ const questions = {
106
+ [TOOL_KEY]: {
107
+ type: "choice",
108
+ instructions: "Given the conversation, what should the assistant do next? " +
109
+ "Pick the single tool whose call best advances the user's latest request.",
110
+ criteria,
111
+ },
112
+ [NEEDS_TOOL_KEY]: {
113
+ type: "noul",
114
+ instructions: "Does the assistant need to call one of its tools now, rather than reply to the user in plain text?",
115
+ },
116
+ };
117
+ if (!options.withArgs)
118
+ return { questions, plans };
119
+ let argQuestions = 0;
120
+ plans.forEach((plan, toolIndex) => {
121
+ const asked = plan.closedParams?.filter((param) => param.kind !== "const") ?? [];
122
+ const cost = asked.reduce((sum, param) => sum + (param.required ? 1 : 2), 0);
123
+ if (!plan.closedParams || argQuestions + cost > MAX_ARG_QUESTIONS) {
124
+ delete plan.closedParams;
125
+ return;
126
+ }
127
+ argQuestions += cost;
128
+ for (const param of asked) {
129
+ const about = `the "${param.name}" argument of the tool "${plan.name}"${param.description ? ` (${truncate(param.description, MAX_DESCRIPTION_CHARS)})` : ""}`;
130
+ questions[argKey(toolIndex, param.name)] =
131
+ param.kind === "boolean"
132
+ ? { type: "noul", instructions: `If the assistant calls "${plan.name}" now, should ${about} be true?` }
133
+ : {
134
+ type: "choice",
135
+ instructions: `If the assistant calls "${plan.name}" now, what value should ${about} have?`,
136
+ criteria: Object.fromEntries([...param.values.keys()].map((label) => [label, null])),
137
+ };
138
+ if (!param.required) {
139
+ questions[statedKey(toolIndex, param.name)] = {
140
+ type: "noul",
141
+ instructions: `Does the conversation state or clearly imply a value for ${about}?`,
142
+ };
143
+ }
144
+ }
145
+ });
146
+ return { questions, plans };
147
+ }
package/dist/state.js ADDED
@@ -0,0 +1,43 @@
1
+ /** Keep the head and tail of long text; the middle is what matters least for routing. */
2
+ export function truncate(text, max) {
3
+ if (text.length <= max)
4
+ return text;
5
+ const marker = " …[truncated]… ";
6
+ const keep = Math.max(0, max - marker.length);
7
+ const head = Math.ceil(keep * 0.6);
8
+ return text.slice(0, head) + marker + text.slice(text.length - (keep - head));
9
+ }
10
+ /** Jev is text-only: flatten content parts and leave a placeholder for anything else. */
11
+ export function textOf(content) {
12
+ if (content == null)
13
+ return "";
14
+ if (typeof content === "string")
15
+ return content;
16
+ if (!Array.isArray(content))
17
+ return JSON.stringify(content);
18
+ return content
19
+ .map((part) => typeof part?.text === "string" ? part.text : `[${part?.type ?? "attachment"}]`)
20
+ .join("\n");
21
+ }
22
+ /**
23
+ * Jev state: the content the questions are asked about.
24
+ * The newest turns are kept when the conversation exceeds the state budget.
25
+ */
26
+ export function buildState(input, limits) {
27
+ const systemText = truncate(input.system, limits.maxMessageChars);
28
+ let budget = limits.maxStateChars - systemText.length;
29
+ const conversation = [];
30
+ for (let i = input.turns.length - 1; i >= 0; i--) {
31
+ const turn = input.turns[i];
32
+ budget -= JSON.stringify(turn).length;
33
+ if (budget < 0 && conversation.length > 0)
34
+ break;
35
+ conversation.unshift(turn);
36
+ }
37
+ const omitted = input.turns.length - conversation.length;
38
+ return {
39
+ ...(systemText ? { assistant_instructions: systemText } : {}),
40
+ ...(omitted ? { earlier_turns_omitted: omitted } : {}),
41
+ conversation,
42
+ };
43
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ /** The subset of the OpenAI Chat Completions shapes the router needs to understand. */
2
+ export {};
@@ -0,0 +1,71 @@
1
+ // Hop-by-hop and length/encoding headers must not cross the proxy: fetch re-frames
2
+ // (and transparently decompresses) bodies, so the originals would be wrong.
3
+ const DROPPED_REQUEST_HEADERS = new Set(["host", "connection", "content-length", "accept-encoding", "transfer-encoding"]);
4
+ const DROPPED_RESPONSE_HEADERS = new Set(["connection", "content-length", "content-encoding", "transfer-encoding"]);
5
+ /**
6
+ * A client that hangs up mid-stream aborts the upstream read, which surfaces as a stream error
7
+ * and gets logged as one by the HTTP server. It isn't: Codex closes every SSE stream as soon as
8
+ * it has `response.completed`. End the body quietly instead; real upstream errors still propagate.
9
+ */
10
+ function quietOnClientAbort(body, signal) {
11
+ if (!body)
12
+ return body;
13
+ const reader = body.getReader();
14
+ return new ReadableStream({
15
+ async pull(controller) {
16
+ try {
17
+ const { done, value } = await reader.read();
18
+ if (done)
19
+ controller.close();
20
+ else
21
+ controller.enqueue(value);
22
+ }
23
+ catch (error) {
24
+ if (signal.aborted)
25
+ controller.close();
26
+ else
27
+ controller.error(error);
28
+ }
29
+ },
30
+ cancel: (reason) => reader.cancel(reason),
31
+ });
32
+ }
33
+ /** Proxy a gateway request (`/v1/...`) to the upstream API, streaming the response back. */
34
+ export async function forward(incoming, config, fetchImpl, options = {}) {
35
+ const url = new URL(incoming.url);
36
+ const target = config.upstreamBaseUrl + url.pathname.replace(/^\/v1/, "") + url.search;
37
+ const headers = new Headers();
38
+ incoming.headers.forEach((value, name) => {
39
+ if (!DROPPED_REQUEST_HEADERS.has(name) && !name.startsWith("x-jev-"))
40
+ headers.set(name, value);
41
+ });
42
+ if (config.upstreamApiKey)
43
+ headers.set("authorization", `Bearer ${config.upstreamApiKey}`);
44
+ if (typeof options.body === "string")
45
+ headers.delete("content-encoding");
46
+ const hasBody = incoming.method !== "GET" && incoming.method !== "HEAD";
47
+ const init = { method: incoming.method, headers, signal: incoming.signal };
48
+ if (options.body !== undefined) {
49
+ init.body = options.body;
50
+ }
51
+ else if (hasBody && incoming.body) {
52
+ init.body = incoming.body;
53
+ init.duplex = "half";
54
+ }
55
+ let upstream;
56
+ try {
57
+ upstream = await fetchImpl(target, init);
58
+ }
59
+ catch (error) {
60
+ const message = error instanceof Error ? error.message : String(error);
61
+ return Response.json({ error: { message: `jev-gateway could not reach upstream: ${message}`, type: "upstream_unreachable" } }, { status: 502, headers: options.responseHeaders });
62
+ }
63
+ const responseHeaders = new Headers();
64
+ upstream.headers.forEach((value, name) => {
65
+ if (!DROPPED_RESPONSE_HEADERS.has(name))
66
+ responseHeaders.set(name, value);
67
+ });
68
+ for (const [name, value] of Object.entries(options.responseHeaders ?? {}))
69
+ responseHeaders.set(name, value);
70
+ return new Response(quietOnClientAbort(upstream.body, incoming.signal), { status: upstream.status, headers: responseHeaders });
71
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "jev-gateway",
3
+ "version": "0.1.0",
4
+ "description": "LLM gateway that hands tool-selection decisions to TypeSafe's Jev model \u2014 with launchers for Codex and Claude Code",
5
+ "keywords": [
6
+ "llm",
7
+ "gateway",
8
+ "proxy",
9
+ "jev",
10
+ "typesafe",
11
+ "tool-calling",
12
+ "codex",
13
+ "claude-code",
14
+ "openai",
15
+ "anthropic"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "Vinicius Lana",
19
+ "type": "module",
20
+ "engines": {
21
+ "node": ">=22.9"
22
+ },
23
+ "bin": {
24
+ "jev-codex": "bin/jev-codex.mjs",
25
+ "jev-claude": "bin/jev-claude.mjs"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "bin",
30
+ "scripts/mock-jev.mjs",
31
+ ".env.example"
32
+ ],
33
+ "scripts": {
34
+ "dev": "tsx watch --env-file-if-exists=.env src/index.ts",
35
+ "start": "node --env-file-if-exists=.env dist/index.js",
36
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
37
+ "typecheck": "tsc --noEmit",
38
+ "test": "vitest run",
39
+ "codex": "node bin/jev-codex.mjs",
40
+ "claude": "node bin/jev-claude.mjs",
41
+ "prepublishOnly": "pnpm typecheck && pnpm test && pnpm build"
42
+ },
43
+ "dependencies": {
44
+ "@hono/node-server": "^2.1.1",
45
+ "@typesafe-ai/sdk": "^0.6.0",
46
+ "hono": "^4.13.8"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^22.20.3",
50
+ "typescript": "^7.0.2",
51
+ "vitest": "^5.0.1",
52
+ "tsx": "^4.23.13"
53
+ }
54
+ }
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ // mock-jev: a local stand-in for TypeSafe's `POST /v1/systemone`, for driving the router
3
+ // end to end (real client, real upstream) without a TypeSafe key. The SDK honours
4
+ // TYPESAFE_BASE_URL, so:
5
+ //
6
+ // MOCK_JEV_SCRIPT=exec_command,no_tool_needed node scripts/mock-jev.mjs &
7
+ // TYPESAFE_BASE_URL=http://127.0.0.1:8799 TYPESAFE_API_KEY=mock jev-codex exec "…"
8
+ //
9
+ // It answers whatever question keys it is sent (see src/questions.ts): scripted for `tool`,
10
+ // consistent for `needs_tool`, deliberately unsure for arguments.
11
+ import { createServer } from "node:http";
12
+ import { mkdirSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ const PORT = Number(process.env.MOCK_JEV_PORT ?? 8799);
16
+ const NO_TOOL = "no_tool_needed";
17
+ // One `tool` answer per request, in order; the last one repeats. An agent loop needs this:
18
+ // "always pick the shell" never lets the turn end.
19
+ const SCRIPT = (process.env.MOCK_JEV_SCRIPT ?? NO_TOOL).split(",").map((name) => name.trim()).filter(Boolean);
20
+ const CONFIDENCE = Number(process.env.MOCK_JEV_CONFIDENCE ?? 0.95);
21
+ // Argument answers below the router's JEV_ARG_MIN_CERTAINTY keep it out of `direct` mode;
22
+ // raise this to exercise direct calls.
23
+ const ARG_CERTAINTY = Number(process.env.MOCK_JEV_ARG_CERTAINTY ?? 0.5);
24
+ const DUMP_DIR = process.env.MOCK_JEV_DUMP_DIR;
25
+
26
+ let served = 0;
27
+
28
+ function answer(key, question, wanted) {
29
+ if (key === "tool") {
30
+ const options = Object.keys(question.criteria ?? {});
31
+ // A scripted name the client doesn't offer would be a mock bug, not a routing result.
32
+ const choice = options.includes(wanted) ? wanted : options.includes(NO_TOOL) ? NO_TOOL : options[0];
33
+ const rest = (1 - CONFIDENCE) / Math.max(1, options.length - 1);
34
+ const probabilities = Object.fromEntries(options.map((name) => [name, name === choice ? CONFIDENCE : rest]));
35
+ return { type: "choice", choice, confidence: CONFIDENCE, probabilities };
36
+ }
37
+ if (key.startsWith("shard:")) {
38
+ // First pass over a big roster: the wanted tool lives in exactly one shard.
39
+ const options = Object.keys(question.criteria ?? {});
40
+ const choice = options.includes(wanted) ? wanted : "none_of_these";
41
+ const rest = (1 - CONFIDENCE) / Math.max(1, options.length - 1);
42
+ return { type: "choice", choice, confidence: CONFIDENCE, probabilities: Object.fromEntries(options.map((name) => [name, name === choice ? CONFIDENCE : rest])) };
43
+ }
44
+ if (key === "needs_tool") return { type: "noul", noul: wanted === NO_TOOL ? 0.1 : 0.9 };
45
+ if (question.type === "noul") return { type: "noul", noul: ARG_CERTAINTY };
46
+ if (question.type === "score") return { type: "score", score: 0.5 };
47
+ const options = Object.keys(question.criteria ?? {});
48
+ return {
49
+ type: "choice",
50
+ choice: options[0],
51
+ confidence: ARG_CERTAINTY,
52
+ probabilities: Object.fromEntries(options.map((name, i) => [name, i === 0 ? ARG_CERTAINTY : 0])),
53
+ };
54
+ }
55
+
56
+ createServer(async (req, res) => {
57
+ const chunks = [];
58
+ for await (const chunk of req) chunks.push(chunk);
59
+ const send = (status, body) => {
60
+ res.writeHead(status, { "content-type": "application/json" });
61
+ res.end(JSON.stringify(body));
62
+ };
63
+ if (req.method !== "POST" || !req.url?.startsWith("/v1/systemone")) return send(404, { error: "mock-jev only serves POST /v1/systemone" });
64
+
65
+ let request;
66
+ try {
67
+ request = JSON.parse(Buffer.concat(chunks).toString("utf8"));
68
+ } catch {
69
+ return send(400, { error: "invalid JSON" });
70
+ }
71
+ const questions = request.questions ?? {};
72
+ const wanted = SCRIPT[Math.min(served, SCRIPT.length - 1)];
73
+ // A shortlist pass and the decision that follows it belong to the same turn of the script.
74
+ if ("tool" in questions) served++;
75
+ const answers = Object.fromEntries(Object.entries(questions).map(([key, q]) => [key, answer(key, q, wanted)]));
76
+ const state = JSON.stringify(request.state ?? null);
77
+
78
+ if (DUMP_DIR) {
79
+ mkdirSync(DUMP_DIR, { recursive: true });
80
+ writeFileSync(join(DUMP_DIR, `jev-${String(served).padStart(4, "0")}.json`), JSON.stringify({ request, answers }, null, 2));
81
+ }
82
+ console.log(
83
+ JSON.stringify({
84
+ n: served,
85
+ scripted: wanted,
86
+ answered: answers.tool?.choice,
87
+ options: Object.keys(questions.tool?.criteria ?? {}),
88
+ questions: Object.keys(questions).length,
89
+ stateChars: state.length,
90
+ }),
91
+ );
92
+ send(200, { model: request.model ?? "mock-jev", answers, usage: { input_tokens: Math.ceil(state.length / 4), output_tokens: 0 } });
93
+ }).listen(PORT, "127.0.0.1", () => console.log(`mock-jev on http://127.0.0.1:${PORT} — script: ${SCRIPT.join(" → ")}`));