clauderipple 0.7.0 → 0.8.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 +51 -0
- package/README.ko.md +15 -1
- package/README.md +23 -3
- package/dist/app/package.json +1 -1
- package/dist/cli/src/claude-auth.js +35 -1
- package/dist/cli/src/codex.js +23 -0
- package/dist/cli/src/hooks/agent-title.js +58 -29
- package/dist/cli/src/index.js +46 -1
- package/dist/cli/src/runtime.js +3 -0
- package/dist/cli/src/settings.js +49 -0
- package/dist/mod/.claude-plugin/plugin.json +1 -0
- package/dist/mod/hooks/hooks.json +1 -0
- package/dist/mod/hooks/register.tsx +309 -0
- package/dist/mod/hooks/worker.ts +49 -0
- package/dist/mod/tsconfig.json +3 -0
- package/dist/mod/types/index.d.ts +30 -0
- package/dist/router/src/admin.js +411 -32
- package/dist/router/src/agents.js +24 -8
- package/dist/router/src/catalog.js +80 -0
- package/dist/router/src/compat.js +27 -1
- package/dist/router/src/config.js +31 -0
- package/dist/router/src/index.js +12 -1
- package/dist/router/src/ingress/server.js +3 -2
- package/dist/router/src/picker.js +33 -2
- package/dist/router/src/presets.js +2 -1
- package/dist/router/src/providers/chatgpt/index.js +42 -15
- package/dist/router/src/providers/chatgpt/translate.js +34 -6
- package/dist/router/src/providers/google/accounts.js +345 -0
- package/dist/router/src/providers/google/antigravity.js +392 -0
- package/dist/router/src/providers/google/index.js +617 -0
- package/dist/router/src/providers/google/login.js +249 -0
- package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
- package/dist/router/src/providers/google/translate.js +539 -0
- package/dist/router/src/providers/google/transport.js +118 -0
- package/dist/router/src/providers/openai/translate.js +3 -3
- package/dist/router/src/proxy.js +85 -1
- package/dist/router/src/requestlog.js +2 -0
- package/dist/router/src/routing.js +22 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +420 -45
- package/dist/ui/i18n.js +76 -4
- package/dist/ui/index.html +5 -0
- package/dist/ui/style.css +4 -0
- package/docs/ARCHITECTURE.md +287 -2
- package/package.json +1 -1
|
@@ -15,6 +15,7 @@ import path from "node:path";
|
|
|
15
15
|
import { homeDir } from "./config.js";
|
|
16
16
|
import { declaredBy } from "./routing.js";
|
|
17
17
|
import { adminPort } from "./admin.js";
|
|
18
|
+
import { shortName } from "../../cli/src/hooks/agent-title.js";
|
|
18
19
|
/** Where Claude Code's Agent tool reads its worker definitions. */
|
|
19
20
|
export function defaultAgentDir() {
|
|
20
21
|
return path.join(os.homedir(), ".claude", "agents");
|
|
@@ -147,18 +148,33 @@ function capsEffortLevels(provider) {
|
|
|
147
148
|
return provider.caps?.effortLevels;
|
|
148
149
|
}
|
|
149
150
|
/**
|
|
150
|
-
* The `@effort` suffix a generated file's `model:` carries: `@
|
|
151
|
-
* offers
|
|
152
|
-
*
|
|
151
|
+
* The `@effort` suffix a generated file's `model:` carries: `@high` when the provider or the model
|
|
152
|
+
* offers any reasoning effort, otherwise none. A ladder without `high` is fine — the request is
|
|
153
|
+
* clamped to the nearest level the model takes (compat.ts). It used to be `@medium`, which a ladder
|
|
154
|
+
* from the catalogue such as `low`/`high`/`max` does not hold, and the operator wants workers at
|
|
155
|
+
* high unless a marker says otherwise (2026-10-02). An empty `model.effortLevels` disables the
|
|
156
|
+
* provider fallback (config.ts), so it is honoured rather than ignored.
|
|
153
157
|
*/
|
|
154
158
|
function effortSuffix(provider, modelId) {
|
|
155
159
|
const modelLevels = provider.models?.find((m) => m.id === modelId)?.effortLevels;
|
|
156
160
|
const levels = modelLevels !== undefined ? modelLevels : capsEffortLevels(provider);
|
|
157
|
-
return levels
|
|
161
|
+
return levels && levels.length > 0 ? "@high" : "";
|
|
158
162
|
}
|
|
159
|
-
|
|
163
|
+
/**
|
|
164
|
+
* What the parent is asked to start the Agent `description` with. The Desktop task panel names only
|
|
165
|
+
* Claude models, and rebuilt from the transcript it shows the description as the parent wrote it,
|
|
166
|
+
* so the agent-title hook's live correction is lost (app web bundle, 2026-09-30); a prefix the
|
|
167
|
+
* parent writes itself survives. Same form as the hook's: the display name's last word, `·level`.
|
|
168
|
+
*/
|
|
169
|
+
function titlePrefix(title, suffix) {
|
|
170
|
+
return suffix
|
|
171
|
+
? `Start the Agent description with "${title}·<level> · " (the marker's level, else ${suffix.slice(1)}) so the app's task list shows the model.`
|
|
172
|
+
: `Start the Agent description with "${title} · " so the app's task list shows the model.`;
|
|
173
|
+
}
|
|
174
|
+
function fileContent(name, modelId, title, provider, suffix, limitTools, imagePort) {
|
|
160
175
|
const description = `${modelId} via ${provider}. Generated by ClaudeRipple from config.json — edits are overwritten; ` +
|
|
161
|
-
`to customise, copy to another name. Set effort with [[ripple: ${name}@<level>]] on the first line
|
|
176
|
+
`to customise, copy to another name. Set effort with [[ripple: ${name}@<level>]] on the first line. ` +
|
|
177
|
+
titlePrefix(title, suffix);
|
|
162
178
|
const tools = limitTools ? `tools: ${TOOLS.join(", ")}\n` : "";
|
|
163
179
|
const body = imagePort === null ? BODY : `${BODY}\n${imageLine(imagePort)}`;
|
|
164
180
|
return `---\nname: ${name}\ndescription: ${description}\nmodel: ${modelId}${suffix}\n${tools}---\n${body}\n`;
|
|
@@ -220,7 +236,7 @@ export function syncAgentFiles(cfg, dir, log, opts = {}) {
|
|
|
220
236
|
log?.warn(`agent files: "${model.id}" and "${clash.modelId}" both map to "${name}"; skipping "${model.id}"`);
|
|
221
237
|
continue;
|
|
222
238
|
}
|
|
223
|
-
targets.set(name, { modelId: model.id, provider: providerName, suffix: effortSuffix(provider, model.id) });
|
|
239
|
+
targets.set(name, { modelId: model.id, title: shortName(model.name ?? model.id), provider: providerName, suffix: effortSuffix(provider, model.id) });
|
|
224
240
|
}
|
|
225
241
|
}
|
|
226
242
|
const imagePort = Object.values(cfg.providers).some((p) => p.type === "chatgpt") ? adminPort(cfg) : null;
|
|
@@ -231,7 +247,7 @@ export function syncAgentFiles(cfg, dir, log, opts = {}) {
|
|
|
231
247
|
continue;
|
|
232
248
|
}
|
|
233
249
|
const file = path.join(dir, `${name}.md`);
|
|
234
|
-
const content = fileContent(name, t.modelId, t.provider, t.suffix, cfg.cli.limitWorkerTools === true, imagePort);
|
|
250
|
+
const content = fileContent(name, t.modelId, t.title, t.provider, t.suffix, cfg.cli.limitWorkerTools === true, imagePort);
|
|
235
251
|
const existing = scanned.get(`${name}.md`);
|
|
236
252
|
if (existing) {
|
|
237
253
|
let current = null;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// What OpenCode itself knows about each model, read from the catalogue its own client is built on.
|
|
2
|
+
//
|
|
3
|
+
// OpenCode's docs publish an endpoint per model and no effort ladder at all, and its `/models`
|
|
4
|
+
// reports ids alone, so `capabilities.ts` established both by sending requests: about ten per
|
|
5
|
+
// model, one after another. With upstream latency swinging from 0.5s to 25s on identical one-token
|
|
6
|
+
// requests (glm-5.3-flash, measured 2026-10-02), three models took 2m33s.
|
|
7
|
+
//
|
|
8
|
+
// models.dev (https://models.dev/api.json, maintained by the team behind OpenCode) states both for
|
|
9
|
+
// every model OpenCode serves: `reasoning_options` lists the effort values, and `provider.npm` names
|
|
10
|
+
// the SDK the OpenCode client speaks to it with. It is also more accurate than asking: a 200 only
|
|
11
|
+
// says a level was not refused, and glm-5.3-flash answered 200 to seven levels where the catalogue
|
|
12
|
+
// lists `low`/`high`/`max` — the rest were accepted and, as far as anything can tell, ignored. And
|
|
13
|
+
// muse-spark-1.3-contributor names `max` in its own error message, refuses it when sent, and the
|
|
14
|
+
// catalogue never lists it.
|
|
15
|
+
//
|
|
16
|
+
// The catalogue is community-maintained and a new model can arrive a day late; a model it does not
|
|
17
|
+
// list is measured exactly as before.
|
|
18
|
+
/** Which models.dev provider describes the endpoint a preset points at. Only presets whose base URL
|
|
19
|
+
* is the one the catalogue entry names (`api`) are listed, so the catalogue never describes a
|
|
20
|
+
* different endpoint from the one the request goes to. */
|
|
21
|
+
const CATALOG_PROVIDER = {
|
|
22
|
+
"opencode-go": "opencode-go", // api https://opencode.ai/zen/go/v1
|
|
23
|
+
"opencode-zen": "opencode", // api https://opencode.ai/zen/v1
|
|
24
|
+
};
|
|
25
|
+
export const CATALOG_URL = "https://models.dev/api.json";
|
|
26
|
+
const CATALOG_TIMEOUT_MS = 10_000;
|
|
27
|
+
/** The catalogue changes when a model is added, not minute to minute. */
|
|
28
|
+
const CATALOG_TTL_MS = 60 * 60 * 1000;
|
|
29
|
+
/** The SDK packages that map onto a wire this router sends. Any other (`@ai-sdk/google`, …) says
|
|
30
|
+
* nothing we can use, and leaves the wire to measurement. */
|
|
31
|
+
function wireOf(npm) {
|
|
32
|
+
if (npm === "@ai-sdk/openai-compatible")
|
|
33
|
+
return "chat";
|
|
34
|
+
if (npm === "@ai-sdk/openai")
|
|
35
|
+
return "responses";
|
|
36
|
+
if (npm === "@ai-sdk/anthropic")
|
|
37
|
+
return "anthropic";
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
/** One model as the catalogue describes it, or nothing when the preset has no catalogue or the model is not in it. */
|
|
41
|
+
export function catalogEntry(catalog, preset, modelId) {
|
|
42
|
+
const providerId = preset ? CATALOG_PROVIDER[preset] : undefined;
|
|
43
|
+
if (!providerId || !catalog || typeof catalog !== "object")
|
|
44
|
+
return undefined;
|
|
45
|
+
const provider = catalog[providerId];
|
|
46
|
+
const model = provider?.models?.[modelId];
|
|
47
|
+
if (!model || typeof model !== "object")
|
|
48
|
+
return undefined;
|
|
49
|
+
// A model with no SDK of its own is spoken to with the provider's.
|
|
50
|
+
const wire = wireOf(model.provider?.npm ?? provider?.npm);
|
|
51
|
+
let effortLevels;
|
|
52
|
+
if (Array.isArray(model.reasoning_options)) {
|
|
53
|
+
const effort = model.reasoning_options.find((option) => option?.type === "effort" && Array.isArray(option.values));
|
|
54
|
+
effortLevels = effort ? effort.values.filter((value) => typeof value === "string") : [];
|
|
55
|
+
}
|
|
56
|
+
else if (model.reasoning === false) {
|
|
57
|
+
effortLevels = [];
|
|
58
|
+
}
|
|
59
|
+
return { ...(wire ? { wire } : {}), ...(effortLevels ? { effortLevels } : {}) };
|
|
60
|
+
}
|
|
61
|
+
let cached;
|
|
62
|
+
/**
|
|
63
|
+
* The catalogue, fetched at most once an hour. Undefined when it cannot be had: the caller measures
|
|
64
|
+
* instead, so a catalogue outage costs time and never a wrong answer.
|
|
65
|
+
*/
|
|
66
|
+
export async function loadModelCatalog(fetchImpl = fetch) {
|
|
67
|
+
if (cached && Date.now() - cached.at < CATALOG_TTL_MS)
|
|
68
|
+
return cached.catalog;
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetchImpl(CATALOG_URL, { signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS) });
|
|
71
|
+
if (!response.ok)
|
|
72
|
+
return cached?.catalog;
|
|
73
|
+
const catalog = await response.json();
|
|
74
|
+
cached = { at: Date.now(), catalog };
|
|
75
|
+
return catalog;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return cached?.catalog;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Sanitise Claude Code's Anthropic-only request extensions for providers that expose
|
|
2
2
|
// an Anthropic-shaped Messages endpoint without implementing Anthropic's full feature set.
|
|
3
3
|
// Pure: callers receive a new object and an audit list; the input is never mutated.
|
|
4
|
+
import { droppedServerToolNote } from "./providers/chatgpt/translate.js";
|
|
4
5
|
export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max", "ultra"];
|
|
5
6
|
export const STRICT_COMPAT_CAPS = {
|
|
6
7
|
effortLevels: [],
|
|
@@ -28,6 +29,10 @@ export function forwardCompatibleHeader(name, caps) {
|
|
|
28
29
|
/**
|
|
29
30
|
* Return the nearest supported canonical effort level. Unknown requested levels fall back
|
|
30
31
|
* to the provider's lowest listed level, which is safer than forwarding a rejected value.
|
|
32
|
+
*
|
|
33
|
+
* Equally near on both sides goes up: `medium` on a `low`/`high`/`max` ladder — the shape most
|
|
34
|
+
* OpenCode Go models list (models.dev, 2026-10-02) — is `high`. Asking for medium means "more
|
|
35
|
+
* than low", and rounding it down cost a worker its reasoning for the sake of a tie.
|
|
31
36
|
*/
|
|
32
37
|
export function clampEffort(effort, supported) {
|
|
33
38
|
if (supported.includes(effort))
|
|
@@ -38,7 +43,11 @@ export function clampEffort(effort, supported) {
|
|
|
38
43
|
.filter((entry) => entry.index >= 0);
|
|
39
44
|
if (requested < 0 || canonical.length === 0)
|
|
40
45
|
return supported[0] ?? effort;
|
|
41
|
-
return canonical.reduce((best, candidate) =>
|
|
46
|
+
return canonical.reduce((best, candidate) => {
|
|
47
|
+
const distance = Math.abs(candidate.index - requested);
|
|
48
|
+
const bestDistance = Math.abs(best.index - requested);
|
|
49
|
+
return distance < bestDistance || (distance === bestDistance && candidate.index > best.index) ? candidate : best;
|
|
50
|
+
}).value;
|
|
42
51
|
}
|
|
43
52
|
function stripCacheControl(value) {
|
|
44
53
|
if (Array.isArray(value)) {
|
|
@@ -77,6 +86,15 @@ function stripCacheControl(value) {
|
|
|
77
86
|
// translated providers drop it for the same reason (`systemText` in providers/chatgpt/translate.ts,
|
|
78
87
|
// measured 2026-09-13), and it means nothing to a provider that is not Anthropic.
|
|
79
88
|
const BILLING_BLOCK = /^x-anthropic-billing-header:/;
|
|
89
|
+
function appendSystemText(system, text) {
|
|
90
|
+
if (system === undefined || system === null || system === "")
|
|
91
|
+
return text;
|
|
92
|
+
if (typeof system === "string")
|
|
93
|
+
return `${system}\n\n${text}`;
|
|
94
|
+
if (Array.isArray(system))
|
|
95
|
+
return [...system, { type: "text", text }];
|
|
96
|
+
return system;
|
|
97
|
+
}
|
|
80
98
|
function stripBillingHeader(system) {
|
|
81
99
|
if (typeof system === "string") {
|
|
82
100
|
const value = system.replace(/^x-anthropic-billing-header:[^\n]*\n*/, "");
|
|
@@ -183,6 +201,14 @@ export function sanitizeForCompatible(json, caps) {
|
|
|
183
201
|
delete out.tool_choice;
|
|
184
202
|
changes.push("tool_choice");
|
|
185
203
|
}
|
|
204
|
+
// The instruction text for a dropped tool stays in the prompt unless withdrawn (see the note's
|
|
205
|
+
// own comment). Appended after the caller's blocks, without cache_control, so the prefix up to
|
|
206
|
+
// Claude Code's breakpoints is untouched.
|
|
207
|
+
const note = droppedServerToolNote(droppedNames);
|
|
208
|
+
if (note) {
|
|
209
|
+
out.system = appendSystemText(out.system, note);
|
|
210
|
+
changes.push("server_tool_note");
|
|
211
|
+
}
|
|
186
212
|
}
|
|
187
213
|
if ("system" in out) {
|
|
188
214
|
const next = stripBillingHeader(out.system);
|
|
@@ -239,6 +239,22 @@ function foldSplitProviders(c) {
|
|
|
239
239
|
}
|
|
240
240
|
export function validate(c) {
|
|
241
241
|
const errors = [];
|
|
242
|
+
if (c.autoModeClassifier !== undefined) {
|
|
243
|
+
const target = c.autoModeClassifier;
|
|
244
|
+
if (!target || typeof target !== "object")
|
|
245
|
+
errors.push("autoModeClassifier must be an object");
|
|
246
|
+
else {
|
|
247
|
+
const provider = c.providers[target.provider];
|
|
248
|
+
if (!provider)
|
|
249
|
+
errors.push("autoModeClassifier: unknown provider");
|
|
250
|
+
else if (provider.type === "anthropic" && !provider.accountPool)
|
|
251
|
+
errors.push("autoModeClassifier: provider is ingress-only");
|
|
252
|
+
if (typeof target.model !== "string" || !target.model.trim())
|
|
253
|
+
errors.push("autoModeClassifier: missing model");
|
|
254
|
+
if (target.effort !== undefined && typeof target.effort !== "string")
|
|
255
|
+
errors.push("autoModeClassifier: effort must be a string");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
242
258
|
for (const [alias, r] of Object.entries(c.routes)) {
|
|
243
259
|
if (!c.providers[r.provider])
|
|
244
260
|
errors.push(`route ${alias}: unknown provider "${r.provider}"`);
|
|
@@ -360,6 +376,21 @@ export function validate(c) {
|
|
|
360
376
|
errors.push(`provider ${name}: caps must contain effortLevels?: string[], reasoning?: "effort"|"none"`);
|
|
361
377
|
}
|
|
362
378
|
}
|
|
379
|
+
else if (p.type === "google") {
|
|
380
|
+
if (p.auth !== "api-key" && p.auth !== "antigravity")
|
|
381
|
+
errors.push(`provider ${name}: auth must be "api-key" or "antigravity"`);
|
|
382
|
+
// The api-key is required, but a key may also come from the environment. The router, not the
|
|
383
|
+
// GUI save, is where that is settled — so this only refuses a key of the wrong type.
|
|
384
|
+
if (p.apiKey !== undefined && typeof p.apiKey !== "string")
|
|
385
|
+
errors.push(`provider ${name}: apiKey must be a string`);
|
|
386
|
+
if (p.url !== undefined && !/^https?:\/\//.test(p.url))
|
|
387
|
+
errors.push(`provider ${name}: url must start with http:// or https://`);
|
|
388
|
+
if (p.defaultEffort !== undefined && typeof p.defaultEffort !== "string")
|
|
389
|
+
errors.push(`provider ${name}: defaultEffort must be a string`);
|
|
390
|
+
if (p.models !== undefined && !validModels(p.models)) {
|
|
391
|
+
errors.push(`provider ${name}: ${MODELS_SHAPE}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
363
394
|
else if (p.type === "anthropic") {
|
|
364
395
|
if (p.auth !== "api-key" && p.auth !== "claude-code")
|
|
365
396
|
errors.push(`provider ${name}: auth must be "api-key" or "claude-code"`);
|
package/dist/router/src/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Exit codes: 0 clean, 2 bad config/certs, 75 upstream unreachable (supervisor should restart).
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
|
-
import { codexEnabled, writeCodexCatalog } from "../../cli/src/codex.js";
|
|
9
|
+
import { codexEnabled, followCodexCache, writeCodexCatalog } from "../../cli/src/codex.js";
|
|
10
10
|
import { ingressModels } from "./ingress/models.js";
|
|
11
11
|
import { ConfigStore, configPath, homeDir, terminateHosts } from "./config.js";
|
|
12
12
|
import { defaultAgentDir, syncAgentFiles } from "./agents.js";
|
|
@@ -15,6 +15,7 @@ import { Logger } from "./log.js";
|
|
|
15
15
|
import { UpstreamHealth, EXIT_UPSTREAM_UNREACHABLE } from "./health.js";
|
|
16
16
|
import { Proxy } from "./proxy.js";
|
|
17
17
|
import { startAdmin } from "./admin.js";
|
|
18
|
+
import { loadModelCatalog } from "./catalog.js";
|
|
18
19
|
import { RequestLog } from "./requestlog.js";
|
|
19
20
|
import { OpenAiIngress } from "./ingress/server.js";
|
|
20
21
|
import { ObservedClaudeCodeAuth } from "./providers/anthropic-observed.js";
|
|
@@ -123,6 +124,9 @@ setInterval(() => {
|
|
|
123
124
|
const oi = ingress.stats;
|
|
124
125
|
log.info(`stats proxy=${s.started}/${s.completed}/${s.failed} inFlight=${s.inFlight} ingress=${oi.started}/${oi.completed}/${oi.failed} inFlight=${oi.inFlight} consecutiveUpstreamFailures=${health.consecutiveFailures}`);
|
|
125
126
|
}, statsEvery).unref();
|
|
127
|
+
// The config callback above rewrites the Codex catalog when *our* models change; this covers
|
|
128
|
+
// Codex's side (a new OpenAI model arriving in its cache).
|
|
129
|
+
followCodexCache(() => ingressModels(store.get()), undefined, undefined, (e) => log.warn(`codex catalog: ${e.message}`));
|
|
126
130
|
proxy
|
|
127
131
|
.listen()
|
|
128
132
|
.then(async () => {
|
|
@@ -150,11 +154,18 @@ proxy
|
|
|
150
154
|
accounts: () => proxy.chatgptAccounts(),
|
|
151
155
|
clearCooldown: (id) => proxy.chatgptClearCooldown(id),
|
|
152
156
|
}),
|
|
157
|
+
google: () => ({
|
|
158
|
+
accounts: () => proxy.googleAccounts(),
|
|
159
|
+
models: (name) => proxy.googleFetchModels(name),
|
|
160
|
+
clearCooldown: (id) => proxy.googleClearCooldown(id),
|
|
161
|
+
recheckVerification: (id) => proxy.googleRecheckVerification(id),
|
|
162
|
+
}),
|
|
153
163
|
credentials: () => proxy.credentialHealth(),
|
|
154
164
|
picker: () => ({ enabled: !!store.get().picker?.enabled, hosts: terminateHosts(store.get()).slice(1), last: proxy.lastPickerInjection }),
|
|
155
165
|
observedClaudeCodeAuth,
|
|
156
166
|
claudeUsage: () => proxy.claudeUsage.snapshot(),
|
|
157
167
|
image: (req, signal) => proxy.generateImage(req, signal),
|
|
168
|
+
modelCatalog: () => loadModelCatalog(),
|
|
158
169
|
shutdown: () => beginDrain("shutdown requested"),
|
|
159
170
|
});
|
|
160
171
|
log.info(`clauderipple admin GUI on http://127.0.0.1:${admin.port}/`);
|
|
@@ -332,8 +332,9 @@ export class OpenAiIngress {
|
|
|
332
332
|
// is an OpenAI wire, which the refusal below would otherwise turn away on the provider's type
|
|
333
333
|
// alone. One subscription is one provider, so the model decides.
|
|
334
334
|
const provider = providerFor(configured, route.model);
|
|
335
|
-
if (provider.type === "chatgpt" || provider.type === "openai-compatible") {
|
|
336
|
-
const
|
|
335
|
+
if (provider.type === "chatgpt" || provider.type === "openai-compatible" || provider.type === "google") {
|
|
336
|
+
const label = provider.type === "chatgpt" ? "ChatGPT" : provider.type === "google" ? "Google" : "OpenAI-compatible";
|
|
337
|
+
const bytes = sendJson(res, 400, openAiError(`${label} provider is not available through OpenAI ingress`, "invalid_request_error", "unsupported_provider"));
|
|
337
338
|
finish(400, bytes, { note: `model ${requested} -> ${provider.type} provider unsupported` });
|
|
338
339
|
return;
|
|
339
340
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// entry as a template and override the identifying fields. Surfaces are matched by id
|
|
10
10
|
// heuristically (anything that is not the claude.ai chat surface); the ids we saw are
|
|
11
11
|
// logged once so the heuristic can be tightened.
|
|
12
|
+
import { clampEffort } from "./compat.js";
|
|
12
13
|
export const BOOTSTRAP_PATHS = ["/edge-api/bootstrap", "/api/bootstrap"];
|
|
13
14
|
export function isBootstrapPath(path) {
|
|
14
15
|
return BOOTSTRAP_PATHS.some((p) => path === p || path.startsWith(p + "/") || path.startsWith(p + "?"));
|
|
@@ -23,8 +24,35 @@ function isClaudeEntry(m) {
|
|
|
23
24
|
function pickTemplate(models) {
|
|
24
25
|
return models.find((m) => isClaudeEntry(m) && !m.disabled && m.section !== "deprecated" && m.section !== "legacy") ?? models.find(isClaudeEntry) ?? null;
|
|
25
26
|
}
|
|
26
|
-
/**
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The effort menu for one routed model: the template's own options, narrowed to the levels the
|
|
29
|
+
* model takes.
|
|
30
|
+
*
|
|
31
|
+
* The app builds the menu per entry from `thinking.effort_options` (`effortOptionsFor: e =>
|
|
32
|
+
* …effort_options ?? []` in the Code tab bundle, read 2026-10-02), so a cloned Claude entry offered
|
|
33
|
+
* Claude's whole ladder for a model that takes three of it. The template's option objects are kept
|
|
34
|
+
* as they are — they carry the app's localised labels, and an id the app does not know (its list is
|
|
35
|
+
* low/medium/high/xhigh/max) is not invented here. `recommended` moves to high, or the level nearest
|
|
36
|
+
* it: when the selected level is not on the list, the app switches to the recommended one (`uw`).
|
|
37
|
+
* A model that takes none of them gets no `thinking` at all, which the app reads as "no effort".
|
|
38
|
+
*/
|
|
39
|
+
function narrowThinking(entry, levels) {
|
|
40
|
+
const thinking = entry.thinking;
|
|
41
|
+
if (!thinking || !Array.isArray(thinking.effort_options))
|
|
42
|
+
return;
|
|
43
|
+
const kept = thinking.effort_options.filter((option) => typeof option.id === "string" && levels.includes(option.id));
|
|
44
|
+
if (kept.length === 0) {
|
|
45
|
+
delete entry.thinking;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const preferred = clampEffort("high", kept.map((option) => option.id));
|
|
49
|
+
entry.thinking = { ...thinking, effort_options: kept.map(({ recommended: _recommended, ...option }) => (option.id === preferred ? { ...option, recommended: true } : option)) };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Mutates `json` in place; returns what was done for logging. `effortFor` names the levels a model
|
|
53
|
+
* takes (by its id without an `@effort` suffix); undefined leaves the template's menu as it is.
|
|
54
|
+
*/
|
|
55
|
+
export function injectPickerModels(json, extra, contextWindow, effortFor) {
|
|
28
56
|
const result = { injected: 0, surfaces: [] };
|
|
29
57
|
const msc = json.model_selector_config;
|
|
30
58
|
if (!Array.isArray(msc) || extra.length === 0)
|
|
@@ -60,6 +88,9 @@ export function injectPickerModels(json, extra, contextWindow) {
|
|
|
60
88
|
for (const k of ["disabled", "disabled_reason", "notice", "selection_notice", "badge", "badge_tooltip", "tooltip", "minimum_tier", "is_default"])
|
|
61
89
|
delete entry[k];
|
|
62
90
|
entry.section = "main";
|
|
91
|
+
const levels = effortFor?.(e.model.split("@")[0]);
|
|
92
|
+
if (levels)
|
|
93
|
+
narrowThinking(entry, levels);
|
|
63
94
|
surface.models.push(entry);
|
|
64
95
|
result.injected++;
|
|
65
96
|
}
|
|
@@ -207,7 +207,8 @@ export const PRESETS = [
|
|
|
207
207
|
// assumption rather than a measurement, and it was wrong: deepseek-v4.1-flash accepts all eight
|
|
208
208
|
// levels including max, and mimo-v2.6-pro accepts none/low/medium/high (measured 2026-09-22).
|
|
209
209
|
// It could not correct itself either — an empty ladder is never measured. Left unstated, each
|
|
210
|
-
// model's ladder is established on the first save.
|
|
210
|
+
// model's ladder is established on the first save: from models.dev when it lists the model
|
|
211
|
+
// (catalog.ts — OpenCode's own catalogue, which does state ladders), by request otherwise.
|
|
211
212
|
fallbackModels: [
|
|
212
213
|
// Responses — the preset's own wire, so these carry no override.
|
|
213
214
|
{ id: "muse-spark-1.3-contributor", name: "Muse Spark 1.3 (Contributor)" },
|
|
@@ -15,6 +15,8 @@ import path from "node:path";
|
|
|
15
15
|
import { homeDir } from "../../config.js";
|
|
16
16
|
export const DEFAULT_BASE = "https://chatgpt.com/backend-api";
|
|
17
17
|
const PING_MS = 15_000;
|
|
18
|
+
/** How long a stream may hold its headers waiting for upstream's first item (see `handle`). Tests shorten it. */
|
|
19
|
+
export const streamHold = { ms: 5_000 };
|
|
18
20
|
/** Active quota lookup. Measured 2026-09-20: GET {base}/wham/usage → 200 JSON. The binary also
|
|
19
21
|
* carries `/api/codex/usage`, but that path answers 403 here; `wham` is the one that works. */
|
|
20
22
|
const USAGE_PATH = "/wham/usage";
|
|
@@ -1018,12 +1020,41 @@ export class ChatGptAdapter {
|
|
|
1018
1020
|
const parser = new SseParser();
|
|
1019
1021
|
let bytes = 0;
|
|
1020
1022
|
let ping = null;
|
|
1021
|
-
|
|
1023
|
+
// Keep early rejection as an HTTP error until an actual content block arrives.
|
|
1024
|
+
// A 200 followed by an error loses the status Claude uses for overflow recovery.
|
|
1025
|
+
const pendingEvents = [];
|
|
1026
|
+
const openStream = () => {
|
|
1022
1027
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
1023
|
-
|
|
1028
|
+
};
|
|
1029
|
+
const sendEvents = (events) => {
|
|
1030
|
+
if (!wantStream)
|
|
1031
|
+
return;
|
|
1032
|
+
pendingEvents.push(...events);
|
|
1033
|
+
if (!res.headersSent) {
|
|
1034
|
+
if (mapper.failure?.type === "invalid_request_error")
|
|
1035
|
+
return;
|
|
1036
|
+
if (!pendingEvents.some((e) => e.event !== "message_start"))
|
|
1037
|
+
return;
|
|
1038
|
+
openStream();
|
|
1039
|
+
}
|
|
1040
|
+
for (const ev of pendingEvents.splice(0))
|
|
1024
1041
|
bytes += write(res, formatSse(ev));
|
|
1042
|
+
};
|
|
1043
|
+
// The hold waits for upstream's first item, which normally follows the request within a second
|
|
1044
|
+
// — and an overflow rejection comes before it. Should it not come, the client must still hear
|
|
1045
|
+
// something: past this deadline the stream opens with message_start and the pings keep it alive,
|
|
1046
|
+
// as they did before the hold.
|
|
1047
|
+
let holdDeadline = null;
|
|
1048
|
+
if (wantStream) {
|
|
1049
|
+
holdDeadline = setTimeout(() => {
|
|
1050
|
+
if (res.headersSent || res.writableEnded || mapper.failure)
|
|
1051
|
+
return;
|
|
1052
|
+
openStream();
|
|
1053
|
+
for (const ev of [...pendingEvents.splice(0), ...mapper.start()])
|
|
1054
|
+
bytes += write(res, formatSse(ev));
|
|
1055
|
+
}, streamHold.ms);
|
|
1025
1056
|
ping = setInterval(() => {
|
|
1026
|
-
if (!res.writableEnded)
|
|
1057
|
+
if (res.headersSent && !res.writableEnded)
|
|
1027
1058
|
bytes += write(res, formatSse({ event: "ping", data: { type: "ping" } }));
|
|
1028
1059
|
}, PING_MS);
|
|
1029
1060
|
}
|
|
@@ -1039,9 +1070,7 @@ export class ChatGptAdapter {
|
|
|
1039
1070
|
if (mapper.rateLimits && mapper.rateLimits !== this.rateLimitsByAccount.get(credential.ownerId))
|
|
1040
1071
|
this.noteRateLimits(credential, mapper.rateLimits);
|
|
1041
1072
|
this.rememberInput(cacheKey, mapper.usage);
|
|
1042
|
-
|
|
1043
|
-
for (const o of outEvents)
|
|
1044
|
-
bytes += write(res, formatSse(o));
|
|
1073
|
+
sendEvents(outEvents);
|
|
1045
1074
|
if (mapper.isFinished)
|
|
1046
1075
|
break;
|
|
1047
1076
|
}
|
|
@@ -1055,22 +1084,20 @@ export class ChatGptAdapter {
|
|
|
1055
1084
|
const tail = parser.sawDone
|
|
1056
1085
|
? mapper.finish()
|
|
1057
1086
|
: mapper.fail(`${model}: upstream stream ended before the response completed`, "server_is_overloaded");
|
|
1058
|
-
|
|
1059
|
-
for (const o of tail)
|
|
1060
|
-
bytes += write(res, formatSse(o));
|
|
1087
|
+
sendEvents(tail);
|
|
1061
1088
|
}
|
|
1062
1089
|
}
|
|
1063
1090
|
catch (e) {
|
|
1064
1091
|
if (!ac.signal.aborted) {
|
|
1065
1092
|
const tail = mapper.fail(`stream interrupted: ${e.message}`, "server_is_overloaded");
|
|
1066
|
-
|
|
1067
|
-
for (const o of tail)
|
|
1068
|
-
bytes += write(res, formatSse(o));
|
|
1093
|
+
sendEvents(tail);
|
|
1069
1094
|
}
|
|
1070
1095
|
}
|
|
1071
1096
|
finally {
|
|
1072
1097
|
if (ping)
|
|
1073
1098
|
clearInterval(ping);
|
|
1099
|
+
if (holdDeadline)
|
|
1100
|
+
clearTimeout(holdDeadline);
|
|
1074
1101
|
res.off("close", onClose);
|
|
1075
1102
|
try {
|
|
1076
1103
|
await reader.cancel();
|
|
@@ -1080,8 +1107,8 @@ export class ChatGptAdapter {
|
|
|
1080
1107
|
}
|
|
1081
1108
|
}
|
|
1082
1109
|
const failure = mapper.failure;
|
|
1083
|
-
const failedStatus = failure?.type === "overloaded_error" ? 529 : failure?.type === "rate_limit_error" ? 429 : 502;
|
|
1084
|
-
if (!wantStream) {
|
|
1110
|
+
const failedStatus = failure?.type === "invalid_request_error" ? 400 : failure?.type === "overloaded_error" ? 529 : failure?.type === "rate_limit_error" ? 429 : 502;
|
|
1111
|
+
if (!wantStream || (failure && !res.headersSent)) {
|
|
1085
1112
|
// A failed turn is an error here too, not a 200 carrying whatever arrived before it failed.
|
|
1086
1113
|
const msg = failure ? anthropicError(failedStatus, failure.type, failure.message).body : JSON.stringify(mapper.message());
|
|
1087
1114
|
res.writeHead(failure ? failedStatus : 200, { "content-type": "application/json", "content-length": String(Buffer.byteLength(msg)) }).end(msg);
|
|
@@ -1097,7 +1124,7 @@ export class ChatGptAdapter {
|
|
|
1097
1124
|
bytes,
|
|
1098
1125
|
note: failure
|
|
1099
1126
|
? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens})`
|
|
1100
|
-
: `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason}`,
|
|
1127
|
+
: `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason}${upstreamReq.service_tier ? " fast" : ""}`,
|
|
1101
1128
|
usage: {
|
|
1102
1129
|
input: u.input_tokens,
|
|
1103
1130
|
cached: u.cache_read_input_tokens,
|
|
@@ -161,10 +161,11 @@ export function toolNameForResponses(name) {
|
|
|
161
161
|
// worst failure shape there is. `compat.ts` has dropped them on the anthropic-compatible path from
|
|
162
162
|
// the start; the rule belongs here too, and is the same rule, not a second one.
|
|
163
163
|
//
|
|
164
|
-
//
|
|
165
|
-
// small model — measured 2026-09-17: an Opus session and a DeepSeek-routed session both sent it
|
|
166
|
-
// `claude-haiku-4-5`, which passes through to Anthropic and never reaches an adapter. Which model
|
|
167
|
-
// that is, is a server-side flag we do not own, so this guards the day it changes.
|
|
164
|
+
// `web_search` does not arrive today. Claude Code runs its web search as a separate side request on a
|
|
165
|
+
// fixed small model — measured 2026-09-17: an Opus session and a DeepSeek-routed session both sent it
|
|
166
|
+
// to `claude-haiku-4-5`, which passes through to Anthropic and never reaches an adapter. Which model
|
|
167
|
+
// that is, is a server-side flag we do not own, so this guards the day it changes. The `advisor`
|
|
168
|
+
// server tool does arrive: see `droppedServerToolNote`.
|
|
168
169
|
export function isServerTool(tool) {
|
|
169
170
|
const type = tool.type;
|
|
170
171
|
return type !== undefined && type !== "custom";
|
|
@@ -177,6 +178,26 @@ export function serverToolNames(tools) {
|
|
|
177
178
|
names.add(t.name);
|
|
178
179
|
return names;
|
|
179
180
|
}
|
|
181
|
+
// Dropping the declaration is half of it (issue #47). Claude Code also tells the model, in text, how
|
|
182
|
+
// to use the tool: the advisor comes with an `isMeta` user message ("# Advisor Tool / You have access
|
|
183
|
+
// to an `advisor` tool…") on every subagent, whatever its `tools:` list says (CLI 2.1.286, measured
|
|
184
|
+
// 2026-10-04: `dropped server tools for gpt-6-astra: advisor` on every worker request). A routed
|
|
185
|
+
// worker reads the instruction, finds no such tool, and says so in each report. The CLI withdraws its
|
|
186
|
+
// own instruction with one line when it removes the tool ("The advisor tool is no longer available;
|
|
187
|
+
// disregard the earlier advisor instructions."); a line does the same here, without matching wording
|
|
188
|
+
// that changes from release to release. Sorted, so one set always yields the same bytes and the
|
|
189
|
+
// cached prefix does not move.
|
|
190
|
+
/** One line withdrawing the instructions for the server tools a request had to drop; "" when none. */
|
|
191
|
+
export function droppedServerToolNote(dropped) {
|
|
192
|
+
if (dropped.size === 0)
|
|
193
|
+
return "";
|
|
194
|
+
const names = [...dropped].sort().map((n) => `\`${n}\``).join(", ");
|
|
195
|
+
return `Not available in this session: ${names}. Anthropic runs these tools on its own servers, so this model cannot call them. Disregard any instruction to use them and carry on without them; there is no need to mention that they are missing.`;
|
|
196
|
+
}
|
|
197
|
+
/** The caller's system text with that line after it, so every translator places it the same way. */
|
|
198
|
+
export function systemWithDroppedToolNote(req) {
|
|
199
|
+
return [systemText(req.system), droppedServerToolNote(serverToolNames(req.tools))].filter(Boolean).join("\n\n");
|
|
200
|
+
}
|
|
180
201
|
/**
|
|
181
202
|
* Mangled name → original, for the tools declared in this request. The model echoes the name it was
|
|
182
203
|
* given and Claude Code matches `tool_use.name` against its own tool list, so the response path has
|
|
@@ -199,7 +220,7 @@ export function toResponsesRequest(req, opts) {
|
|
|
199
220
|
// Constant per (model, effort): changing effort mid-session costs one cache miss, which is acceptable.
|
|
200
221
|
if (opts.identity)
|
|
201
222
|
parts.push(identityLine(opts.model, opts.effort));
|
|
202
|
-
const sys =
|
|
223
|
+
const sys = systemWithDroppedToolNote(req);
|
|
203
224
|
if (sys)
|
|
204
225
|
parts.push(sys);
|
|
205
226
|
if (opts.instructionsAppend)
|
|
@@ -308,6 +329,10 @@ export function toResponsesRequest(req, opts) {
|
|
|
308
329
|
}
|
|
309
330
|
if (tool_choice)
|
|
310
331
|
out.tool_choice = tool_choice;
|
|
332
|
+
// Claude Code's `/fast` arrives as `speed: "fast"` (Anthropic fast mode). The ChatGPT backend's
|
|
333
|
+
// equivalent is the Codex CLI's Fast mode, which bills the plan at a higher rate (Codex docs, Speed).
|
|
334
|
+
if (req.speed === "fast")
|
|
335
|
+
out.service_tier = "priority";
|
|
311
336
|
return out;
|
|
312
337
|
}
|
|
313
338
|
/** Rough token estimate for /v1/messages/count_tokens when the model is not Anthropic's. */
|
|
@@ -506,7 +531,10 @@ export class StreamMapper {
|
|
|
506
531
|
if (this.finished)
|
|
507
532
|
return [];
|
|
508
533
|
this.finished = true;
|
|
509
|
-
const
|
|
534
|
+
const contextOverflow = code === "context_length_exceeded" || /input exceeds the context window/i.test(message);
|
|
535
|
+
const type = contextOverflow ? "invalid_request_error" : code === "server_is_overloaded" ? "overloaded_error" : code === "rate_limit_exceeded" || code === "usage_limit_reached" ? "rate_limit_error" : "api_error";
|
|
536
|
+
if (contextOverflow)
|
|
537
|
+
message = `prompt is too long: ${message}`;
|
|
510
538
|
this.failure = { type, message };
|
|
511
539
|
return [...this.start(), ...this.closeBlock(), { event: "error", data: { type: "error", error: { type, message } } }];
|
|
512
540
|
}
|