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/.env.example +33 -0
- package/LICENSE +21 -0
- package/README.md +179 -0
- package/bin/jev-claude.mjs +20 -0
- package/bin/jev-codex.mjs +53 -0
- package/bin/launcher.mjs +148 -0
- package/dist/adapters/adapter.js +1 -0
- package/dist/adapters/chat.js +128 -0
- package/dist/adapters/messages.js +135 -0
- package/dist/adapters/responses.js +184 -0
- package/dist/app.js +176 -0
- package/dist/config.js +45 -0
- package/dist/debug.js +84 -0
- package/dist/decide.js +149 -0
- package/dist/index.js +21 -0
- package/dist/questions.js +147 -0
- package/dist/state.js +43 -0
- package/dist/types.js +2 -0
- package/dist/upstream.js +71 -0
- package/package.json +54 -0
- package/scripts/mock-jev.mjs +93 -0
package/.env.example
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# --- Required -------------------------------------------------------------
|
|
2
|
+
# TypeSafe API key (https://docs.typesafe.ai). Jev makes the tool-selection call.
|
|
3
|
+
TYPESAFE_API_KEY=
|
|
4
|
+
|
|
5
|
+
# --- Upstream LLM (any OpenAI-compatible API) -------------------------------
|
|
6
|
+
UPSTREAM_BASE_URL=https://api.openai.com/v1
|
|
7
|
+
# If set, replaces the client's Authorization header on upstream requests.
|
|
8
|
+
# If unset, the client's own Authorization header is forwarded as-is.
|
|
9
|
+
UPSTREAM_API_KEY=
|
|
10
|
+
# If set, clients must send "Authorization: Bearer <ROUTER_API_KEY>" (requires UPSTREAM_API_KEY).
|
|
11
|
+
ROUTER_API_KEY=
|
|
12
|
+
# Optional cheaper model used only to fill arguments once Jev has picked the tool.
|
|
13
|
+
ARGS_MODEL=
|
|
14
|
+
|
|
15
|
+
# --- Routing ----------------------------------------------------------------
|
|
16
|
+
PORT=8787
|
|
17
|
+
JEV_MODEL=jev-latest
|
|
18
|
+
JEV_TIMEOUT_MS=4000
|
|
19
|
+
# Below this confidence the request is forwarded untouched (the LLM decides).
|
|
20
|
+
JEV_MIN_CONFIDENCE=0.7
|
|
21
|
+
# Minimum certainty per closed-set argument before answering without the LLM.
|
|
22
|
+
JEV_ARG_MIN_CERTAINTY=0.8
|
|
23
|
+
# When Jev says "no tool needed": force_none (tool_choice=none) | passthrough
|
|
24
|
+
JEV_ON_NONE=force_none
|
|
25
|
+
# Answer directly (no LLM call) when every argument is an enum/boolean.
|
|
26
|
+
JEV_DIRECT_CALLS=true
|
|
27
|
+
JEV_MAX_STATE_CHARS=60000
|
|
28
|
+
JEV_MAX_MESSAGE_CHARS=4000
|
|
29
|
+
|
|
30
|
+
# --- Debugging --------------------------------------------------------------
|
|
31
|
+
# Opt-in wire dumps: every routed request (decoded JSON body, credentials redacted), what the
|
|
32
|
+
# router changed, and the reply's usage / error body. Dumps contain the whole conversation.
|
|
33
|
+
JEV_DEBUG_DUMP_DIR=
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vinicius Lana
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# jev-gateway
|
|
2
|
+
|
|
3
|
+
> Independent project — not affiliated with or endorsed by TypeSafe. "Jev" is TypeSafe's model; this
|
|
4
|
+
> gateway is a client of its public API.
|
|
5
|
+
|
|
6
|
+
An LLM gateway that speaks Chat Completions, the Responses API and the Anthropic Messages API.
|
|
7
|
+
Point your client — or Codex, or Claude Code — at it instead of your LLM provider; whenever a
|
|
8
|
+
request is really asking **"which tool should I call?"**, the router hands that decision to
|
|
9
|
+
[Jev](https://docs.typesafe.ai/introduction) — TypeSafe's System One model — instead of paying a
|
|
10
|
+
reasoning LLM to make it.
|
|
11
|
+
|
|
12
|
+
Jev doesn't generate text. It answers typed questions (Choice / Score / Noul) about a piece of
|
|
13
|
+
state and returns calibrated probabilities plus a confidence, in one fast call. Tool selection is
|
|
14
|
+
exactly that kind of question, so the split is:
|
|
15
|
+
|
|
16
|
+
| Decision | Who makes it |
|
|
17
|
+
| --- | --- |
|
|
18
|
+
| Which tool, or no tool at all | **Jev** (Choice over the tool list + a Noul cross-check) |
|
|
19
|
+
| Arguments that are enums / booleans / consts | **Jev** (fanned out in the same call) |
|
|
20
|
+
| Open-ended arguments (free text, numbers, dates) | Upstream LLM, with `tool_choice` forced to Jev's pick |
|
|
21
|
+
| Plain text replies, everything without `tools` | Upstream LLM, untouched |
|
|
22
|
+
|
|
23
|
+
## How a request is routed
|
|
24
|
+
|
|
25
|
+
`POST /v1/chat/completions` with `tools` and `tool_choice` of `auto`/`required` triggers **one**
|
|
26
|
+
Jev call. The conversation becomes the state; the questions are:
|
|
27
|
+
|
|
28
|
+
- `tool` — Choice: every tool name → its description, plus `no_tool_needed` (omitted for `required`)
|
|
29
|
+
- `needs_tool` — Noul: an independent "does the assistant need a tool now?" check
|
|
30
|
+
- `arg:*` / `stated:*` — for each tool whose parameters are *all* closed-set, one question per
|
|
31
|
+
argument (and "was it stated?" for optional ones), asked speculatively since extra questions are
|
|
32
|
+
nearly free
|
|
33
|
+
|
|
34
|
+
The answer picks one of five modes, reported in the `x-jev-gateway-mode` response header:
|
|
35
|
+
|
|
36
|
+
| Mode | When | What happens |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| `direct` | Tool is confident and every argument is closed-set and certain | The router synthesizes the `tool_calls` response itself (streaming included). **No LLM call.** |
|
|
39
|
+
| `forced` | Tool is confident, arguments need an LLM | Forwarded with `tool_choice` set to that function — and to `ARGS_MODEL` if configured, since the hard part is already done |
|
|
40
|
+
| `hint` | Tool is confident, but `tool_choice` can't be rewritten (Anthropic: extended thinking on, or the conversation is prompt-cached) | Forwarded with a one-line suggestion appended *after* the client's last block, so cached prefixes stay intact. The LLM may disagree |
|
|
41
|
+
| `none` | Jev is confident no tool is needed | Forwarded with `tool_choice: "none"` (or untouched with `JEV_ON_NONE=passthrough`) |
|
|
42
|
+
| `passthrough` | Low confidence, the two questions disagree, Jev errored/timed out, no tools, caller already chose | Forwarded byte-for-byte; `x-jev-gateway-reason` says why |
|
|
43
|
+
|
|
44
|
+
Rosters over 120 tools (Claude Code sends ~280) don't fit one good question, so they take two Jev
|
|
45
|
+
calls: every shard of the roster is ranked in one call, and the top 3 of each go on to the decision
|
|
46
|
+
above with full-length descriptions.
|
|
47
|
+
|
|
48
|
+
The router always **fails open**: any Jev problem means the LLM decides, as if the gateway weren't
|
|
49
|
+
there. All other `/v1/*` routes (models, embeddings, …) are proxied unchanged.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
npm install -g jev-gateway
|
|
55
|
+
mkdir -p ~/.jev-gateway && echo "TYPESAFE_API_KEY=…" > ~/.jev-gateway/.env
|
|
56
|
+
jev-codex # or: jev-claude
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
That is all the two launchers need (details below). To run the gateway as a standalone server for
|
|
60
|
+
your own clients, work from a checkout:
|
|
61
|
+
|
|
62
|
+
## Run it
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pnpm install
|
|
66
|
+
cp .env.example .env # set TYPESAFE_API_KEY, and UPSTREAM_BASE_URL if not OpenAI
|
|
67
|
+
pnpm dev
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from openai import OpenAI
|
|
72
|
+
client = OpenAI(base_url="http://localhost:8787/v1") # your usual provider key still works
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
By default the client's own `Authorization` header is forwarded upstream. Set `UPSTREAM_API_KEY`
|
|
76
|
+
to have the gateway hold the provider key, and `ROUTER_API_KEY` to require a gateway key from
|
|
77
|
+
clients. Any OpenAI-compatible upstream works (OpenAI, OpenRouter, vLLM, Ollama, LiteLLM, …).
|
|
78
|
+
|
|
79
|
+
### Try a decision without an upstream
|
|
80
|
+
|
|
81
|
+
`POST /router/decide` takes a chat.completions body, calls Jev, and returns the decision — mode,
|
|
82
|
+
tool, arguments, Jev's confidence, top probabilities, tokens and latency — without calling the LLM:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
curl -s localhost:8787/router/decide -H 'content-type: application/json' -d '{
|
|
86
|
+
"model": "gpt-5",
|
|
87
|
+
"messages": [{"role": "user", "content": "turn the kitchen lights on"}],
|
|
88
|
+
"tools": [{"type": "function", "function": {
|
|
89
|
+
"name": "set_lights", "description": "Turn the lights in a room on or off.",
|
|
90
|
+
"parameters": {"type": "object", "required": ["room", "on"], "properties": {
|
|
91
|
+
"room": {"type": "string", "enum": ["kitchen", "bedroom", "office"]},
|
|
92
|
+
"on": {"type": "boolean"}}}}}]
|
|
93
|
+
}'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Send `x-jev-gateway: off` on any request to bypass Jev for that call.
|
|
97
|
+
|
|
98
|
+
## Use it with Codex (local)
|
|
99
|
+
|
|
100
|
+
Codex only speaks the Responses API, so the router handles `POST /v1/responses` the same way as
|
|
101
|
+
chat completions — including Codex's free-form tools (`apply_patch`), provider-run tools
|
|
102
|
+
(`web_search`, offered to Jev but never forced) and zstd-compressed request bodies.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
jev-codex # instead of `codex`; every codex argument still works
|
|
106
|
+
jev-codex exec "fix the failing test"
|
|
107
|
+
jev-codex --jev-logs # second terminal: watch each routing decision live
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`jev-codex` starts a background router on `127.0.0.1:8790` if one isn't running, then launches
|
|
111
|
+
`codex` with a `-c model_providers.jev-gateway…` override. **Nothing in `~/.codex` is modified**, and
|
|
112
|
+
plain `codex` keeps working as before. It reuses your existing Codex login
|
|
113
|
+
(`requires_openai_auth = true`): with a ChatGPT subscription the router forwards to
|
|
114
|
+
`https://chatgpt.com/backend-api/codex`, with an API key to `https://api.openai.com/v1`
|
|
115
|
+
(override with `JEV_CODEX_UPSTREAM_BASE_URL`). `TYPESAFE_API_KEY` is read from the environment,
|
|
116
|
+
`~/.jev-gateway/.env`, or a checkout's own `.env`. `jev-codex --jev-help` lists the rest (`--jev-status`, `--jev-stop`,
|
|
117
|
+
`--jev-config` for a permanent `codex --profile jev`).
|
|
118
|
+
|
|
119
|
+
If the upstream rejects a rewritten request (HTTP 400/422 — some backends only accept
|
|
120
|
+
`tool_choice: "auto"`), the router replays the original, so Codex never sees a router-caused error.
|
|
121
|
+
|
|
122
|
+
## Use it with Claude Code (local)
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
jev-claude # instead of `claude`; every claude argument still works
|
|
126
|
+
jev-claude -p "summarise this repo"
|
|
127
|
+
jev-claude --jev-logs # second terminal: watch each routing decision live
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`jev-claude` starts a background router on `127.0.0.1:8789` forwarding to `https://api.anthropic.com/v1`
|
|
131
|
+
and runs `claude` with only `ANTHROPIC_BASE_URL` set. With no gateway credential alongside it, Claude
|
|
132
|
+
Code keeps using its saved login, so a **claude.ai subscription keeps working** and its limits apply
|
|
133
|
+
as usual; **nothing in `~/.claude` is modified**. The same `--jev-*` flags as `jev-codex` apply.
|
|
134
|
+
|
|
135
|
+
What Jev can do here is narrower than with Codex, by design of the API rather than the router:
|
|
136
|
+
Claude Code runs with adaptive thinking (a forced `tool_choice` is rejected) and re-reads a cached
|
|
137
|
+
conversation every turn (any `tool_choice` change would invalidate it). So Claude Code requests are
|
|
138
|
+
steered with `hint` mode, `none` is never applied, and `direct` still answers without the LLM when
|
|
139
|
+
a tool's arguments are all closed-set. API callers without thinking or message caching get `forced`.
|
|
140
|
+
|
|
141
|
+
## Configuration
|
|
142
|
+
|
|
143
|
+
See [.env.example](.env.example). The ones worth tuning:
|
|
144
|
+
|
|
145
|
+
- `JEV_MIN_CONFIDENCE` (0.7) — below this the LLM decides. Raise it to be more conservative.
|
|
146
|
+
- `JEV_ARG_MIN_CERTAINTY` (0.8) — the weakest argument must clear this for a `direct` answer;
|
|
147
|
+
otherwise the request degrades to `forced`.
|
|
148
|
+
- `ARGS_MODEL` — a cheap model for argument filling in `forced` mode.
|
|
149
|
+
- `JEV_DIRECT_CALLS=false` — never answer without the LLM; Jev only picks the tool.
|
|
150
|
+
|
|
151
|
+
Each routed request logs one JSON line (mode, reason, Jev's choice/confidence/latency) to stdout.
|
|
152
|
+
|
|
153
|
+
## Known trade-offs
|
|
154
|
+
|
|
155
|
+
- Jev picks **one** tool per turn. In `forced` mode the LLM can still call that tool several times
|
|
156
|
+
in parallel, but not mix different tools in one turn; `direct` mode emits exactly one call.
|
|
157
|
+
- Jev is text-only with a 32k-token state budget: images become `[image_url]` placeholders and long
|
|
158
|
+
conversations keep their newest turns (`JEV_MAX_STATE_CHARS`). It is most accurate in English.
|
|
159
|
+
- A hint is a suggestion, not a decision: in `hint` mode the LLM still spends its own reasoning on
|
|
160
|
+
the choice, so the gain is accuracy on large rosters, not latency or cost.
|
|
161
|
+
- Validated end to end on subscriptions (Codex 0.154 on ChatGPT, Claude Code 2.1 on claude.ai) with
|
|
162
|
+
`scripts/mock-jev.mjs` standing in for Jev: `forced`/`none` are accepted by the ChatGPT Codex
|
|
163
|
+
backend, `hint` by Anthropic. Jev's real accuracy on these rosters, and the confidence thresholds,
|
|
164
|
+
still need tuning against a real `TYPESAFE_API_KEY`.
|
|
165
|
+
|
|
166
|
+
## Layout
|
|
167
|
+
|
|
168
|
+
```
|
|
169
|
+
src/adapters/ wire formats ↔ neutral shapes: chat.ts, responses.ts (Codex), messages.ts (Claude Code)
|
|
170
|
+
src/state.ts conversation → Jev state (truncation, newest-turns budget)
|
|
171
|
+
src/questions.ts tools → Jev questions; detects closed-set parameters
|
|
172
|
+
src/decide.ts the Jev call and the mode decision
|
|
173
|
+
src/upstream.ts streaming reverse proxy
|
|
174
|
+
src/app.ts Hono app: routes, auth, headers, fail-open replay
|
|
175
|
+
bin/ jev-codex / jev-claude launchers (shared logic in launcher.mjs)
|
|
176
|
+
scripts/mock-jev.mjs local stand-in for Jev, for end-to-end runs without a TypeSafe key
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`pnpm test` runs the suite against fake Jev and upstream transports; no keys needed.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// jev-claude: run Claude Code through a local jev-gateway — nothing in ~/.claude is modified.
|
|
3
|
+
import { runLauncher } from "./launcher.mjs";
|
|
4
|
+
|
|
5
|
+
await runLauncher({
|
|
6
|
+
name: "jev-claude",
|
|
7
|
+
client: "claude",
|
|
8
|
+
portEnv: "JEV_CLAUDE_PORT",
|
|
9
|
+
defaultPort: 8789,
|
|
10
|
+
upstream: () => process.env.JEV_CLAUDE_UPSTREAM_BASE_URL ?? "https://api.anthropic.com/v1",
|
|
11
|
+
upstreamHelp: "JEV_CLAUDE_UPSTREAM_BASE_URL where Claude traffic goes (default https://api.anthropic.com/v1)",
|
|
12
|
+
// Only the base URL is set. With no gateway credential alongside it, Claude Code keeps using its
|
|
13
|
+
// saved claude.ai login, so a Pro/Max subscription (or an existing API key) keeps working as is.
|
|
14
|
+
env: (origin) => ({ ANTHROPIC_BASE_URL: origin }),
|
|
15
|
+
configHelp: (origin) =>
|
|
16
|
+
`# Keep the router running (jev-claude --jev-start), then either:\n` +
|
|
17
|
+
`# ANTHROPIC_BASE_URL=${origin} claude\n` +
|
|
18
|
+
`# or add to ~/.claude/settings.json:\n` +
|
|
19
|
+
JSON.stringify({ env: { ANTHROPIC_BASE_URL: origin } }, null, 2),
|
|
20
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// jev-codex: run Codex through a local jev-gateway — nothing in ~/.codex is modified.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { runLauncher } from "./launcher.mjs";
|
|
7
|
+
|
|
8
|
+
/** Codex talks to a different backend depending on how the user logged in. */
|
|
9
|
+
function detectUpstream() {
|
|
10
|
+
if (process.env.JEV_CODEX_UPSTREAM_BASE_URL) return process.env.JEV_CODEX_UPSTREAM_BASE_URL;
|
|
11
|
+
try {
|
|
12
|
+
const codexHome = process.env.CODEX_HOME ?? join(homedir(), ".codex");
|
|
13
|
+
const auth = JSON.parse(readFileSync(join(codexHome, "auth.json"), "utf8"));
|
|
14
|
+
if (auth.auth_mode === "chatgpt" || (auth.tokens && !auth.OPENAI_API_KEY)) {
|
|
15
|
+
return "https://chatgpt.com/backend-api/codex";
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
// No readable login: assume API-key usage.
|
|
19
|
+
}
|
|
20
|
+
return "https://api.openai.com/v1";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const provider = (origin) => ({
|
|
24
|
+
name: `"jev-gateway"`,
|
|
25
|
+
base_url: `"${origin}/v1"`,
|
|
26
|
+
wire_api: `"responses"`,
|
|
27
|
+
// Reuse whatever login Codex already has; the router forwards it upstream untouched.
|
|
28
|
+
requires_openai_auth: "true",
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await runLauncher({
|
|
32
|
+
name: "jev-codex",
|
|
33
|
+
client: "codex",
|
|
34
|
+
portEnv: "JEV_CODEX_PORT",
|
|
35
|
+
defaultPort: 8790,
|
|
36
|
+
upstream: detectUpstream,
|
|
37
|
+
upstreamHelp:
|
|
38
|
+
"JEV_CODEX_UPSTREAM_BASE_URL where Codex traffic goes; default follows your Codex login:\n" +
|
|
39
|
+
" ChatGPT login → https://chatgpt.com/backend-api/codex\n" +
|
|
40
|
+
" API key → https://api.openai.com/v1",
|
|
41
|
+
args: (origin) => [
|
|
42
|
+
"-c",
|
|
43
|
+
`model_provider="jev-gateway"`,
|
|
44
|
+
...Object.entries(provider(origin)).flatMap(([key, value]) => ["-c", `model_providers.jev-gateway.${key}=${value}`]),
|
|
45
|
+
],
|
|
46
|
+
configHelp: (origin) =>
|
|
47
|
+
`# Save as ~/.codex/jev.config.toml, keep the router running (jev-codex --jev-start),\n` +
|
|
48
|
+
`# then use: codex --profile jev\n` +
|
|
49
|
+
`model_provider = "jev-gateway"\n\n[model_providers.jev-gateway]\n` +
|
|
50
|
+
Object.entries(provider(origin))
|
|
51
|
+
.map(([key, value]) => `${key} = ${value}`)
|
|
52
|
+
.join("\n"),
|
|
53
|
+
});
|
package/bin/launcher.mjs
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Shared by the jev-<client> launchers: keep one background router per client alive, then run
|
|
2
|
+
// the client pointed at it. Nothing in the client's own config directory is ever modified.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
const STATE_DIR = join(homedir(), ".jev-gateway");
|
|
11
|
+
// A git checkout runs the TypeScript sources directly; an installed package only ships dist/.
|
|
12
|
+
const FROM_SOURCE = existsSync(join(ROOT, "src/index.ts"));
|
|
13
|
+
const ROUTER_ARGS = FROM_SOURCE ? ["--import", "tsx", join(ROOT, "src/index.ts")] : [join(ROOT, "dist/index.js")];
|
|
14
|
+
/** Where TYPESAFE_API_KEY and tuning knobs may live; the first file to set a variable wins. */
|
|
15
|
+
const ENV_FILES = [...(FROM_SOURCE ? [join(ROOT, ".env")] : []), join(STATE_DIR, ".env")];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} spec
|
|
19
|
+
* @param {string} spec.name launcher name, e.g. "jev-claude"
|
|
20
|
+
* @param {string} spec.client binary to run, e.g. "claude"; also names the log/pid files
|
|
21
|
+
* @param {string} spec.portEnv env var overriding the router port
|
|
22
|
+
* @param {number} spec.defaultPort
|
|
23
|
+
* @param {() => string} spec.upstream where this client's traffic is forwarded
|
|
24
|
+
* @param {string} spec.upstreamHelp help text describing the upstream default
|
|
25
|
+
* @param {(origin: string) => string[]} [spec.args] extra leading arguments for the client
|
|
26
|
+
* @param {(origin: string) => Record<string, string>} [spec.env] extra environment for the client
|
|
27
|
+
* @param {(origin: string) => string} spec.configHelp how to wire the client up permanently
|
|
28
|
+
*/
|
|
29
|
+
export async function runLauncher(spec) {
|
|
30
|
+
// Real environment variables win over both files.
|
|
31
|
+
for (const file of ENV_FILES) if (existsSync(file)) process.loadEnvFile(file);
|
|
32
|
+
|
|
33
|
+
const port = Number(process.env[spec.portEnv] ?? spec.defaultPort);
|
|
34
|
+
const origin = `http://127.0.0.1:${port}`;
|
|
35
|
+
const logFile = join(STATE_DIR, `${spec.client}.log`);
|
|
36
|
+
const pidFile = join(STATE_DIR, `${spec.client}.pid`);
|
|
37
|
+
|
|
38
|
+
const help = `${spec.name} — ${spec.client} with tool selection routed through Jev
|
|
39
|
+
|
|
40
|
+
${spec.name} [${spec.client} args…] start the router if needed, then run ${spec.client} through it
|
|
41
|
+
${spec.name} --jev-start only start the background router
|
|
42
|
+
${spec.name} --jev-status is the router up, and where does it forward to?
|
|
43
|
+
${spec.name} --jev-logs follow the router's decisions (run in a second terminal)
|
|
44
|
+
${spec.name} --jev-stop stop the background router
|
|
45
|
+
${spec.name} --jev-config how to point plain \`${spec.client}\` at the router permanently
|
|
46
|
+
|
|
47
|
+
Environment (or ${ENV_FILES.at(-1)}):
|
|
48
|
+
TYPESAFE_API_KEY required — Jev makes the tool-selection call
|
|
49
|
+
${spec.portEnv} router port for ${spec.client} (default ${spec.defaultPort})
|
|
50
|
+
${spec.upstreamHelp}
|
|
51
|
+
`;
|
|
52
|
+
|
|
53
|
+
const health = async () => {
|
|
54
|
+
try {
|
|
55
|
+
const response = await fetch(`${origin}/health`, { signal: AbortSignal.timeout(1000) });
|
|
56
|
+
return response.ok ? await response.json() : undefined;
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const tailLog = (lines = 15) =>
|
|
63
|
+
existsSync(logFile) ? readFileSync(logFile, "utf8").trimEnd().split("\n").slice(-lines).join("\n") : "";
|
|
64
|
+
|
|
65
|
+
const ensureRouter = async () => {
|
|
66
|
+
const upstream = spec.upstream().replace(/\/+$/, "");
|
|
67
|
+
const running = await health();
|
|
68
|
+
if (running) {
|
|
69
|
+
if (running.upstream === upstream) return;
|
|
70
|
+
console.error(`${spec.name}: router on :${port} forwards to ${running.upstream}, expected ${upstream}.`);
|
|
71
|
+
console.error(`${" ".repeat(spec.name.length)} Run \`${spec.name} --jev-stop\` and try again.`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
if (!process.env.TYPESAFE_API_KEY) {
|
|
75
|
+
console.error(`${spec.name}: TYPESAFE_API_KEY is not set. Export it, or put it in ${ENV_FILES.at(-1)}`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
80
|
+
const log = openSync(logFile, "a");
|
|
81
|
+
// The client authenticates itself (subscription login or its own key); the gateway must not swap that out.
|
|
82
|
+
const { UPSTREAM_API_KEY: _key, ROUTER_API_KEY: _routerKey, ...env } = process.env;
|
|
83
|
+
const child = spawn(process.execPath, ROUTER_ARGS, {
|
|
84
|
+
cwd: ROOT,
|
|
85
|
+
env: { ...env, PORT: String(port), UPSTREAM_BASE_URL: upstream },
|
|
86
|
+
detached: true,
|
|
87
|
+
stdio: ["ignore", log, log],
|
|
88
|
+
});
|
|
89
|
+
closeSync(log);
|
|
90
|
+
child.unref();
|
|
91
|
+
writeFileSync(pidFile, String(child.pid));
|
|
92
|
+
|
|
93
|
+
let exited = false;
|
|
94
|
+
child.once("exit", () => (exited = true));
|
|
95
|
+
for (let attempt = 0; attempt < 50 && !exited; attempt++) {
|
|
96
|
+
if (await health()) return;
|
|
97
|
+
await new Promise((done) => setTimeout(done, 100));
|
|
98
|
+
}
|
|
99
|
+
console.error(`${spec.name}: the router did not start. Last log lines (${logFile}):\n${tailLog()}`);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const stopRouter = () => {
|
|
104
|
+
if (!existsSync(pidFile)) return console.log(`${spec.name}: no background router recorded.`);
|
|
105
|
+
const pid = Number(readFileSync(pidFile, "utf8"));
|
|
106
|
+
try {
|
|
107
|
+
process.kill(pid);
|
|
108
|
+
console.log(`${spec.name}: stopped router (pid ${pid}).`);
|
|
109
|
+
} catch {
|
|
110
|
+
console.log(`${spec.name}: router was not running.`);
|
|
111
|
+
}
|
|
112
|
+
rmSync(pidFile, { force: true });
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const [flag] = process.argv.slice(2);
|
|
116
|
+
if (flag === "--jev-help") return console.log(help);
|
|
117
|
+
if (flag === "--jev-stop") return stopRouter();
|
|
118
|
+
if (flag === "--jev-config") return console.log(spec.configHelp(origin));
|
|
119
|
+
if (flag === "--jev-start") {
|
|
120
|
+
await ensureRouter();
|
|
121
|
+
return console.log(`${spec.name}: router up on ${origin} → ${spec.upstream()} (logs: ${logFile})`);
|
|
122
|
+
}
|
|
123
|
+
if (flag === "--jev-status") {
|
|
124
|
+
const running = await health();
|
|
125
|
+
console.log(running ? `${spec.name}: router up on ${origin} → ${running.upstream}` : `${spec.name}: router is not running`);
|
|
126
|
+
return console.log(`logs: ${logFile}`);
|
|
127
|
+
}
|
|
128
|
+
if (flag === "--jev-logs") {
|
|
129
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
130
|
+
closeSync(openSync(logFile, "a"));
|
|
131
|
+
return spawn("tail", ["-n", "30", "-f", logFile], { stdio: "inherit" });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await ensureRouter();
|
|
135
|
+
const child = spawn(spec.client, [...(spec.args?.(origin) ?? []), ...process.argv.slice(2)], {
|
|
136
|
+
stdio: "inherit",
|
|
137
|
+
env: { ...process.env, ...spec.env?.(origin) },
|
|
138
|
+
});
|
|
139
|
+
child.on("error", (error) => {
|
|
140
|
+
console.error(`${spec.name}: could not run ${spec.client}: ${error.message}`);
|
|
141
|
+
process.exit(127);
|
|
142
|
+
});
|
|
143
|
+
// The router is left running for the next session; `--jev-stop` ends it.
|
|
144
|
+
child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
|
|
145
|
+
// Ctrl-C reaches the client directly (same foreground process group); it decides what that means.
|
|
146
|
+
process.on("SIGINT", () => {});
|
|
147
|
+
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
|
148
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const sse = (events) => events.map(({ event, data }) => `${event ? `event: ${event}\n` : ""}data: ${data}\n\n`).join("");
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { textOf, truncate } from "../state.js";
|
|
3
|
+
import { sse } from "./adapter.js";
|
|
4
|
+
/** OpenAI Chat Completions (`POST /v1/chat/completions`). */
|
|
5
|
+
function toInput(req, maxMessageChars) {
|
|
6
|
+
if (!Array.isArray(req.messages))
|
|
7
|
+
return { skip: "no_messages" };
|
|
8
|
+
const rawTools = Array.isArray(req.tools) ? req.tools : [];
|
|
9
|
+
if (rawTools.some((tool) => tool.type !== "function" || !tool.function?.name))
|
|
10
|
+
return { skip: "non_function_tools" };
|
|
11
|
+
const toolNameByCallId = new Map();
|
|
12
|
+
for (const message of req.messages) {
|
|
13
|
+
for (const call of message.tool_calls ?? [])
|
|
14
|
+
toolNameByCallId.set(call.id, call.function.name);
|
|
15
|
+
}
|
|
16
|
+
const system = [];
|
|
17
|
+
const turns = [];
|
|
18
|
+
for (const message of req.messages) {
|
|
19
|
+
const text = truncate(textOf(message.content), maxMessageChars);
|
|
20
|
+
if (message.role === "system" || message.role === "developer") {
|
|
21
|
+
if (text)
|
|
22
|
+
system.push(text);
|
|
23
|
+
}
|
|
24
|
+
else if (message.role === "tool") {
|
|
25
|
+
turns.push({
|
|
26
|
+
role: "tool_result",
|
|
27
|
+
tool: toolNameByCallId.get(message.tool_call_id ?? "") ?? "unknown",
|
|
28
|
+
content: text,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
else if (message.tool_calls?.length) {
|
|
32
|
+
turns.push({
|
|
33
|
+
role: message.role,
|
|
34
|
+
...(text ? { text } : {}),
|
|
35
|
+
tool_calls: message.tool_calls.map((call) => ({
|
|
36
|
+
tool: call.function.name,
|
|
37
|
+
arguments: truncate(call.function.arguments, maxMessageChars),
|
|
38
|
+
})),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
turns.push({ role: message.role, text });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const choice = req.tool_choice ?? "auto";
|
|
46
|
+
return {
|
|
47
|
+
system: system.join("\n\n"),
|
|
48
|
+
turns,
|
|
49
|
+
tools: rawTools.map((tool) => ({
|
|
50
|
+
kind: "function",
|
|
51
|
+
name: tool.function.name,
|
|
52
|
+
description: tool.function.description,
|
|
53
|
+
parameters: tool.function.parameters,
|
|
54
|
+
})),
|
|
55
|
+
toolChoice: choice === "auto" || choice === "required" ? choice : "decided",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function apply(req, decision, argsModel) {
|
|
59
|
+
if (decision.mode === "forced") {
|
|
60
|
+
return {
|
|
61
|
+
...req,
|
|
62
|
+
model: argsModel ?? req.model,
|
|
63
|
+
tool_choice: { type: "function", function: { name: decision.tool } },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (decision.mode === "none")
|
|
67
|
+
return { ...req, tool_choice: "none" };
|
|
68
|
+
return req;
|
|
69
|
+
}
|
|
70
|
+
const usageOf = (call) => ({
|
|
71
|
+
prompt_tokens: call.inputTokens,
|
|
72
|
+
completion_tokens: 0,
|
|
73
|
+
total_tokens: call.inputTokens,
|
|
74
|
+
});
|
|
75
|
+
const ids = () => ({
|
|
76
|
+
id: `chatcmpl-jev-${randomUUID()}`,
|
|
77
|
+
callId: `call_${randomBytes(12).toString("hex")}`,
|
|
78
|
+
created: Math.floor(Date.now() / 1000),
|
|
79
|
+
});
|
|
80
|
+
/** A chat.completion carrying the tool call Jev decided on, shaped like an LLM's. */
|
|
81
|
+
function directJson(req, call) {
|
|
82
|
+
const { id, callId, created } = ids();
|
|
83
|
+
return {
|
|
84
|
+
id,
|
|
85
|
+
object: "chat.completion",
|
|
86
|
+
created,
|
|
87
|
+
model: req.model,
|
|
88
|
+
choices: [
|
|
89
|
+
{
|
|
90
|
+
index: 0,
|
|
91
|
+
message: {
|
|
92
|
+
role: "assistant",
|
|
93
|
+
content: null,
|
|
94
|
+
refusal: null,
|
|
95
|
+
tool_calls: [
|
|
96
|
+
{ id: callId, type: "function", function: { name: call.tool, arguments: JSON.stringify(call.args) } },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
logprobs: null,
|
|
100
|
+
finish_reason: "tool_calls",
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
usage: usageOf(call),
|
|
104
|
+
system_fingerprint: "jev-gateway",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/** The same tool call as a chat.completion.chunk stream, for `stream: true` clients. */
|
|
108
|
+
function directStream(req, call) {
|
|
109
|
+
const { id, callId, created } = ids();
|
|
110
|
+
const base = { id, object: "chat.completion.chunk", created, model: req.model, system_fingerprint: "jev-gateway" };
|
|
111
|
+
const delta = (delta, finish_reason = null) => ({
|
|
112
|
+
...base,
|
|
113
|
+
choices: [{ index: 0, delta, logprobs: null, finish_reason }],
|
|
114
|
+
});
|
|
115
|
+
const chunks = [
|
|
116
|
+
delta({
|
|
117
|
+
role: "assistant",
|
|
118
|
+
content: null,
|
|
119
|
+
tool_calls: [{ index: 0, id: callId, type: "function", function: { name: call.tool, arguments: "" } }],
|
|
120
|
+
}),
|
|
121
|
+
delta({ tool_calls: [{ index: 0, function: { arguments: JSON.stringify(call.args) } }] }),
|
|
122
|
+
delta({}, "tool_calls"),
|
|
123
|
+
];
|
|
124
|
+
if (req.stream_options?.include_usage)
|
|
125
|
+
chunks.push({ ...base, choices: [], usage: usageOf(call) });
|
|
126
|
+
return sse([...chunks.map((chunk) => ({ data: JSON.stringify(chunk) })), { data: "[DONE]" }]);
|
|
127
|
+
}
|
|
128
|
+
export const chatAdapter = { toInput, apply, directJson, directStream };
|