clauderipple 0.2.0 → 0.3.1
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 +96 -0
- package/README.ko.md +48 -4
- package/README.md +58 -4
- package/dist/cli/src/claude-auth.js +3 -2
- package/dist/cli/src/codex.js +20 -1
- package/dist/cli/src/hooks/agent-title.js +1 -1
- package/dist/cli/src/index.js +4 -4
- package/dist/cli/src/schtasks.js +43 -1
- package/dist/cli/src/settings.js +73 -6
- package/dist/cli/src/tray.js +17 -2
- package/dist/router/src/admin.js +489 -56
- package/dist/router/src/agents.js +250 -0
- package/dist/router/src/bootstrap.js +24 -8
- package/dist/router/src/capabilities.js +214 -0
- package/dist/router/src/compat.js +5 -1
- package/dist/router/src/config.js +264 -11
- package/dist/router/src/index.js +14 -1
- package/dist/router/src/ingress/server.js +24 -14
- package/dist/router/src/picker.js +14 -6
- package/dist/router/src/pool.js +233 -0
- package/dist/router/src/presets.js +163 -2
- package/dist/router/src/providers/anthropic-account-pool.js +139 -0
- package/dist/router/src/providers/anthropic-accounts.js +281 -0
- package/dist/router/src/providers/chatgpt/catalog.js +97 -0
- package/dist/router/src/providers/chatgpt/index.js +343 -12
- package/dist/router/src/providers/chatgpt/sse.js +4 -0
- package/dist/router/src/providers/chatgpt/translate.js +156 -14
- package/dist/router/src/providers/claude-oauth.js +61 -19
- package/dist/router/src/providers/openai/index.js +55 -11
- package/dist/router/src/providers/openai/translate.js +82 -14
- package/dist/router/src/providers/retry.js +88 -0
- package/dist/router/src/proxy.js +713 -82
- package/dist/router/src/requestlog.js +5 -2
- package/dist/router/src/routing.js +151 -17
- package/dist/router/src/version.js +1 -1
- package/dist/router/src/websearch.js +307 -0
- package/dist/router/src/x509.js +7 -2
- package/dist/ui/app.js +740 -160
- package/dist/ui/i18n.js +14 -6
- package/dist/ui/index.html +18 -5
- package/dist/ui/presets-fallback.js +2 -0
- package/dist/ui/style.css +133 -9
- package/docs/ARCHITECTURE.md +381 -20
- package/package.json +5 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Agent definitions, derived from one place: config.json.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code's Agent tool reads `~/.claude/agents/*.md` and takes the worker's model from the
|
|
4
|
+
// frontmatter `model:`. Until now that was a third copy of the truth, beside `providers.*.models`
|
|
5
|
+
// (what the router knows) and `aliases` (what a `[[ripple: xxx@effort]]` marker resolves to), and
|
|
6
|
+
// the copies drifted: an agent file named `deepseek` had no matching alias, so the marker resolved
|
|
7
|
+
// to a model id no provider declared and `PASS` sent thirty 404s to Anthropic (2026-09-20).
|
|
8
|
+
//
|
|
9
|
+
// This module derives both from config: the aliases a marker can name, and the agent files
|
|
10
|
+
// themselves. Files it writes are recorded in a manifest and are the only ones it may touch — a
|
|
11
|
+
// hand-written `muse.md` or `gpt.md` is never overwritten or removed.
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { homeDir } from "./config.js";
|
|
16
|
+
import { declaredBy } from "./routing.js";
|
|
17
|
+
/** Where Claude Code's Agent tool reads its worker definitions. */
|
|
18
|
+
export function defaultAgentDir() {
|
|
19
|
+
return path.join(os.homedir(), ".claude", "agents");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* `cfg` with the agent files' derived aliases merged in. An explicit `cfg.aliases` entry wins, so a
|
|
23
|
+
* hand-written alias is never overridden by a generated one.
|
|
24
|
+
*/
|
|
25
|
+
export function withAgentAliases(cfg, dir, log) {
|
|
26
|
+
const derived = agentAliases(dir, log);
|
|
27
|
+
return { ...cfg, aliases: { ...derived, ...cfg.aliases } };
|
|
28
|
+
}
|
|
29
|
+
/** The body every generated worker carries, verbatim. */
|
|
30
|
+
const BODY = [
|
|
31
|
+
"너는 이 세션의 실행자다. 위임받은 작업을 직접 끝내고 직접 검증해서 결론만 간결히 보고한다.",
|
|
32
|
+
"파일 전문·코드 덤프는 보고에 넣지 않는다. 추측은 추측이라 명시하고 근거는 파일:줄번호로 댄다.",
|
|
33
|
+
"다른 에이전트에게 넘기지 않는다. 프롬프트 첫 줄의 `[[ripple: …]]` 표식은 라우팅용이니 무시한다.",
|
|
34
|
+
"**작업 디렉터리(cwd)에 어떤 파일도 만들지 않는다.** 조사·감사처럼 \"읽기만\" 하는 일이어도 마찬가지다.",
|
|
35
|
+
"임시 파일이 필요하면 `/tmp` 아래에만 만들고, 끝나면 지운다. 사본을 작업 폴더에 떨구지 마라.",
|
|
36
|
+
].join("\n");
|
|
37
|
+
/**
|
|
38
|
+
* Parsed `*.md` frontmatter per directory, keyed by file mtime so a request never re-reads the whole
|
|
39
|
+
* directory. Only the `---` block's own `key: value` lines are read: no YAML library, because the
|
|
40
|
+
* two keys we need are two lines.
|
|
41
|
+
*/
|
|
42
|
+
const cache = new Map();
|
|
43
|
+
function parseFrontmatter(text) {
|
|
44
|
+
const lines = text.split(/\r?\n/);
|
|
45
|
+
if (lines[0]?.trim() !== "---")
|
|
46
|
+
return null;
|
|
47
|
+
const out = {};
|
|
48
|
+
for (let i = 1; i < lines.length; i++) {
|
|
49
|
+
const line = lines[i];
|
|
50
|
+
if (line.trim() === "---")
|
|
51
|
+
return out;
|
|
52
|
+
const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
53
|
+
if (m)
|
|
54
|
+
out[m[1].toLowerCase()] = m[2].trim().replace(/^['"]|['"]$/g, "");
|
|
55
|
+
}
|
|
56
|
+
return null; // no closing fence: not frontmatter we understand
|
|
57
|
+
}
|
|
58
|
+
/** The `*.md` files of `dir`, parsed and cached on their mtimes. Parse failures are skipped. */
|
|
59
|
+
function scanDir(dir, log) {
|
|
60
|
+
let files;
|
|
61
|
+
try {
|
|
62
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
cache.delete(dir);
|
|
66
|
+
return new Map();
|
|
67
|
+
}
|
|
68
|
+
const prev = cache.get(dir) ?? new Map();
|
|
69
|
+
const next = new Map();
|
|
70
|
+
let changed = false;
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
let st;
|
|
73
|
+
try {
|
|
74
|
+
st = fs.statSync(path.join(dir, file));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const hit = prev.get(file);
|
|
80
|
+
if (hit && hit.mtimeMs === st.mtimeMs) {
|
|
81
|
+
next.set(file, hit);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
changed = true;
|
|
85
|
+
let text;
|
|
86
|
+
try {
|
|
87
|
+
text = fs.readFileSync(path.join(dir, file), "utf8");
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
log?.warn(`agent ${file}: unreadable (${e.message}); skipped`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const fm = parseFrontmatter(text);
|
|
94
|
+
if (!fm)
|
|
95
|
+
continue; // no frontmatter: not an agent definition, nothing to warn about
|
|
96
|
+
const name = fm.name;
|
|
97
|
+
if (!name) {
|
|
98
|
+
log?.warn(`agent ${file}: frontmatter has no name; skipped`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
next.set(file, { name, model: fm.model ?? null, mtimeMs: st.mtimeMs });
|
|
102
|
+
}
|
|
103
|
+
if (changed || prev.size !== next.size)
|
|
104
|
+
cache.set(dir, next);
|
|
105
|
+
return next;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* `{ [name]: model id without its "@effort" }` for every agent file in `dir`. This is the derived
|
|
109
|
+
* half of the marker aliases; an explicit `cfg.aliases` entry wins over it at the call site.
|
|
110
|
+
*/
|
|
111
|
+
export function agentAliases(dir, log) {
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const entry of scanDir(dir, log).values()) {
|
|
114
|
+
if (!entry.model)
|
|
115
|
+
continue;
|
|
116
|
+
out[entry.name] = entry.model.replace(/@[a-z]+$/i, "");
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
/** The agent name (and file basename) a model id maps to: everything outside `[a-z0-9-]` becomes `-`. */
|
|
121
|
+
export function agentNameFor(modelId) {
|
|
122
|
+
return modelId.replace(/[^a-z0-9-]/g, "-");
|
|
123
|
+
}
|
|
124
|
+
function capsEffortLevels(provider) {
|
|
125
|
+
return provider.caps?.effortLevels;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The `@effort` suffix a generated file's `model:` carries: `@medium` when the provider or the model
|
|
129
|
+
* offers medium reasoning, otherwise none. An empty `model.effortLevels` disables the provider
|
|
130
|
+
* fallback (config.ts), so it is honoured rather than ignored.
|
|
131
|
+
*/
|
|
132
|
+
function effortSuffix(provider, modelId) {
|
|
133
|
+
const modelLevels = provider.models?.find((m) => m.id === modelId)?.effortLevels;
|
|
134
|
+
const levels = modelLevels !== undefined ? modelLevels : capsEffortLevels(provider);
|
|
135
|
+
return levels?.includes("medium") ? "@medium" : "";
|
|
136
|
+
}
|
|
137
|
+
function fileContent(name, modelId, provider, suffix) {
|
|
138
|
+
const description = `${modelId} via ${provider}. Generated by ClaudeRipple from config.json — edits are overwritten; ` +
|
|
139
|
+
`to customise, copy to another name. Set effort with [[ripple: ${name}@<level>]] on the first line.`;
|
|
140
|
+
return `---\nname: ${name}\ndescription: ${description}\nmodel: ${modelId}${suffix}\n---\n${BODY}\n`;
|
|
141
|
+
}
|
|
142
|
+
function readManifest(file) {
|
|
143
|
+
try {
|
|
144
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
145
|
+
if (Array.isArray(parsed.agents))
|
|
146
|
+
return { agents: parsed.agents.filter((a) => typeof a === "string") };
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* missing or broken: treat as empty, and it is rewritten below */
|
|
150
|
+
}
|
|
151
|
+
return { agents: [] };
|
|
152
|
+
}
|
|
153
|
+
function writeManifest(file, manifest, log) {
|
|
154
|
+
try {
|
|
155
|
+
fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + "\n");
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
log?.warn(`generated-agents manifest: cannot write ${file}: ${e.message}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Bring `dir`'s generated agent files in step with `cfg`: one file per model exactly one non-anthropic
|
|
163
|
+
* provider declares, and remove the ones this router wrote whose model is no longer ticked. Only
|
|
164
|
+
* files named in the manifest are ever written or removed — a hand-written agent is left alone, and
|
|
165
|
+
* a name it already uses is skipped rather than overwritten.
|
|
166
|
+
*
|
|
167
|
+
* Never throws: a write failure is a warning, not a dead router.
|
|
168
|
+
*/
|
|
169
|
+
export function syncAgentFiles(cfg, dir, log, opts = {}) {
|
|
170
|
+
if (cfg.cli.agentFiles === false)
|
|
171
|
+
return;
|
|
172
|
+
const manifestFile = opts.manifestPath ?? path.join(homeDir(), "generated-agents.json");
|
|
173
|
+
const manifest = readManifest(manifestFile);
|
|
174
|
+
const owned = new Set(manifest.agents);
|
|
175
|
+
const scanned = scanDir(dir, log);
|
|
176
|
+
// Names already taken by files this router did not write: hand files win, always.
|
|
177
|
+
const handNames = new Set();
|
|
178
|
+
for (const entry of scanned.values())
|
|
179
|
+
if (!owned.has(entry.name))
|
|
180
|
+
handNames.add(entry.name);
|
|
181
|
+
// Targets: each model declared by exactly one provider, that provider not ingress-only.
|
|
182
|
+
const targets = new Map();
|
|
183
|
+
for (const [providerName, provider] of Object.entries(cfg.providers)) {
|
|
184
|
+
if (provider.type === "anthropic" && !provider.accountPool)
|
|
185
|
+
continue;
|
|
186
|
+
for (const model of provider.models ?? []) {
|
|
187
|
+
const owners = declaredBy(model.id, cfg);
|
|
188
|
+
if (owners.length !== 1) {
|
|
189
|
+
if (owners.length > 1)
|
|
190
|
+
log?.warn(`agent files: "${model.id}" is declared by ${owners.join(", ")}; not generating an agent for it`);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const name = agentNameFor(model.id);
|
|
194
|
+
const clash = targets.get(name);
|
|
195
|
+
if (clash) {
|
|
196
|
+
log?.warn(`agent files: "${model.id}" and "${clash.modelId}" both map to "${name}"; skipping "${model.id}"`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
targets.set(name, { modelId: model.id, provider: providerName, suffix: effortSuffix(provider, model.id) });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const nextOwned = [];
|
|
203
|
+
for (const [name, t] of targets) {
|
|
204
|
+
if (handNames.has(name)) {
|
|
205
|
+
log?.info?.(`agent files: "${name}" exists and is not ours; leaving it alone`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const file = path.join(dir, `${name}.md`);
|
|
209
|
+
const content = fileContent(name, t.modelId, t.provider, t.suffix);
|
|
210
|
+
const existing = scanned.get(`${name}.md`);
|
|
211
|
+
if (existing) {
|
|
212
|
+
let current = null;
|
|
213
|
+
try {
|
|
214
|
+
current = fs.readFileSync(file, "utf8");
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
current = null;
|
|
218
|
+
}
|
|
219
|
+
if (current === content) {
|
|
220
|
+
nextOwned.push(name); // already correct: do not rewrite
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
226
|
+
fs.writeFileSync(file, content);
|
|
227
|
+
nextOwned.push(name);
|
|
228
|
+
log?.info?.(`agent files: wrote ${name}.md (${t.modelId} via ${t.provider})`);
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
log?.warn(`agent files: cannot write ${file}: ${e.message}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// A model that is no longer ticked: remove the file this router wrote for it, and only that.
|
|
235
|
+
for (const name of owned) {
|
|
236
|
+
if (nextOwned.includes(name))
|
|
237
|
+
continue;
|
|
238
|
+
try {
|
|
239
|
+
fs.rmSync(path.join(dir, `${name}.md`), { force: true });
|
|
240
|
+
log?.info?.(`agent files: removed ${name}.md (model no longer configured)`);
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
log?.warn(`agent files: cannot remove ${name}.md: ${e.message}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const sorted = [...nextOwned].sort();
|
|
247
|
+
if (JSON.stringify(sorted) !== JSON.stringify([...manifest.agents].sort())) {
|
|
248
|
+
writeManifest(manifestFile, { agents: sorted }, log);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
|
+
import { agentAliases } from "./agents.js";
|
|
8
9
|
export const BOOTSTRAP_PATH = "/api/claude_cli/bootstrap";
|
|
9
10
|
/**
|
|
10
11
|
* Model ids named in the user's agent definitions (~/.claude/agents/*.md frontmatter `model:`),
|
|
@@ -46,16 +47,25 @@ function routable(id, cfg) {
|
|
|
46
47
|
}
|
|
47
48
|
export function injectBootstrap(body, cfg, agentDirs) {
|
|
48
49
|
const known = new Set(cfg.cli.extraModels.map((m) => m.model));
|
|
50
|
+
// The agent files' derived aliases are the same set a marker resolves against, so an id an agent
|
|
51
|
+
// file names (and its base) is routable here too — otherwise the CLI never lists it and the
|
|
52
|
+
// subagent silently falls back to the parent session's Claude model.
|
|
53
|
+
const aliasDir = agentDirs?.[0] ?? path.join(os.homedir(), ".claude", "agents");
|
|
54
|
+
const aliases = { ...agentAliases(aliasDir), ...cfg.aliases };
|
|
49
55
|
const fromAgents = agentModelIds(agentDirs)
|
|
50
|
-
.filter((id) => !known.has(id) && routable(id, cfg))
|
|
56
|
+
.filter((id) => !known.has(id) && routable(id, { ...cfg, aliases }))
|
|
51
57
|
.map((id) => {
|
|
52
58
|
const [base, effort] = id.split("@");
|
|
53
59
|
const named = cfg.cli.extraModels.find((m) => m.model === base);
|
|
54
|
-
|
|
60
|
+
// An "<model>@<effort>" entry is the same model with a different effort, so it has the same window.
|
|
61
|
+
return { model: id, name: `${named?.name ?? base}${effort ? ` · ${effort}` : ""}`, ...(named?.contextWindow ? { contextWindow: named.contextWindow } : {}) };
|
|
55
62
|
});
|
|
56
63
|
const extra = [...cfg.cli.extraModels, ...fromAgents];
|
|
57
64
|
const win = cfg.cli.autoCompactWindow;
|
|
58
|
-
|
|
65
|
+
// Routed models do not share a context window; the global value is only the fallback for entries
|
|
66
|
+
// that do not name their own, so a per-entry window alone is reason enough to write the map.
|
|
67
|
+
const anyWindow = win !== undefined || extra.some((m) => m.contextWindow) || Object.values(cfg.routes).some((r) => r.contextWindow);
|
|
68
|
+
if (extra.length === 0 && !anyWindow)
|
|
59
69
|
return body;
|
|
60
70
|
let j;
|
|
61
71
|
try {
|
|
@@ -68,12 +78,18 @@ export function injectBootstrap(body, cfg, agentDirs) {
|
|
|
68
78
|
const existing = Array.isArray(j.additional_model_options) ? j.additional_model_options : [];
|
|
69
79
|
j.additional_model_options = [...existing, ...extra];
|
|
70
80
|
}
|
|
71
|
-
if (
|
|
81
|
+
if (anyWindow) {
|
|
72
82
|
const acw = { ...(j.auto_compact_windows ?? {}) };
|
|
73
|
-
for (const m of extra)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
83
|
+
for (const m of extra) {
|
|
84
|
+
const w = m.contextWindow ?? win;
|
|
85
|
+
if (w)
|
|
86
|
+
acw[m.model] = w;
|
|
87
|
+
}
|
|
88
|
+
for (const [alias, route] of Object.entries(cfg.routes)) {
|
|
89
|
+
const w = route.contextWindow ?? win;
|
|
90
|
+
if (w)
|
|
91
|
+
acw[alias] = w;
|
|
92
|
+
}
|
|
77
93
|
j.auto_compact_windows = acw;
|
|
78
94
|
}
|
|
79
95
|
return Buffer.from(JSON.stringify(j));
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Measure what a model's capability actually is, by making it answer.
|
|
2
|
+
//
|
|
3
|
+
// A vendor's `/models` tells you ids, not capabilities. OpenCode Go reports only `id`, `object`,
|
|
4
|
+
// `created` and `owned_by` (measured 2026-09-22), yet one subscription serves several wires on one
|
|
5
|
+
// key: `/responses` for Muse Spark, `/chat/completions` for MiMo, `/messages` for MiniMax. The
|
|
6
|
+
// route a model is on is only knowable by asking it — `mimo-v2.6-pro` answered 503 on `/responses`
|
|
7
|
+
// and 200 on `/chat/completions`, while `muse-spark-1.3-contributor` answered the other way round
|
|
8
|
+
// (measured 2026-09-22). A model sent down the wrong wire reads to the user as an unexplained 529.
|
|
9
|
+
//
|
|
10
|
+
// The same is true of reasoning effort: `mimo-v2.6-pro` accepted `none`/`low`/`medium`/`high` and
|
|
11
|
+
// refused `minimal`/`xhigh` with 400 "Invalid request parameters" (measured 2026-09-22). The
|
|
12
|
+
// provider's whole ladder handed to every model therefore offers the GUI steps that fail.
|
|
13
|
+
//
|
|
14
|
+
// This module is pure: every network call goes through `deps.fetch`, so a test can drive it with a
|
|
15
|
+
// stub and nothing here reaches the network on its own.
|
|
16
|
+
import crypto from "node:crypto";
|
|
17
|
+
/** The endpoint a wire appends to a base. Matches what the router itself sends: `endpoint()` in
|
|
18
|
+
* providers/openai/index.ts, and `messagesUrl()` in admin.ts for the Anthropic wire. */
|
|
19
|
+
function endpointFor(wire, base) {
|
|
20
|
+
const trimmed = base.replace(/\/+$/, "");
|
|
21
|
+
if (wire === "chat")
|
|
22
|
+
return `${trimmed}/chat/completions`;
|
|
23
|
+
if (wire === "responses")
|
|
24
|
+
return `${trimmed}/responses`;
|
|
25
|
+
return `${trimmed}/v1/messages`;
|
|
26
|
+
}
|
|
27
|
+
function errorText(error) {
|
|
28
|
+
return error instanceof Error ? error.message : String(error);
|
|
29
|
+
}
|
|
30
|
+
function snippet(text) {
|
|
31
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 200);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The provider's own key, re-sent under the header this wire expects.
|
|
35
|
+
*
|
|
36
|
+
* Same rule as `reauthorized` in config.ts: only the auth header is replaced and every other header
|
|
37
|
+
* is kept, because sending both conventions at once would hand the endpoint a credential it did not
|
|
38
|
+
* ask for. Nothing recognisable to move means the headers are left exactly as they were rather than
|
|
39
|
+
* inventing an empty credential, which would read as a missing key instead of a config error.
|
|
40
|
+
*/
|
|
41
|
+
function authHeaders(candidate, headers) {
|
|
42
|
+
const out = { ...(headers ?? {}) };
|
|
43
|
+
if (!candidate.authHeader)
|
|
44
|
+
return out;
|
|
45
|
+
let key;
|
|
46
|
+
for (const [name, value] of Object.entries(out)) {
|
|
47
|
+
const lower = name.toLowerCase();
|
|
48
|
+
if (lower === "x-api-key") {
|
|
49
|
+
key ??= value;
|
|
50
|
+
delete out[name];
|
|
51
|
+
}
|
|
52
|
+
else if (lower === "authorization") {
|
|
53
|
+
key ??= value.replace(/^Bearer\s+/i, "");
|
|
54
|
+
delete out[name];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (key === undefined)
|
|
58
|
+
return { ...(headers ?? {}) };
|
|
59
|
+
if (candidate.authHeader === "x-api-key")
|
|
60
|
+
out["x-api-key"] = key;
|
|
61
|
+
else
|
|
62
|
+
out.authorization = `Bearer ${key}`;
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
function requestHeaders(candidate, deps) {
|
|
66
|
+
const headers = { "content-type": "application/json", ...authHeaders(candidate, deps.headers) };
|
|
67
|
+
// Some vendors gate on the session header: OpenCode Go answers 400 `MissingSessionID` without
|
|
68
|
+
// `x-opencode-session` (measured 2026-09-18), so a measurement that omitted it would report a
|
|
69
|
+
// working provider as broken. A fresh value per request, the same random-per-request shape
|
|
70
|
+
// admin.ts's probeProvider uses.
|
|
71
|
+
if (deps.sessionHeader)
|
|
72
|
+
headers[deps.sessionHeader] = `measure-${crypto.randomBytes(8).toString("hex")}`;
|
|
73
|
+
return headers;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The smallest request each wire accepts, shaped exactly as the router sends it.
|
|
77
|
+
*
|
|
78
|
+
* Effort travels the way that wire carries it: `reasoning_effort` for Chat Completions and
|
|
79
|
+
* `reasoning: { effort }` for Responses (providers/openai/translate.ts), and Anthropic Messages
|
|
80
|
+
* carries it as `output_config.effort`, which the anthropic-compatible adapter sanitises
|
|
81
|
+
* (compat.ts, `sanitizeForCompatible`).
|
|
82
|
+
*/
|
|
83
|
+
function requestBody(wire, id, effort) {
|
|
84
|
+
if (wire === "chat") {
|
|
85
|
+
return JSON.stringify({ model: id, max_tokens: 1, stream: false, messages: [{ role: "user", content: "hi" }], ...(effort ? { reasoning_effort: effort } : {}) });
|
|
86
|
+
}
|
|
87
|
+
if (wire === "responses") {
|
|
88
|
+
// Sixteen, not one: Responses refuses anything smaller with a 400 naming `max_output_tokens`
|
|
89
|
+
// (measured 2026-09-22 against OpenCode Go). Asking for one token there made every model on
|
|
90
|
+
// this wire look like it did not serve the wire at all, and every effort level look refused.
|
|
91
|
+
return JSON.stringify({ model: id, max_output_tokens: 16, input: "hi", ...(effort ? { reasoning: { effort } } : {}) });
|
|
92
|
+
}
|
|
93
|
+
return JSON.stringify({ model: id, max_tokens: 1, messages: [{ role: "user", content: "hi" }], ...(effort ? { output_config: { effort } } : {}) });
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Every effort level seen named by any endpoint here, weakest first.
|
|
97
|
+
*
|
|
98
|
+
* A model can take a level its provider's configured ladder never lists: measured 2026-09-22,
|
|
99
|
+
* `deepseek-v4.1-flash` answers 200 to `max`, which is absent from the OpenCode Go ladder, so
|
|
100
|
+
* measuring only what the provider declared could narrow a ladder but never widen one and the level
|
|
101
|
+
* stayed invisible. The candidate set has to be wider than the configuration it is correcting.
|
|
102
|
+
*/
|
|
103
|
+
const KNOWN_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "ultra", "max"];
|
|
104
|
+
/** A value no vendor defines, sent to make the endpoint name the ones it does. */
|
|
105
|
+
const NOT_A_LEVEL = "clauderipple-probe";
|
|
106
|
+
/** A refusal of the level itself, as opposed to anything that merely went wrong. OpenCode Go answers
|
|
107
|
+
* 400 for one model and 422 for another (measured 2026-09-22), so both count. */
|
|
108
|
+
function refusedLevel(status) {
|
|
109
|
+
return status === 400 || status === 422;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* A refusal aimed at the plan rather than the credential.
|
|
113
|
+
*
|
|
114
|
+
* Measured 2026-09-22: OpenCode answers 403 FreeTierError, "OpenCode's free tier can only be used
|
|
115
|
+
* from within OpenCode", for every model whose id ends in `-free`. The key is accepted and the
|
|
116
|
+
* model exists; it simply cannot be reached from here, ever. Reading that as an auth failure sends
|
|
117
|
+
* the operator to re-check a key that is fine and leaves the model looking merely unmeasured.
|
|
118
|
+
*/
|
|
119
|
+
export function refusedByPlan(detail) {
|
|
120
|
+
return /free.?tier|data.?policy|region|entitle|upgrade|subscription/i.test(detail);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The levels worth trying: what the endpoint says it takes, else everything known.
|
|
124
|
+
*
|
|
125
|
+
* Asked for a level that cannot exist, some endpoints answer with their whole list — "expected one
|
|
126
|
+
* of `none`, `minimal`, … `ultra`, `max`", or "Supported values: [minimal, low, … max]" (measured
|
|
127
|
+
* 2026-09-22). That is a candidate list and nothing more: `muse-spark-1.3-contributor` names `max`
|
|
128
|
+
* among its supported values and then refuses it with a 400, so what a level actually does is still
|
|
129
|
+
* settled by sending it.
|
|
130
|
+
*/
|
|
131
|
+
async function candidateLevels(id, candidate, ladder, deps) {
|
|
132
|
+
let listed = [];
|
|
133
|
+
try {
|
|
134
|
+
const response = await deps.fetch(endpointFor(candidate.wire, candidate.url), { method: "POST", headers: requestHeaders(candidate, deps), body: requestBody(candidate.wire, id, NOT_A_LEVEL) });
|
|
135
|
+
if (!response.ok)
|
|
136
|
+
listed = parseLevels(await response.text());
|
|
137
|
+
}
|
|
138
|
+
catch { /* the endpoint said nothing; fall back to everything known */ }
|
|
139
|
+
const wanted = listed.length > 0 ? listed : [...ladder, ...KNOWN_EFFORTS];
|
|
140
|
+
const seen = new Set(wanted);
|
|
141
|
+
// Canonical order first so the GUI reads weakest to strongest, then anything newly named.
|
|
142
|
+
return [...KNOWN_EFFORTS.filter((level) => seen.has(level)), ...wanted.filter((level) => !KNOWN_EFFORTS.includes(level))]
|
|
143
|
+
.filter((level, index, all) => all.indexOf(level) === index);
|
|
144
|
+
}
|
|
145
|
+
/** The levels an endpoint listed in its complaint, or nothing when it did not list any. */
|
|
146
|
+
export function parseLevels(message) {
|
|
147
|
+
const listed = /supported values:\s*\[([^\]]+)\]/i.exec(message)?.[1] ?? /expected one of\s+(.+?)(?:\s+at line\b|$)/i.exec(message)?.[1];
|
|
148
|
+
if (!listed)
|
|
149
|
+
return [];
|
|
150
|
+
return [...listed.matchAll(/[A-Za-z][A-Za-z0-9_-]*/g)].map((match) => match[0].toLowerCase()).filter((level) => level !== NOT_A_LEVEL);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Which levels this model accepts, one at a time.
|
|
154
|
+
*
|
|
155
|
+
* Only an explicit refusal is a capability answer. A 429, a 5xx or a dropped connection says nothing
|
|
156
|
+
* about the level itself, and dropping a level on one of those would quietly shrink the ladder the
|
|
157
|
+
* GUI offers over a momentary hiccup. Those levels are kept: an unmeasured level is not an absent one.
|
|
158
|
+
*/
|
|
159
|
+
async function measureLadder(id, candidate, ladder, deps) {
|
|
160
|
+
const supported = [];
|
|
161
|
+
const url = endpointFor(candidate.wire, candidate.url);
|
|
162
|
+
for (const level of await candidateLevels(id, candidate, ladder, deps)) {
|
|
163
|
+
let response;
|
|
164
|
+
try {
|
|
165
|
+
response = await deps.fetch(url, { method: "POST", headers: requestHeaders(candidate, deps), body: requestBody(candidate.wire, id, level) });
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
supported.push(level); // transient — keep
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!refusedLevel(response.status))
|
|
172
|
+
supported.push(level);
|
|
173
|
+
}
|
|
174
|
+
return supported;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Establish one model's wire and effort ladder by calling it.
|
|
178
|
+
*
|
|
179
|
+
* Candidates are tried in order and the first 200 wins; the rest are never called, so the common
|
|
180
|
+
* case (the provider's own wire, first) is one request. An auth failure stops everything: the key
|
|
181
|
+
* was refused before the path mattered, so reporting anything but "auth" would send the user to
|
|
182
|
+
* check the wire when the problem is the credential. When no candidate answers, the result carries
|
|
183
|
+
* the last status and body as `error` and no `wire`.
|
|
184
|
+
*/
|
|
185
|
+
export async function measureModel(id, candidates, ladder, deps) {
|
|
186
|
+
if (candidates.length === 0)
|
|
187
|
+
return { id, error: "no candidate wire to try" };
|
|
188
|
+
let failure = "no candidate wire answered";
|
|
189
|
+
for (const candidate of candidates) {
|
|
190
|
+
let response;
|
|
191
|
+
try {
|
|
192
|
+
response = await deps.fetch(endpointFor(candidate.wire, candidate.url), { method: "POST", headers: requestHeaders(candidate, deps), body: requestBody(candidate.wire, id) });
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
failure = `network: ${errorText(e)}`;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (response.status === 401)
|
|
199
|
+
return { id, error: "auth" };
|
|
200
|
+
if (response.status === 403) {
|
|
201
|
+
// A plan refusing the model is a fact about the model, and worth reporting as one: no other
|
|
202
|
+
// wire will answer either, so there is nothing left to try.
|
|
203
|
+
const detail = snippet(await response.text());
|
|
204
|
+
return { id, error: refusedByPlan(detail) ? `not-entitled: ${detail}` : "auth" };
|
|
205
|
+
}
|
|
206
|
+
if (response.ok) {
|
|
207
|
+
if (ladder.length === 0)
|
|
208
|
+
return { id, wire: candidate.wire };
|
|
209
|
+
return { id, wire: candidate.wire, effortLevels: await measureLadder(id, candidate, ladder, deps) };
|
|
210
|
+
}
|
|
211
|
+
failure = `${response.status} ${snippet(await response.text())}`.trim();
|
|
212
|
+
}
|
|
213
|
+
return { id, error: failure };
|
|
214
|
+
}
|
|
@@ -7,6 +7,7 @@ export const STRICT_COMPAT_CAPS = {
|
|
|
7
7
|
thinking: "none",
|
|
8
8
|
betas: false,
|
|
9
9
|
cacheControl: true,
|
|
10
|
+
serverTools: false,
|
|
10
11
|
};
|
|
11
12
|
export function resolveCompatibleCaps(preset, override) {
|
|
12
13
|
return {
|
|
@@ -14,6 +15,7 @@ export function resolveCompatibleCaps(preset, override) {
|
|
|
14
15
|
thinking: override?.thinking ?? preset?.thinking ?? STRICT_COMPAT_CAPS.thinking,
|
|
15
16
|
betas: override?.betas ?? preset?.betas ?? STRICT_COMPAT_CAPS.betas,
|
|
16
17
|
cacheControl: override?.cacheControl ?? preset?.cacheControl ?? STRICT_COMPAT_CAPS.cacheControl,
|
|
18
|
+
serverTools: override?.serverTools ?? preset?.serverTools ?? STRICT_COMPAT_CAPS.serverTools,
|
|
17
19
|
};
|
|
18
20
|
}
|
|
19
21
|
function record(value) {
|
|
@@ -131,7 +133,9 @@ export function sanitizeForCompatible(json, caps) {
|
|
|
131
133
|
kept.push(tool);
|
|
132
134
|
continue;
|
|
133
135
|
}
|
|
134
|
-
|
|
136
|
+
// A provider that runs server tools keeps them: dropping one it can execute throws away a
|
|
137
|
+
// capability the user is paying for, and the loss is invisible (see `serverTools`).
|
|
138
|
+
if (source.type !== undefined && source.type !== "custom" && !caps.serverTools) {
|
|
135
139
|
dropped++;
|
|
136
140
|
if (typeof source.name === "string")
|
|
137
141
|
droppedNames.add(source.name);
|