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.
@@ -0,0 +1,135 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { textOf, truncate } from "../state.js";
3
+ import { sse } from "./adapter.js";
4
+ const isClientTool = (tool) => tool.type === undefined || tool.type === "custom";
5
+ function toTools(raw) {
6
+ return raw
7
+ .filter((tool) => typeof tool?.name === "string")
8
+ .map((tool) => isClientTool(tool)
9
+ ? { kind: "function", name: tool.name, description: tool.description, parameters: tool.input_schema }
10
+ : { kind: "hosted", name: tool.name, description: tool.description ?? `Anthropic's built-in ${tool.name} tool.` });
11
+ }
12
+ function toInput(req, maxMessageChars) {
13
+ if (!Array.isArray(req.messages))
14
+ return { skip: "no_messages" };
15
+ const clip = (value) => truncate(textOf(value), maxMessageChars);
16
+ const toolNameById = new Map();
17
+ const turns = [];
18
+ for (const message of req.messages) {
19
+ if (typeof message.content === "string") {
20
+ turns.push({ role: message.role, text: clip(message.content) });
21
+ continue;
22
+ }
23
+ // One message can interleave prose, tool calls and tool results; Jev reads them as separate turns.
24
+ const text = [];
25
+ const flushText = () => {
26
+ if (text.length)
27
+ turns.push({ role: message.role, text: clip(text.splice(0)) });
28
+ };
29
+ for (const block of message.content ?? []) {
30
+ if (block.type === "tool_use" || block.type === "server_tool_use") {
31
+ flushText();
32
+ if (block.id && block.name)
33
+ toolNameById.set(block.id, block.name);
34
+ turns.push({
35
+ role: "assistant",
36
+ tool_calls: [{ tool: block.name ?? "unknown", arguments: clip(JSON.stringify(block.input ?? {})) }],
37
+ });
38
+ }
39
+ else if (block.type === "tool_result" || block.type.endsWith("_tool_result")) {
40
+ flushText();
41
+ turns.push({
42
+ role: "tool_result",
43
+ tool: toolNameById.get(block.tool_use_id ?? "") ?? "unknown",
44
+ content: clip(block.content),
45
+ });
46
+ }
47
+ else if (block.type !== "thinking" && block.type !== "redacted_thinking") {
48
+ text.push(block);
49
+ }
50
+ }
51
+ flushText();
52
+ }
53
+ const choice = req.tool_choice?.type ?? "auto";
54
+ const thinking = req.thinking?.type !== undefined && req.thinking.type !== "disabled";
55
+ // Two reasons not to touch tool_choice: the API rejects a forced tool while extended thinking
56
+ // is on, and any tool_choice change invalidates the cached conversation — which an agent like
57
+ // Claude Code re-reads on every turn. A trailing hint does neither.
58
+ const cached = JSON.stringify(req.messages).includes('"cache_control"') || "cache_control" in req;
59
+ return {
60
+ system: textOf(req.system),
61
+ turns,
62
+ tools: toTools(Array.isArray(req.tools) ? req.tools : []),
63
+ toolChoice: choice === "auto" ? "auto" : choice === "any" ? "required" : "decided",
64
+ steer: thinking || cached ? "hint" : "tool_choice",
65
+ };
66
+ }
67
+ /**
68
+ * Suggest Jev's pick in a block appended after everything the client sent. Cache breakpoints sit
69
+ * on the client's own blocks, so the cached prefix stays byte-identical to what the client will
70
+ * resend next turn; the wording leaves the model free to disagree.
71
+ */
72
+ function withHint(req, tool) {
73
+ const messages = req.messages ?? [];
74
+ const last = messages.at(-1);
75
+ if (last?.role !== "user")
76
+ return req;
77
+ const hint = {
78
+ type: "text",
79
+ text: `<system-reminder>A tool-routing model suggests the "${tool}" tool is the most relevant next step. ` +
80
+ "Ignore this if it does not fit what the user actually asked for.</system-reminder>",
81
+ };
82
+ const content = typeof last.content === "string" ? [{ type: "text", text: last.content }] : last.content;
83
+ return { ...req, messages: [...messages.slice(0, -1), { ...last, content: [...content, hint] }] };
84
+ }
85
+ function apply(req, decision, argsModel) {
86
+ if (decision.mode === "hint")
87
+ return withHint(req, decision.tool);
88
+ const parallel = req.tool_choice?.disable_parallel_tool_use === undefined
89
+ ? {}
90
+ : { disable_parallel_tool_use: req.tool_choice.disable_parallel_tool_use };
91
+ if (decision.mode === "forced") {
92
+ return { ...req, model: argsModel ?? req.model, tool_choice: { type: "tool", name: decision.tool, ...parallel } };
93
+ }
94
+ if (decision.mode === "none")
95
+ return { ...req, tool_choice: { type: "none" } };
96
+ return req;
97
+ }
98
+ const hex = (bytes) => randomBytes(bytes).toString("hex");
99
+ function build(req, call) {
100
+ const block = { type: "tool_use", id: `toolu_jev_${hex(12)}`, name: call.tool, input: call.args };
101
+ const usage = {
102
+ input_tokens: call.inputTokens,
103
+ cache_creation_input_tokens: 0,
104
+ cache_read_input_tokens: 0,
105
+ output_tokens: 0,
106
+ };
107
+ const message = {
108
+ id: `msg_jev_${hex(12)}`,
109
+ type: "message",
110
+ role: "assistant",
111
+ model: req.model ?? "jev-gateway",
112
+ content: [block],
113
+ stop_reason: "tool_use",
114
+ stop_sequence: null,
115
+ usage,
116
+ };
117
+ return { block, message, usage };
118
+ }
119
+ function directJson(req, call) {
120
+ return build(req, call).message;
121
+ }
122
+ /** The event sequence Claude itself streams for a single tool call. */
123
+ function directStream(req, call) {
124
+ const { block, message, usage } = build(req, call);
125
+ const events = [
126
+ { type: "message_start", message: { ...message, content: [], stop_reason: null } },
127
+ { type: "content_block_start", index: 0, content_block: { ...block, input: {} } },
128
+ { type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: JSON.stringify(call.args) } },
129
+ { type: "content_block_stop", index: 0 },
130
+ { type: "message_delta", delta: { stop_reason: "tool_use", stop_sequence: null }, usage: { output_tokens: usage.output_tokens } },
131
+ { type: "message_stop" },
132
+ ];
133
+ return sse(events.map((event) => ({ event: event.type, data: JSON.stringify(event) })));
134
+ }
135
+ export const messagesAdapter = { toInput, apply, directJson, directStream };
@@ -0,0 +1,184 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { textOf, truncate } from "../state.js";
3
+ import { sse } from "./adapter.js";
4
+ const HOSTED_DESCRIPTIONS = {
5
+ web_search: "Search the web for up-to-date information the assistant does not already have.",
6
+ web_search_preview: "Search the web for up-to-date information the assistant does not already have.",
7
+ local_shell: "Run a shell command on the user's machine.",
8
+ image_generation: "Generate an image.",
9
+ code_interpreter: "Run Python code in a sandbox.",
10
+ file_search: "Search the user's uploaded files.",
11
+ };
12
+ /** The namespace whose tools are addressed by bare name, as top-level tools are. */
13
+ const DEFAULT_NAMESPACE = "functions";
14
+ const qualified = (namespace, name) => namespace && namespace !== DEFAULT_NAMESPACE ? `${namespace}.${name}` : name;
15
+ const inputItems = (req) => typeof req.input === "string" ? [{ role: "user", content: req.input }] : Array.isArray(req.input) ? req.input : [];
16
+ /**
17
+ * Tools come from two places. Classic requests list them in `tools`; "responses-lite" ones
18
+ * (what Codex sends for its newer models) leave `tools` out entirely and declare them in
19
+ * `additional_tools` input items, grouped into namespaces.
20
+ */
21
+ function declaredTools(req) {
22
+ const declared = Array.isArray(req.tools) ? [...req.tools] : [];
23
+ for (const item of inputItems(req)) {
24
+ if (item.type === "additional_tools" && Array.isArray(item.tools))
25
+ declared.push(...item.tools);
26
+ }
27
+ return declared;
28
+ }
29
+ function toTools(raw) {
30
+ const tools = new Map();
31
+ const add = (tool, namespace) => {
32
+ if (tool.type === "namespace") {
33
+ for (const nested of Array.isArray(tool.tools) ? tool.tools : [])
34
+ add(nested, tool);
35
+ }
36
+ else if ((tool.type === "function" || tool.type === "custom") && tool.name) {
37
+ const name = qualified(namespace?.name, tool.name);
38
+ const group = name === tool.name ? undefined : namespace?.description?.trim();
39
+ tools.set(name, {
40
+ kind: tool.type,
41
+ name,
42
+ description: group ? `[${group}] ${tool.description ?? ""}`.trim() : tool.description,
43
+ parameters: tool.type === "function" ? tool.parameters : undefined,
44
+ ...(name === tool.name ? {} : { namespace: namespace?.name }),
45
+ });
46
+ }
47
+ else if (tool.type && !tools.has(tool.type)) {
48
+ tools.set(tool.type, {
49
+ kind: "hosted",
50
+ name: tool.type,
51
+ description: HOSTED_DESCRIPTIONS[tool.type] ?? tool.description ?? `The built-in ${tool.type} tool.`,
52
+ });
53
+ }
54
+ };
55
+ for (const tool of raw)
56
+ add(tool);
57
+ return [...tools.values()];
58
+ }
59
+ function toInput(req, maxMessageChars) {
60
+ // With server-side history the router would be judging a conversation it cannot see.
61
+ if (req.previous_response_id)
62
+ return { skip: "previous_response_id" };
63
+ const items = inputItems(req);
64
+ const clip = (value) => truncate(typeof value === "string" ? value : textOf(value), maxMessageChars);
65
+ const toolNameByCallId = new Map();
66
+ const system = typeof req.instructions === "string" && req.instructions ? [req.instructions] : [];
67
+ const turns = [];
68
+ for (const item of items) {
69
+ const type = item.type ?? (item.role ? "message" : undefined);
70
+ if (type === "message") {
71
+ const text = clip(item.content);
72
+ if (item.role === "system" || item.role === "developer") {
73
+ if (text)
74
+ system.push(text);
75
+ }
76
+ else {
77
+ turns.push({ role: item.role ?? "user", text });
78
+ }
79
+ }
80
+ else if (type === "function_call" || type === "custom_tool_call") {
81
+ const name = qualified(item.namespace, item.name ?? "unknown");
82
+ if (item.call_id)
83
+ toolNameByCallId.set(item.call_id, name);
84
+ turns.push({
85
+ role: "assistant",
86
+ tool_calls: [{ tool: name, arguments: clip(item.arguments ?? item.input ?? "") }],
87
+ });
88
+ }
89
+ else if (type === "local_shell_call") {
90
+ if (item.call_id)
91
+ toolNameByCallId.set(item.call_id, "local_shell");
92
+ turns.push({
93
+ role: "assistant",
94
+ tool_calls: [{ tool: "local_shell", arguments: clip((item.action?.command ?? []).join(" ")) }],
95
+ });
96
+ }
97
+ else if (type?.endsWith("_call_output")) {
98
+ turns.push({
99
+ role: "tool_result",
100
+ tool: toolNameByCallId.get(item.call_id ?? "") ?? "unknown",
101
+ content: clip(item.output),
102
+ });
103
+ }
104
+ // Reasoning items (encrypted), item references and hosted-tool traces carry nothing Jev can read.
105
+ }
106
+ const choice = req.tool_choice ?? "auto";
107
+ return {
108
+ system: system.join("\n\n"),
109
+ turns,
110
+ tools: toTools(declaredTools(req)),
111
+ toolChoice: choice === "auto" || choice === "required" ? choice : "decided",
112
+ };
113
+ }
114
+ function apply(req, decision, argsModel) {
115
+ if (decision.mode === "forced") {
116
+ return {
117
+ ...req,
118
+ model: argsModel ?? req.model,
119
+ tool_choice: { type: decision.kind === "custom" ? "custom" : "function", name: decision.tool },
120
+ };
121
+ }
122
+ if (decision.mode === "none")
123
+ return { ...req, tool_choice: "none" };
124
+ return req;
125
+ }
126
+ const hex = (bytes) => randomBytes(bytes).toString("hex");
127
+ function build(req, call) {
128
+ const item = {
129
+ type: "function_call",
130
+ id: `fc_${hex(16)}`,
131
+ call_id: `call_${hex(12)}`,
132
+ name: call.tool,
133
+ arguments: JSON.stringify(call.args),
134
+ status: "completed",
135
+ };
136
+ const response = {
137
+ id: `resp_jev_${hex(16)}`,
138
+ object: "response",
139
+ created_at: Math.floor(Date.now() / 1000),
140
+ status: "completed",
141
+ error: null,
142
+ incomplete_details: null,
143
+ instructions: null,
144
+ model: req.model ?? "jev-gateway",
145
+ output: [item],
146
+ parallel_tool_calls: req.parallel_tool_calls ?? true,
147
+ previous_response_id: null,
148
+ store: false,
149
+ tool_choice: req.tool_choice ?? "auto",
150
+ tools: req.tools ?? [],
151
+ metadata: {},
152
+ usage: {
153
+ input_tokens: call.inputTokens,
154
+ input_tokens_details: { cached_tokens: 0 },
155
+ output_tokens: 0,
156
+ output_tokens_details: { reasoning_tokens: 0 },
157
+ total_tokens: call.inputTokens,
158
+ },
159
+ };
160
+ return { item, response };
161
+ }
162
+ function directJson(req, call) {
163
+ return build(req, call).response;
164
+ }
165
+ /** The event sequence an LLM-produced function call would stream. */
166
+ function directStream(req, call) {
167
+ const { item, response } = build(req, call);
168
+ const pending = { ...response, status: "in_progress", output: [], usage: null };
169
+ const at = { item_id: item.id, output_index: 0 };
170
+ const events = [
171
+ { type: "response.created", response: pending },
172
+ { type: "response.in_progress", response: pending },
173
+ { type: "response.output_item.added", output_index: 0, item: { ...item, arguments: "", status: "in_progress" } },
174
+ { type: "response.function_call_arguments.delta", ...at, delta: item.arguments },
175
+ { type: "response.function_call_arguments.done", ...at, arguments: item.arguments },
176
+ { type: "response.output_item.done", output_index: 0, item },
177
+ { type: "response.completed", response },
178
+ ];
179
+ return sse(events.map((event, sequence_number) => ({
180
+ event: event.type,
181
+ data: JSON.stringify({ ...event, sequence_number }),
182
+ })));
183
+ }
184
+ export const responsesAdapter = { toInput, apply, directJson, directStream };
package/dist/app.js ADDED
@@ -0,0 +1,176 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { brotliDecompressSync, gunzipSync, inflateSync, zstdDecompressSync } from "node:zlib";
3
+ import { Hono } from "hono";
4
+ import { chatAdapter } from "./adapters/chat.js";
5
+ import { messagesAdapter } from "./adapters/messages.js";
6
+ import { responsesAdapter } from "./adapters/responses.js";
7
+ import { redactHeaders, summarizeResponse } from "./debug.js";
8
+ import { decide } from "./decide.js";
9
+ import { forward } from "./upstream.js";
10
+ const safeEqual = (a, b) => {
11
+ const left = Buffer.from(a);
12
+ const right = Buffer.from(b);
13
+ return left.length === right.length && timingSafeEqual(left, right);
14
+ };
15
+ const DECODERS = {
16
+ zstd: zstdDecompressSync,
17
+ gzip: gunzipSync,
18
+ br: brotliDecompressSync,
19
+ deflate: inflateSync,
20
+ };
21
+ /** Parse a JSON body, undoing request compression (Codex sends zstd). Undefined if unreadable. */
22
+ function parseBody(bytes, encoding) {
23
+ try {
24
+ const decoder = encoding ? DECODERS[encoding.trim().toLowerCase()] : undefined;
25
+ if (encoding && !decoder)
26
+ return undefined;
27
+ const parsed = JSON.parse(Buffer.from(decoder ? decoder(bytes) : bytes).toString("utf8"));
28
+ return parsed && typeof parsed === "object" ? parsed : undefined;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ function decisionHeaders(decision) {
35
+ const headers = { "x-jev-gateway-mode": decision.mode };
36
+ if (decision.mode === "passthrough")
37
+ headers["x-jev-gateway-reason"] = decision.reason.slice(0, 120);
38
+ if (decision.mode === "forced" || decision.mode === "direct" || decision.mode === "hint") {
39
+ headers["x-jev-gateway-tool"] = decision.tool;
40
+ }
41
+ if (decision.jev) {
42
+ headers["x-jev-gateway-confidence"] = decision.jev.confidence.toFixed(3);
43
+ headers["x-jev-gateway-latency-ms"] = String(decision.jev.latencyMs);
44
+ }
45
+ return headers;
46
+ }
47
+ export function createApp({ config, askJev, fetch: fetchImpl = fetch, log = () => { }, dump }) {
48
+ const app = new Hono();
49
+ /**
50
+ * Error bodies are the only documentation an undocumented backend offers, and a finished
51
+ * stream's usage is the only way to see what a rewrite did to the prompt cache: keep both.
52
+ * Reads a clone in the background, so the client's stream is never delayed.
53
+ */
54
+ const dumpResponse = (kind, response, extra = {}) => {
55
+ if (!dump)
56
+ return;
57
+ const failed = response.status >= 400;
58
+ const copy = response.clone();
59
+ void (async () => {
60
+ let text = "";
61
+ try {
62
+ // Codex hangs up the moment it has `response.completed`, which aborts the upstream read
63
+ // mid-stream: whatever arrived until then is the response.
64
+ for await (const chunk of copy.body?.pipeThrough(new TextDecoderStream()) ?? [])
65
+ text += chunk;
66
+ }
67
+ catch { }
68
+ dump(failed ? kind : "response", {
69
+ status: response.status,
70
+ ...extra,
71
+ ...(failed ? { body: text.slice(0, 20_000) } : summarizeResponse(text)),
72
+ });
73
+ })();
74
+ };
75
+ /** The decision, plus how many tools the adapter found — they aren't always in `req.tools`. */
76
+ const decideFor = async (adapter, req) => {
77
+ const input = adapter.toInput(req, config.maxMessageChars);
78
+ if ("skip" in input)
79
+ return { decision: { mode: "passthrough", reason: input.skip }, tools: undefined };
80
+ return { decision: await decide(input, config, askJev), tools: input.tools.length };
81
+ };
82
+ const route = (adapter) => async (c) => {
83
+ const bytes = new Uint8Array(await c.req.arrayBuffer());
84
+ // Unreadable bodies are not ours to judge: upstream produces its own error for them.
85
+ const req = parseBody(bytes, c.req.header("content-encoding"));
86
+ dump?.("request", {
87
+ method: c.req.method,
88
+ path: c.req.path,
89
+ headers: redactHeaders(c.req.raw.headers),
90
+ body: req ?? `[unparseable, ${bytes.length} bytes]`,
91
+ });
92
+ let decision;
93
+ let tools;
94
+ if (!req)
95
+ decision = { mode: "passthrough", reason: "unparseable_body" };
96
+ else if (c.req.header("x-jev-gateway") === "off")
97
+ decision = { mode: "passthrough", reason: "disabled_by_header" };
98
+ else
99
+ ({ decision, tools } = await decideFor(adapter, req));
100
+ const entry = { event: "route", path: c.req.path, model: req?.model, tools: tools ?? req?.tools?.length ?? 0 };
101
+ if (req && decision.mode === "direct") {
102
+ log({ ...entry, ...decision });
103
+ const call = { tool: decision.tool, args: decision.args, inputTokens: decision.jev?.inputTokens ?? 0 };
104
+ const headers = decisionHeaders(decision);
105
+ return req.stream
106
+ ? c.body(adapter.directStream(req, call), 200, {
107
+ ...headers,
108
+ "content-type": "text/event-stream",
109
+ "cache-control": "no-cache",
110
+ })
111
+ : c.json(adapter.directJson(req, call), 200, headers);
112
+ }
113
+ if (req && decision.mode !== "passthrough") {
114
+ const rewritten = adapter.apply(req, decision, config.argsModel);
115
+ const body = JSON.stringify(rewritten);
116
+ const response = await forward(c.req.raw, config, fetchImpl, { body, responseHeaders: decisionHeaders(decision) });
117
+ const sent = { mode: decision.mode, model: rewritten.model, tool_choice: rewritten.tool_choice };
118
+ dumpResponse("rejected", response, { sent });
119
+ if (response.status !== 400 && response.status !== 422) {
120
+ log({ ...entry, ...decision, status: response.status });
121
+ return response;
122
+ }
123
+ // The upstream refused the rewritten request (some backends only accept tool_choice
124
+ // "auto"): the router must never be the reason a request fails, so replay the original.
125
+ await response.body?.cancel();
126
+ decision = { mode: "passthrough", reason: `upstream_rejected_${decision.mode}`, jev: decision.jev };
127
+ }
128
+ const response = await forward(c.req.raw, config, fetchImpl, {
129
+ body: bytes,
130
+ responseHeaders: decisionHeaders(decision),
131
+ });
132
+ log({ ...entry, ...decision, status: response.status });
133
+ dumpResponse("upstream-error", response);
134
+ return response;
135
+ };
136
+ app.get("/health", (c) => c.json({ status: "ok", upstream: config.upstreamBaseUrl }));
137
+ app.use("*", async (c, next) => {
138
+ if (!config.routerApiKey)
139
+ return next();
140
+ const presented = c.req.header("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
141
+ if (safeEqual(presented, config.routerApiKey))
142
+ return next();
143
+ return c.json({ error: { message: "Invalid jev-gateway API key", type: "invalid_api_key" } }, 401);
144
+ });
145
+ /**
146
+ * Dry run: what would the router do with this body? Calls Jev, never upstream.
147
+ * Accepts any routed wire format; `?format=chat|responses|messages` overrides the guess.
148
+ */
149
+ app.post("/router/decide", async (c) => {
150
+ const req = parseBody(new Uint8Array(await c.req.arrayBuffer()), undefined);
151
+ if (!req)
152
+ return c.json({ error: { message: "Body must be a JSON object", type: "invalid_request_error" } }, 400);
153
+ const adapters = { chat: chatAdapter, responses: responsesAdapter, messages: messagesAdapter };
154
+ // Chat Completions and Anthropic Messages both use `messages`; only Anthropic has a top-level
155
+ // `system` or tools described by `input_schema`.
156
+ const tools = Array.isArray(req.tools) ? req.tools : [];
157
+ const guess = !("messages" in req)
158
+ ? "responses"
159
+ : "system" in req || tools.some((tool) => "input_schema" in tool)
160
+ ? "messages"
161
+ : "chat";
162
+ const format = (c.req.query("format") ?? guess);
163
+ const adapter = (adapters[format] ?? adapters[guess]);
164
+ return c.json((await decideFor(adapter, req)).decision);
165
+ });
166
+ app.post("/v1/chat/completions", route(chatAdapter));
167
+ app.post("/v1/responses", route(responsesAdapter));
168
+ app.post("/v1/messages", route(messagesAdapter));
169
+ // Everything else (models, embeddings, …) is proxied untouched.
170
+ app.all("/v1/*", async (c) => {
171
+ const response = await forward(c.req.raw, config, fetchImpl);
172
+ dump?.("other", { method: c.req.method, path: c.req.path, headers: redactHeaders(c.req.raw.headers), status: response.status });
173
+ return response;
174
+ });
175
+ return app;
176
+ }
package/dist/config.js ADDED
@@ -0,0 +1,45 @@
1
+ const str = (env, key) => {
2
+ const value = env[key]?.trim();
3
+ return value ? value : undefined;
4
+ };
5
+ const num = (env, key, fallback) => {
6
+ const raw = str(env, key);
7
+ if (raw === undefined)
8
+ return fallback;
9
+ const value = Number(raw);
10
+ if (!Number.isFinite(value))
11
+ throw new Error(`${key} must be a number, got "${raw}"`);
12
+ return value;
13
+ };
14
+ const bool = (env, key, fallback) => {
15
+ const raw = str(env, key)?.toLowerCase();
16
+ if (raw === undefined)
17
+ return fallback;
18
+ return raw === "1" || raw === "true" || raw === "yes";
19
+ };
20
+ export function loadConfig(env = process.env) {
21
+ const onNone = str(env, "JEV_ON_NONE") ?? "force_none";
22
+ if (onNone !== "force_none" && onNone !== "passthrough") {
23
+ throw new Error(`JEV_ON_NONE must be "force_none" or "passthrough", got "${onNone}"`);
24
+ }
25
+ const config = {
26
+ port: num(env, "PORT", 8787),
27
+ upstreamBaseUrl: (str(env, "UPSTREAM_BASE_URL") ?? "https://api.openai.com/v1").replace(/\/+$/, ""),
28
+ upstreamApiKey: str(env, "UPSTREAM_API_KEY"),
29
+ routerApiKey: str(env, "ROUTER_API_KEY"),
30
+ argsModel: str(env, "ARGS_MODEL"),
31
+ jevModel: str(env, "JEV_MODEL") ?? "jev-latest",
32
+ jevTimeoutMs: num(env, "JEV_TIMEOUT_MS", 4000),
33
+ minConfidence: num(env, "JEV_MIN_CONFIDENCE", 0.7),
34
+ argMinCertainty: num(env, "JEV_ARG_MIN_CERTAINTY", 0.8),
35
+ onNone,
36
+ directCalls: bool(env, "JEV_DIRECT_CALLS", true),
37
+ maxStateChars: num(env, "JEV_MAX_STATE_CHARS", 60_000),
38
+ maxMessageChars: num(env, "JEV_MAX_MESSAGE_CHARS", 4_000),
39
+ debugDumpDir: str(env, "JEV_DEBUG_DUMP_DIR"),
40
+ };
41
+ if (config.routerApiKey && !config.upstreamApiKey) {
42
+ throw new Error("ROUTER_API_KEY requires UPSTREAM_API_KEY (the client key is not valid upstream)");
43
+ }
44
+ return config;
45
+ }
package/dist/debug.js ADDED
@@ -0,0 +1,84 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Opt-in wire dumps (`JEV_DEBUG_DUMP_DIR`): what a client *really* sends is the only reliable
5
+ * spec for backends like ChatGPT's, which are undocumented. Dumps hold the whole conversation,
6
+ * so they are never on by default and credentials never reach the disk.
7
+ */
8
+ // Anything that authenticates or identifies the account. Matched loosely on purpose: a header
9
+ // redacted by mistake costs nothing, one leaked by mistake is a credential on disk.
10
+ const SECRET_HEADER = /auth|cookie|token|secret|key|account|session|signature/i;
11
+ export function redactHeaders(headers) {
12
+ const out = {};
13
+ headers.forEach((value, name) => {
14
+ out[name] = SECRET_HEADER.test(name) ? `[redacted, ${value.length} chars]` : value;
15
+ });
16
+ return out;
17
+ }
18
+ // A stream can end without completing; clients (Codex: "Reconnecting…") treat that as a failure.
19
+ const TERMINAL = new Set(["response.completed", "response.incomplete", "response.failed"]);
20
+ /** What a Responses reply (SSE or JSON) says about itself: enough to see cache hits and tool calls. */
21
+ export function summarizeResponse(text) {
22
+ let done;
23
+ let ending;
24
+ // "responses-lite" streams leave `response.output` empty; the items only appear as events.
25
+ const streamed = [];
26
+ const parse = (json) => {
27
+ try {
28
+ const event = JSON.parse(json);
29
+ if (event.type === "response.output_item.done" && event.item)
30
+ streamed.push(event.item);
31
+ else if (event.type && TERMINAL.has(event.type))
32
+ [ending, done] = [event.type, event.response];
33
+ else if (!event.type)
34
+ done = event;
35
+ }
36
+ catch {
37
+ // A chunk cut short by the client hanging up.
38
+ }
39
+ };
40
+ if (text.trimStart().startsWith("{"))
41
+ parse(text);
42
+ else
43
+ for (const line of text.split("\n"))
44
+ if (line.startsWith("data:"))
45
+ parse(line.slice(5));
46
+ if (!done)
47
+ return { unparsed: text.slice(-2_000) };
48
+ const { attribution: _perItem, ...usage } = (done.usage ?? {});
49
+ const output = streamed.length ? streamed : Array.isArray(done.output) ? done.output : [];
50
+ return {
51
+ ...(ending && ending !== "response.completed"
52
+ ? { ending, incomplete_details: done.incomplete_details, error: done.error }
53
+ : {}),
54
+ model: done.model,
55
+ tool_choice: done.tool_choice,
56
+ usage,
57
+ // Runs of one item type collapse to a count: a derailed reply can hold a hundred of them.
58
+ output: output.reduce((runs, { type, name, namespace }) => {
59
+ const last = runs.at(-1);
60
+ if (last && last.type === type && last.name === name)
61
+ last.count = (last.count ?? 1) + 1;
62
+ else
63
+ runs.push({ type, name, namespace });
64
+ return runs;
65
+ }, []),
66
+ };
67
+ }
68
+ /** Writes `<dir>/<start>-<seq>-<kind>.json`, never throwing; undefined (off) without a directory. */
69
+ export function createDump(dir) {
70
+ if (!dir)
71
+ return undefined;
72
+ const startedAt = Date.now();
73
+ let sequence = 0;
74
+ return (kind, data) => {
75
+ try {
76
+ mkdirSync(dir, { recursive: true });
77
+ const name = `${startedAt}-${String(++sequence).padStart(4, "0")}-${kind}.json`;
78
+ writeFileSync(join(dir, name), JSON.stringify(data, null, 2), { mode: 0o600 });
79
+ }
80
+ catch {
81
+ // Debugging aid only: a full disk must not break routing.
82
+ }
83
+ };
84
+ }