clauderipple 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +229 -0
- package/LICENSE +674 -0
- package/README.ko.md +328 -0
- package/README.md +372 -0
- package/bin/clauderipple.js +12 -0
- package/dist/app/assets/trayDownTemplate.png +0 -0
- package/dist/app/assets/trayDownTemplate@2x.png +0 -0
- package/dist/app/assets/trayTemplate.png +0 -0
- package/dist/app/assets/trayTemplate@2x.png +0 -0
- package/dist/app/assets/trayWarnTemplate.png +0 -0
- package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
- package/dist/app/assets/trayWin.png +0 -0
- package/dist/app/assets/trayWin@2x.png +0 -0
- package/dist/app/assets/trayWinDown.png +0 -0
- package/dist/app/assets/trayWinDown@2x.png +0 -0
- package/dist/app/assets/trayWinWarn.png +0 -0
- package/dist/app/assets/trayWinWarn@2x.png +0 -0
- package/dist/app/dist/main.js +518 -0
- package/dist/cli/src/browser.js +21 -0
- package/dist/cli/src/bundle.js +51 -0
- package/dist/cli/src/certs.js +33 -0
- package/dist/cli/src/claude-auth.js +112 -0
- package/dist/cli/src/codex.js +172 -0
- package/dist/cli/src/gen-certs.js +7 -0
- package/dist/cli/src/hooks/agent-title.js +160 -0
- package/dist/cli/src/index.js +489 -0
- package/dist/cli/src/launchd.js +183 -0
- package/dist/cli/src/picker.js +166 -0
- package/dist/cli/src/probe.js +55 -0
- package/dist/cli/src/runtime.js +62 -0
- package/dist/cli/src/schtasks.js +134 -0
- package/dist/cli/src/settings.js +142 -0
- package/dist/cli/src/supervisor.js +100 -0
- package/dist/cli/src/tray.js +85 -0
- package/dist/router/src/admin.js +945 -0
- package/dist/router/src/bootstrap.js +80 -0
- package/dist/router/src/certs.js +65 -0
- package/dist/router/src/compat.js +172 -0
- package/dist/router/src/config.js +179 -0
- package/dist/router/src/health.js +45 -0
- package/dist/router/src/identity.js +51 -0
- package/dist/router/src/index.js +144 -0
- package/dist/router/src/ingress/models.js +29 -0
- package/dist/router/src/ingress/server.js +400 -0
- package/dist/router/src/ingress/translate.js +457 -0
- package/dist/router/src/log.js +81 -0
- package/dist/router/src/picker.js +74 -0
- package/dist/router/src/presets.js +267 -0
- package/dist/router/src/providers/anthropic-observed.js +88 -0
- package/dist/router/src/providers/anthropic-token-file.js +48 -0
- package/dist/router/src/providers/anthropic.js +203 -0
- package/dist/router/src/providers/chatgpt/auth.js +226 -0
- package/dist/router/src/providers/chatgpt/index.js +274 -0
- package/dist/router/src/providers/chatgpt/sse.js +28 -0
- package/dist/router/src/providers/chatgpt/translate.js +393 -0
- package/dist/router/src/providers/claude-oauth.js +252 -0
- package/dist/router/src/providers/openai/index.js +193 -0
- package/dist/router/src/providers/openai/translate.js +504 -0
- package/dist/router/src/proxy.js +724 -0
- package/dist/router/src/redact.js +43 -0
- package/dist/router/src/requestlog.js +346 -0
- package/dist/router/src/routing.js +113 -0
- package/dist/router/src/version.js +8 -0
- package/dist/router/src/x509.js +203 -0
- package/dist/ui/app.js +1228 -0
- package/dist/ui/i18n.js +95 -0
- package/dist/ui/index.html +104 -0
- package/dist/ui/presets-fallback.js +61 -0
- package/dist/ui/style.css +347 -0
- package/docs/ARCHITECTURE.md +441 -0
- package/package.json +66 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The CLI fetches /api/claude_cli/bootstrap once per session. Two fields matter to us:
|
|
2
|
+
// additional_model_options → the CLI's own /model list (NOT the app picker; docs/ARCHITECTURE.md §3)
|
|
3
|
+
// auto_compact_windows → per-model compaction thresholds; routed models are unknown to the CLI
|
|
4
|
+
// and would otherwise fall back to 200K.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
export const BOOTSTRAP_PATH = "/api/claude_cli/bootstrap";
|
|
9
|
+
/**
|
|
10
|
+
* Model ids named in the user's agent definitions (~/.claude/agents/*.md frontmatter `model:`),
|
|
11
|
+
* e.g. `gpt-5.6-terra@high`. The CLI only accepts ids it saw in its bootstrap list; an agent whose
|
|
12
|
+
* model id is unknown silently runs on the parent session's Claude model (measured 2026-09-13 after
|
|
13
|
+
* the picker ids lost their `@effort` suffix). Any such id whose base is routable is injected too.
|
|
14
|
+
*/
|
|
15
|
+
export function agentModelIds(dirs = [path.join(os.homedir(), ".claude", "agents")]) {
|
|
16
|
+
const out = new Set();
|
|
17
|
+
for (const dir of dirs) {
|
|
18
|
+
let files;
|
|
19
|
+
try {
|
|
20
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
for (const f of files) {
|
|
26
|
+
try {
|
|
27
|
+
const head = fs.readFileSync(path.join(dir, f), "utf8").slice(0, 8192);
|
|
28
|
+
const m = /^model:\s*['"]?([A-Za-z0-9._\/:\-]+(?:@[a-z]+)?)/m.exec(head);
|
|
29
|
+
if (m)
|
|
30
|
+
out.add(m[1]);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
/* unreadable: skip */
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return [...out];
|
|
38
|
+
}
|
|
39
|
+
function routable(id, cfg) {
|
|
40
|
+
const base = id.split("@")[0];
|
|
41
|
+
if (cfg.cli.extraModels.some((m) => m.model === base))
|
|
42
|
+
return true;
|
|
43
|
+
if (cfg.routes[base] || cfg.aliases[base])
|
|
44
|
+
return true;
|
|
45
|
+
return cfg.direct.some((d) => base.startsWith(d.prefix));
|
|
46
|
+
}
|
|
47
|
+
export function injectBootstrap(body, cfg, agentDirs) {
|
|
48
|
+
const known = new Set(cfg.cli.extraModels.map((m) => m.model));
|
|
49
|
+
const fromAgents = agentModelIds(agentDirs)
|
|
50
|
+
.filter((id) => !known.has(id) && routable(id, cfg))
|
|
51
|
+
.map((id) => {
|
|
52
|
+
const [base, effort] = id.split("@");
|
|
53
|
+
const named = cfg.cli.extraModels.find((m) => m.model === base);
|
|
54
|
+
return { model: id, name: `${named?.name ?? base}${effort ? ` · ${effort}` : ""}` };
|
|
55
|
+
});
|
|
56
|
+
const extra = [...cfg.cli.extraModels, ...fromAgents];
|
|
57
|
+
const win = cfg.cli.autoCompactWindow;
|
|
58
|
+
if (extra.length === 0 && !win)
|
|
59
|
+
return body;
|
|
60
|
+
let j;
|
|
61
|
+
try {
|
|
62
|
+
j = JSON.parse(body.toString("utf8"));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return body;
|
|
66
|
+
}
|
|
67
|
+
if (extra.length > 0) {
|
|
68
|
+
const existing = Array.isArray(j.additional_model_options) ? j.additional_model_options : [];
|
|
69
|
+
j.additional_model_options = [...existing, ...extra];
|
|
70
|
+
}
|
|
71
|
+
if (win) {
|
|
72
|
+
const acw = { ...(j.auto_compact_windows ?? {}) };
|
|
73
|
+
for (const m of extra)
|
|
74
|
+
acw[m.model] = win;
|
|
75
|
+
for (const alias of Object.keys(cfg.routes))
|
|
76
|
+
acw[alias] = win;
|
|
77
|
+
j.auto_compact_windows = acw;
|
|
78
|
+
}
|
|
79
|
+
return Buffer.from(JSON.stringify(j));
|
|
80
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Per-host leaf certificates minted at runtime from the local CA (picker mode terminates
|
|
2
|
+
// claude.ai in addition to api.anthropic.com). Pure node:crypto like the installer, so no
|
|
3
|
+
// openssl binary is needed; results are cached in <home>/certs/<host>.pem|key and reused
|
|
4
|
+
// across restarts.
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import tls from "node:tls";
|
|
9
|
+
import { createLeaf } from "./x509.js";
|
|
10
|
+
export class CertStore {
|
|
11
|
+
home;
|
|
12
|
+
contexts = new Map();
|
|
13
|
+
constructor(home) {
|
|
14
|
+
this.home = home;
|
|
15
|
+
}
|
|
16
|
+
/** Context for `host`; mints a leaf on first use. Throws if the CA key is missing. */
|
|
17
|
+
contextFor(host) {
|
|
18
|
+
const cached = this.contexts.get(host);
|
|
19
|
+
if (cached)
|
|
20
|
+
return cached;
|
|
21
|
+
const { cert, key } = this.leafFor(host);
|
|
22
|
+
const ctx = tls.createSecureContext({ cert, key });
|
|
23
|
+
this.contexts.set(host, ctx);
|
|
24
|
+
return ctx;
|
|
25
|
+
}
|
|
26
|
+
/** Register a pre-existing leaf (the installer's api.anthropic.com leaf). */
|
|
27
|
+
register(host, certPem, keyPem) {
|
|
28
|
+
this.contexts.set(host, tls.createSecureContext({ cert: certPem, key: keyPem }));
|
|
29
|
+
}
|
|
30
|
+
has(host) {
|
|
31
|
+
return this.contexts.has(host);
|
|
32
|
+
}
|
|
33
|
+
leafFor(host) {
|
|
34
|
+
if (!/^[a-z0-9.-]+$/i.test(host))
|
|
35
|
+
throw new Error(`refusing to mint a certificate for "${host}"`);
|
|
36
|
+
const dir = path.join(this.home, "certs");
|
|
37
|
+
const certFile = path.join(dir, `${host}.pem`);
|
|
38
|
+
const keyFile = path.join(dir, `${host}.key`);
|
|
39
|
+
if (fs.existsSync(certFile) && fs.existsSync(keyFile)) {
|
|
40
|
+
const cert = fs.readFileSync(certFile);
|
|
41
|
+
// re-mint when within 30 days of expiry
|
|
42
|
+
try {
|
|
43
|
+
const notAfter = new crypto.X509Certificate(cert).validTo;
|
|
44
|
+
if (Date.parse(notAfter) - Date.now() > 30 * 86400_000)
|
|
45
|
+
return { cert, key: fs.readFileSync(keyFile) };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
/* unparseable cache entry: fall through and mint a fresh one */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const caPem = path.join(this.home, "ca.pem");
|
|
52
|
+
const caKey = path.join(this.home, "ca.key");
|
|
53
|
+
if (!fs.existsSync(caKey))
|
|
54
|
+
throw new Error(`CA key missing (${caKey}); re-run the installer`);
|
|
55
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
56
|
+
const leaf = createLeaf({
|
|
57
|
+
host,
|
|
58
|
+
caCertPem: fs.readFileSync(caPem, "utf8"),
|
|
59
|
+
caKeyPem: fs.readFileSync(caKey, "utf8"),
|
|
60
|
+
});
|
|
61
|
+
fs.writeFileSync(certFile, leaf.certPem);
|
|
62
|
+
fs.writeFileSync(keyFile, leaf.keyPem, { mode: 0o600 });
|
|
63
|
+
return { cert: Buffer.from(leaf.certPem), key: Buffer.from(leaf.keyPem) };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Sanitise Claude Code's Anthropic-only request extensions for providers that expose
|
|
2
|
+
// an Anthropic-shaped Messages endpoint without implementing Anthropic's full feature set.
|
|
3
|
+
// Pure: callers receive a new object and an audit list; the input is never mutated.
|
|
4
|
+
export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max", "ultra"];
|
|
5
|
+
export const STRICT_COMPAT_CAPS = {
|
|
6
|
+
effortLevels: [],
|
|
7
|
+
thinking: "none",
|
|
8
|
+
betas: false,
|
|
9
|
+
cacheControl: true,
|
|
10
|
+
};
|
|
11
|
+
export function resolveCompatibleCaps(preset, override) {
|
|
12
|
+
return {
|
|
13
|
+
effortLevels: [...(override?.effortLevels ?? preset?.effortLevels ?? STRICT_COMPAT_CAPS.effortLevels)],
|
|
14
|
+
thinking: override?.thinking ?? preset?.thinking ?? STRICT_COMPAT_CAPS.thinking,
|
|
15
|
+
betas: override?.betas ?? preset?.betas ?? STRICT_COMPAT_CAPS.betas,
|
|
16
|
+
cacheControl: override?.cacheControl ?? preset?.cacheControl ?? STRICT_COMPAT_CAPS.cacheControl,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function record(value) {
|
|
20
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
21
|
+
}
|
|
22
|
+
/** `anthropic-version` stays intact; only unsupported beta flags are removed. */
|
|
23
|
+
export function forwardCompatibleHeader(name, caps) {
|
|
24
|
+
return name.toLowerCase() !== "anthropic-beta" || caps.betas;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Return the nearest supported canonical effort level. Unknown requested levels fall back
|
|
28
|
+
* to the provider's lowest listed level, which is safer than forwarding a rejected value.
|
|
29
|
+
*/
|
|
30
|
+
export function clampEffort(effort, supported) {
|
|
31
|
+
if (supported.includes(effort))
|
|
32
|
+
return effort;
|
|
33
|
+
const requested = EFFORT_LEVELS.indexOf(effort);
|
|
34
|
+
const canonical = supported
|
|
35
|
+
.map((value) => ({ value, index: EFFORT_LEVELS.indexOf(value) }))
|
|
36
|
+
.filter((entry) => entry.index >= 0);
|
|
37
|
+
if (requested < 0 || canonical.length === 0)
|
|
38
|
+
return supported[0] ?? effort;
|
|
39
|
+
return canonical.reduce((best, candidate) => Math.abs(candidate.index - requested) < Math.abs(best.index - requested) ? candidate : best).value;
|
|
40
|
+
}
|
|
41
|
+
function stripCacheControl(value) {
|
|
42
|
+
if (Array.isArray(value)) {
|
|
43
|
+
let count = 0;
|
|
44
|
+
const out = value.map((item) => {
|
|
45
|
+
const next = stripCacheControl(item);
|
|
46
|
+
count += next.count;
|
|
47
|
+
return next.value;
|
|
48
|
+
});
|
|
49
|
+
return { value: out, count };
|
|
50
|
+
}
|
|
51
|
+
const source = record(value);
|
|
52
|
+
if (!source)
|
|
53
|
+
return { value, count: 0 };
|
|
54
|
+
let count = 0;
|
|
55
|
+
const out = {};
|
|
56
|
+
for (const [key, child] of Object.entries(source)) {
|
|
57
|
+
if (key === "cache_control") {
|
|
58
|
+
count++;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const next = stripCacheControl(child);
|
|
62
|
+
out[key] = next.value;
|
|
63
|
+
count += next.count;
|
|
64
|
+
}
|
|
65
|
+
return { value: out, count };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Remove extensions Claude Code sends only to Anthropic. This deliberately retains content
|
|
69
|
+
* verbatim except cache_control when the provider explicitly rejects prompt caching.
|
|
70
|
+
*/
|
|
71
|
+
export function sanitizeForCompatible(json, caps) {
|
|
72
|
+
const out = { ...json };
|
|
73
|
+
const changes = [];
|
|
74
|
+
const thinking = record(out.thinking);
|
|
75
|
+
if (thinking) {
|
|
76
|
+
if (thinking.type === "adaptive") {
|
|
77
|
+
if (caps.thinking === "enabled") {
|
|
78
|
+
out.thinking = { type: "enabled", budget_tokens: 8192 };
|
|
79
|
+
changes.push("thinking adaptive→enabled");
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
delete out.thinking;
|
|
83
|
+
changes.push("thinking");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
const next = { ...thinking };
|
|
88
|
+
let removed = false;
|
|
89
|
+
for (const key of ["block_binding", "display"]) {
|
|
90
|
+
if (key in next) {
|
|
91
|
+
delete next[key];
|
|
92
|
+
removed = true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (removed) {
|
|
96
|
+
out.thinking = next;
|
|
97
|
+
changes.push("thinking");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const key of ["context_management", "container", "thread", "diagnostics"]) {
|
|
102
|
+
if (key in out) {
|
|
103
|
+
delete out[key];
|
|
104
|
+
changes.push(key);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const outputConfig = record(out.output_config);
|
|
108
|
+
if (outputConfig) {
|
|
109
|
+
const effort = typeof outputConfig.effort === "string" ? outputConfig.effort : undefined;
|
|
110
|
+
if (!effort || caps.effortLevels.length === 0) {
|
|
111
|
+
delete out.output_config;
|
|
112
|
+
changes.push(effort ? `effort ${effort}→removed` : "output_config");
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
const clamped = clampEffort(effort, caps.effortLevels);
|
|
116
|
+
out.output_config = { effort: clamped };
|
|
117
|
+
if (clamped !== effort)
|
|
118
|
+
changes.push(`effort ${effort}→${clamped}`);
|
|
119
|
+
else if (Object.keys(outputConfig).length !== 1)
|
|
120
|
+
changes.push("output_config");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (Array.isArray(out.tools)) {
|
|
124
|
+
const kept = [];
|
|
125
|
+
const droppedNames = new Set();
|
|
126
|
+
let deferred = 0;
|
|
127
|
+
let dropped = 0;
|
|
128
|
+
for (const tool of out.tools) {
|
|
129
|
+
const source = record(tool);
|
|
130
|
+
if (!source) {
|
|
131
|
+
kept.push(tool);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (source.type !== undefined && source.type !== "custom") {
|
|
135
|
+
dropped++;
|
|
136
|
+
if (typeof source.name === "string")
|
|
137
|
+
droppedNames.add(source.name);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const next = { ...source };
|
|
141
|
+
if ("defer_loading" in next) {
|
|
142
|
+
delete next.defer_loading;
|
|
143
|
+
deferred++;
|
|
144
|
+
}
|
|
145
|
+
kept.push(next);
|
|
146
|
+
}
|
|
147
|
+
if (deferred > 0)
|
|
148
|
+
changes.push(`defer_loading×${deferred}`);
|
|
149
|
+
if (dropped > 0)
|
|
150
|
+
changes.push(`server_tools×${dropped}`);
|
|
151
|
+
if (deferred > 0 || dropped > 0)
|
|
152
|
+
out.tools = kept;
|
|
153
|
+
const toolChoice = record(out.tool_choice);
|
|
154
|
+
if (toolChoice && typeof toolChoice.name === "string" && droppedNames.has(toolChoice.name)) {
|
|
155
|
+
delete out.tool_choice;
|
|
156
|
+
changes.push("tool_choice");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (!caps.cacheControl) {
|
|
160
|
+
let removed = 0;
|
|
161
|
+
for (const key of ["system", "messages"]) {
|
|
162
|
+
if (!(key in out))
|
|
163
|
+
continue;
|
|
164
|
+
const next = stripCacheControl(out[key]);
|
|
165
|
+
out[key] = next.value;
|
|
166
|
+
removed += next.count;
|
|
167
|
+
}
|
|
168
|
+
if (removed > 0)
|
|
169
|
+
changes.push(`cache_control×${removed}`);
|
|
170
|
+
}
|
|
171
|
+
return { json: out, changes };
|
|
172
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Configuration: one JSON file, hot-reloaded on mtime change.
|
|
2
|
+
// Home directory layout (default ~/.clauderipple):
|
|
3
|
+
// config.json this file
|
|
4
|
+
// ca.pem ca.key leaf.pem leaf.key generated by the installer
|
|
5
|
+
// logs/router.log (+ rotated .1 .2 ...)
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
export const PICKER_HOSTS_DEFAULT = ["claude.ai"];
|
|
10
|
+
export function terminateHosts(c) {
|
|
11
|
+
const hosts = [c.upstream];
|
|
12
|
+
if (c.picker?.enabled)
|
|
13
|
+
hosts.push(...(c.picker.hosts ?? PICKER_HOSTS_DEFAULT));
|
|
14
|
+
return hosts;
|
|
15
|
+
}
|
|
16
|
+
export const DEFAULTS = {
|
|
17
|
+
listen: { host: "127.0.0.1", port: 8790 },
|
|
18
|
+
upstream: "api.anthropic.com",
|
|
19
|
+
providers: {},
|
|
20
|
+
routes: {},
|
|
21
|
+
direct: [],
|
|
22
|
+
aliases: {},
|
|
23
|
+
effortClamp: { ultra: "max" },
|
|
24
|
+
cli: { extraModels: [] },
|
|
25
|
+
health: { maxConsecutiveUpstreamFailures: 20 },
|
|
26
|
+
log: { maxBytes: 5 * 1024 * 1024, keep: 3 },
|
|
27
|
+
};
|
|
28
|
+
export function homeDir() {
|
|
29
|
+
return process.env.CLAUDERIPPLE_HOME ?? path.join(os.homedir(), ".clauderipple");
|
|
30
|
+
}
|
|
31
|
+
export function configPath() {
|
|
32
|
+
return path.join(homeDir(), "config.json");
|
|
33
|
+
}
|
|
34
|
+
function merge(base, over) {
|
|
35
|
+
return {
|
|
36
|
+
...base,
|
|
37
|
+
...over,
|
|
38
|
+
listen: { ...base.listen, ...(over.listen ?? {}) },
|
|
39
|
+
cli: { ...base.cli, ...(over.cli ?? {}) },
|
|
40
|
+
health: { ...base.health, ...(over.health ?? {}) },
|
|
41
|
+
log: { ...base.log, ...(over.log ?? {}) },
|
|
42
|
+
effortClamp: { ...base.effortClamp, ...(over.effortClamp ?? {}) },
|
|
43
|
+
...((over.admin ?? base.admin) ? { admin: over.admin ?? base.admin } : {}),
|
|
44
|
+
...(over.picker ? { picker: { ...over.picker } } : {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function validModels(models) {
|
|
48
|
+
return Array.isArray(models) && models.every((model) => model !== null && typeof model === "object" &&
|
|
49
|
+
typeof model.id === "string" &&
|
|
50
|
+
(model.name === undefined || typeof model.name === "string") &&
|
|
51
|
+
(model.effortLevels === undefined ||
|
|
52
|
+
(Array.isArray(model.effortLevels) && model.effortLevels.every((level) => typeof level === "string"))));
|
|
53
|
+
}
|
|
54
|
+
export function validate(c) {
|
|
55
|
+
const errors = [];
|
|
56
|
+
for (const [alias, r] of Object.entries(c.routes)) {
|
|
57
|
+
if (!c.providers[r.provider])
|
|
58
|
+
errors.push(`route ${alias}: unknown provider "${r.provider}"`);
|
|
59
|
+
if (!r.model)
|
|
60
|
+
errors.push(`route ${alias}: missing model`);
|
|
61
|
+
}
|
|
62
|
+
for (const d of c.direct) {
|
|
63
|
+
if (!c.providers[d.provider])
|
|
64
|
+
errors.push(`direct ${d.prefix}: unknown provider "${d.provider}"`);
|
|
65
|
+
}
|
|
66
|
+
for (const [name, p] of Object.entries(c.providers)) {
|
|
67
|
+
// Carried by every provider that puts together a system prompt, so checked once for all of them.
|
|
68
|
+
const shared = p;
|
|
69
|
+
if (shared.identity !== undefined && typeof shared.identity !== "boolean")
|
|
70
|
+
errors.push(`provider ${name}: identity must be true or false`);
|
|
71
|
+
if (shared.instructionsAppend !== undefined && typeof shared.instructionsAppend !== "string")
|
|
72
|
+
errors.push(`provider ${name}: instructionsAppend must be a string`);
|
|
73
|
+
if (p.type === "anthropic-compatible") {
|
|
74
|
+
if (!/^https?:\/\//.test(p.url))
|
|
75
|
+
errors.push(`provider ${name}: url must start with http:// or https://`);
|
|
76
|
+
if (p.preset !== undefined && typeof p.preset !== "string")
|
|
77
|
+
errors.push(`provider ${name}: preset must be a string`);
|
|
78
|
+
if (p.models !== undefined && !validModels(p.models)) {
|
|
79
|
+
errors.push(`provider ${name}: models must be entries with string id, optional string name, and optional string[] effortLevels`);
|
|
80
|
+
}
|
|
81
|
+
if (p.caps !== undefined) {
|
|
82
|
+
const caps = p.caps;
|
|
83
|
+
if (!caps || typeof caps !== "object" || Array.isArray(caps) ||
|
|
84
|
+
(caps.effortLevels !== undefined && (!Array.isArray(caps.effortLevels) || caps.effortLevels.some((level) => typeof level !== "string"))) ||
|
|
85
|
+
(caps.thinking !== undefined && caps.thinking !== "enabled" && caps.thinking !== "none") ||
|
|
86
|
+
(caps.betas !== undefined && typeof caps.betas !== "boolean") ||
|
|
87
|
+
(caps.cacheControl !== undefined && typeof caps.cacheControl !== "boolean")) {
|
|
88
|
+
errors.push(`provider ${name}: caps must contain effortLevels?: string[], thinking?: "enabled"|"none", betas?: boolean, cacheControl?: boolean`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else if (p.type === "chatgpt") {
|
|
93
|
+
if (p.models !== undefined && !validModels(p.models)) {
|
|
94
|
+
errors.push(`provider ${name}: models must be entries with string id, optional string name, and optional string[] effortLevels`);
|
|
95
|
+
}
|
|
96
|
+
if (p.url && !/^https?:\/\//.test(p.url))
|
|
97
|
+
errors.push(`provider ${name}: url must start with http:// or https://`);
|
|
98
|
+
}
|
|
99
|
+
else if (p.type === "openai-compatible") {
|
|
100
|
+
if (!/^https?:\/\//.test(p.url))
|
|
101
|
+
errors.push(`provider ${name}: url must start with http:// or https://`);
|
|
102
|
+
if (p.wire !== undefined && p.wire !== "chat" && p.wire !== "responses")
|
|
103
|
+
errors.push(`provider ${name}: wire must be "chat" or "responses"`);
|
|
104
|
+
if (p.preset !== undefined && typeof p.preset !== "string")
|
|
105
|
+
errors.push(`provider ${name}: preset must be a string`);
|
|
106
|
+
if (p.headers !== undefined && (!p.headers || typeof p.headers !== "object" || Array.isArray(p.headers) || Object.values(p.headers).some((value) => typeof value !== "string"))) {
|
|
107
|
+
errors.push(`provider ${name}: headers must be a string record`);
|
|
108
|
+
}
|
|
109
|
+
if (p.models !== undefined && !validModels(p.models)) {
|
|
110
|
+
errors.push(`provider ${name}: models must be entries with string id, optional string name, and optional string[] effortLevels`);
|
|
111
|
+
}
|
|
112
|
+
if (p.caps !== undefined && (!p.caps || typeof p.caps !== "object" || Array.isArray(p.caps) ||
|
|
113
|
+
(p.caps.effortLevels !== undefined && (!Array.isArray(p.caps.effortLevels) || p.caps.effortLevels.some((level) => typeof level !== "string"))) ||
|
|
114
|
+
(p.caps.reasoning !== undefined && p.caps.reasoning !== "effort" && p.caps.reasoning !== "none"))) {
|
|
115
|
+
errors.push(`provider ${name}: caps must contain effortLevels?: string[], reasoning?: "effort"|"none"`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else if (p.type === "anthropic") {
|
|
119
|
+
if (p.auth !== "api-key" && p.auth !== "claude-code")
|
|
120
|
+
errors.push(`provider ${name}: auth must be "api-key" or "claude-code"`);
|
|
121
|
+
if (p.apiKey !== undefined && typeof p.apiKey !== "string")
|
|
122
|
+
errors.push(`provider ${name}: apiKey must be a string`);
|
|
123
|
+
if (p.models !== undefined && !validModels(p.models)) {
|
|
124
|
+
errors.push(`provider ${name}: models must be entries with string id, optional string name, and optional string[] effortLevels`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
errors.push(`provider ${name}: unknown type "${p.type}"`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (!(c.listen.port > 0 && c.listen.port < 65536))
|
|
132
|
+
errors.push("listen.port out of range");
|
|
133
|
+
if (c.listen.openaiPort !== undefined && !(c.listen.openaiPort >= 0 && c.listen.openaiPort < 65536))
|
|
134
|
+
errors.push("listen.openaiPort out of range");
|
|
135
|
+
return errors;
|
|
136
|
+
}
|
|
137
|
+
export class ConfigStore {
|
|
138
|
+
current = DEFAULTS;
|
|
139
|
+
mtimeMs = -1;
|
|
140
|
+
file;
|
|
141
|
+
onChange;
|
|
142
|
+
constructor(file = configPath(), onChange) {
|
|
143
|
+
this.file = file;
|
|
144
|
+
this.onChange = onChange;
|
|
145
|
+
this.reload(true);
|
|
146
|
+
}
|
|
147
|
+
/** Cheap: one stat per call. Called per request. */
|
|
148
|
+
get() {
|
|
149
|
+
this.reload(false);
|
|
150
|
+
return this.current;
|
|
151
|
+
}
|
|
152
|
+
reload(initial) {
|
|
153
|
+
let st;
|
|
154
|
+
try {
|
|
155
|
+
st = fs.statSync(this.file);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
if (initial)
|
|
159
|
+
this.onChange?.(this.current, [`no config at ${this.file}; using defaults`]);
|
|
160
|
+
this.mtimeMs = -1;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (st.mtimeMs === this.mtimeMs)
|
|
164
|
+
return;
|
|
165
|
+
try {
|
|
166
|
+
const parsed = JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
167
|
+
const next = merge(DEFAULTS, parsed);
|
|
168
|
+
const errors = validate(next);
|
|
169
|
+
if (errors.length === 0 || initial)
|
|
170
|
+
this.current = next;
|
|
171
|
+
this.mtimeMs = st.mtimeMs;
|
|
172
|
+
this.onChange?.(this.current, errors);
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
this.mtimeMs = st.mtimeMs; // do not re-parse a broken file on every request
|
|
176
|
+
this.onChange?.(this.current, [`config parse error: ${e.message}`]);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Self-healing: a long-lived process whose resolver or socket layer has gone bad
|
|
2
|
+
// stays bad (observed: 10h of ENOTFOUND, 11 remote-control workers lost). After N
|
|
3
|
+
// consecutive upstream *connect-level* failures with no success in between, exit
|
|
4
|
+
// with a distinctive code so the supervisor restarts a fresh process.
|
|
5
|
+
export const EXIT_UPSTREAM_UNREACHABLE = 75;
|
|
6
|
+
const CONNECT_ERRORS = new Set([
|
|
7
|
+
"ENOTFOUND",
|
|
8
|
+
"EAI_AGAIN",
|
|
9
|
+
"EAI_FAIL",
|
|
10
|
+
"ECONNREFUSED",
|
|
11
|
+
"ECONNRESET",
|
|
12
|
+
"ETIMEDOUT",
|
|
13
|
+
"EHOSTUNREACH",
|
|
14
|
+
"ENETUNREACH",
|
|
15
|
+
"ENETDOWN",
|
|
16
|
+
]);
|
|
17
|
+
export function isConnectError(e) {
|
|
18
|
+
const code = e?.code;
|
|
19
|
+
return typeof code === "string" && CONNECT_ERRORS.has(code);
|
|
20
|
+
}
|
|
21
|
+
export class UpstreamHealth {
|
|
22
|
+
consecutive = 0;
|
|
23
|
+
tripped = false;
|
|
24
|
+
limit;
|
|
25
|
+
onTrip;
|
|
26
|
+
constructor(limit, onTrip) {
|
|
27
|
+
this.limit = limit;
|
|
28
|
+
this.onTrip = onTrip;
|
|
29
|
+
}
|
|
30
|
+
success() {
|
|
31
|
+
this.consecutive = 0;
|
|
32
|
+
}
|
|
33
|
+
failure(e) {
|
|
34
|
+
if (!isConnectError(e))
|
|
35
|
+
return;
|
|
36
|
+
this.consecutive++;
|
|
37
|
+
if (!this.tripped && this.consecutive >= this.limit()) {
|
|
38
|
+
this.tripped = true;
|
|
39
|
+
this.onTrip(this.consecutive);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
get consecutiveFailures() {
|
|
43
|
+
return this.consecutive;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// A routed model reads Claude Code's system prompt, which opens with "You are Claude Code,
|
|
2
|
+
// Anthropic's official CLI for Claude". Nothing else tells the model what it is, so without a line
|
|
3
|
+
// of its own DeepSeek answers that it is Claude (measured 2026-09-16). This is that line, and the
|
|
4
|
+
// fixed addendum that may follow the prompt, shared by every provider that can carry one.
|
|
5
|
+
//
|
|
6
|
+
// The text must stay constant across the turns of a session or the prompt cache misses: the
|
|
7
|
+
// identity depends on the model and the effort only, both fixed for a route, and the addendum is
|
|
8
|
+
// configuration. Changing either costs one cache miss.
|
|
9
|
+
/** The sentence put in front of the system prompt. Effort is named because a model cannot see its own setting. */
|
|
10
|
+
export function identityLine(model, effort) {
|
|
11
|
+
const reasoning = effort ? ` (reasoning effort: ${effort})` : "";
|
|
12
|
+
return `You are ${model}${reasoning}, answering through Claude Code, a terminal-based coding agent.`;
|
|
13
|
+
}
|
|
14
|
+
/** The identity line, when enabled, in the order it belongs: before the caller's own system text. */
|
|
15
|
+
export function identityPrefix(opts) {
|
|
16
|
+
return (opts.identity ?? true) ? identityLine(opts.model, opts.effort) : null;
|
|
17
|
+
}
|
|
18
|
+
/** The configured addendum, trimmed, or null when there is none to add. */
|
|
19
|
+
export function instructionsSuffix(opts) {
|
|
20
|
+
const text = opts.instructionsAppend?.trim();
|
|
21
|
+
return text ? text : null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Wraps an Anthropic Messages body's `system` with the identity line and the addendum, in place.
|
|
25
|
+
* A string system stays a string; a block array stays an array, and the added blocks carry no
|
|
26
|
+
* cache_control of their own so the caller's breakpoints keep their meaning.
|
|
27
|
+
*/
|
|
28
|
+
export function applyIdentityToAnthropicBody(json, opts) {
|
|
29
|
+
const prefix = identityPrefix(opts);
|
|
30
|
+
const suffix = instructionsSuffix(opts);
|
|
31
|
+
if (!prefix && !suffix)
|
|
32
|
+
return;
|
|
33
|
+
const system = json.system;
|
|
34
|
+
if (system === undefined || system === null || system === "") {
|
|
35
|
+
json.system = [prefix, suffix].filter(Boolean).join("\n\n");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (typeof system === "string") {
|
|
39
|
+
json.system = [prefix, system, suffix].filter(Boolean).join("\n\n");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (Array.isArray(system)) {
|
|
43
|
+
const blocks = system;
|
|
44
|
+
json.system = [
|
|
45
|
+
...(prefix ? [{ type: "text", text: prefix }] : []),
|
|
46
|
+
...blocks,
|
|
47
|
+
...(suffix ? [{ type: "text", text: suffix }] : []),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
// Any other shape is left alone: it is not something this router put together.
|
|
51
|
+
}
|