clauderipple 0.6.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 +66 -0
- package/README.ko.md +43 -2
- package/README.md +60 -4
- 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 +89 -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 +497 -34
- package/dist/router/src/agents.js +50 -9
- package/dist/router/src/catalog.js +80 -0
- package/dist/router/src/compat.js +27 -1
- package/dist/router/src/config.js +33 -0
- package/dist/router/src/index.js +13 -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 +154 -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 +99 -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 +436 -44
- package/dist/ui/i18n.js +88 -4
- package/dist/ui/index.html +10 -0
- package/dist/ui/style.css +4 -0
- package/docs/ARCHITECTURE.md +300 -3
- package/package.json +1 -1
|
@@ -14,6 +14,8 @@ import os from "node:os";
|
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { homeDir } from "./config.js";
|
|
16
16
|
import { declaredBy } from "./routing.js";
|
|
17
|
+
import { adminPort } from "./admin.js";
|
|
18
|
+
import { shortName } from "../../cli/src/hooks/agent-title.js";
|
|
17
19
|
/** Where Claude Code's Agent tool reads its worker definitions. */
|
|
18
20
|
export function defaultAgentDir() {
|
|
19
21
|
return path.join(os.homedir(), ".claude", "agents");
|
|
@@ -34,6 +36,27 @@ const BODY = [
|
|
|
34
36
|
"**작업 디렉터리(cwd)에 어떤 파일도 만들지 않는다.** 조사·감사처럼 \"읽기만\" 하는 일이어도 마찬가지다.",
|
|
35
37
|
"임시 파일이 필요하면 `/tmp` 아래에만 만들고, 끝나면 지운다. 사본을 작업 폴더에 떨구지 마라.",
|
|
36
38
|
].join("\n");
|
|
39
|
+
/**
|
|
40
|
+
* Added to the body when a ChatGPT provider is configured: how a worker makes an image. A worker has
|
|
41
|
+
* no image tool, and `clauderipple` is not on PATH for everyone (a checkout runs from source), so
|
|
42
|
+
* the instruction is the admin endpoint itself, which curl reaches on every platform.
|
|
43
|
+
*/
|
|
44
|
+
function imageLine(port) {
|
|
45
|
+
return [
|
|
46
|
+
"이미지가 필요하면 ChatGPT 구독으로 만든다(한 장 30초~수 분):",
|
|
47
|
+
`\`curl -sS --fail-with-body -o <절대경로>.png -H 'content-type: application/json' -d '{"prompt":"…"}' http://127.0.0.1:${port}/api/image\``,
|
|
48
|
+
"선택 키: aspect(square·landscape·portrait), background(transparent), format(png·jpeg·webp), images(참고 이미지 data: URL 목록). 해상도·품질은 지정할 수 없다. 실패하면 그 파일에 오류 JSON이 담긴다. 만든 뒤 Read로 확인한다.",
|
|
49
|
+
].join("\n");
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The tools a generated worker is given when `cli.limitWorkerTools` is on. Without a `tools:` line a
|
|
53
|
+
* subagent inherits every tool of the session — each MCP server, plugin agent and deferred-tool name —
|
|
54
|
+
* and a general worker's first request measured 43,598 tokens (2026-09-20), resent on every turn;
|
|
55
|
+
* limited, a deepseek worker's first request fell from 32,942 to 8,823 (2026-09-29). A worker reads,
|
|
56
|
+
* edits, searches, runs commands and uses skills; it does not drive browsers, simulators or mail.
|
|
57
|
+
* Off by default: a user whose workers do need those tools would otherwise lose them silently.
|
|
58
|
+
*/
|
|
59
|
+
const TOOLS = ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "WebFetch", "WebSearch", "Skill"];
|
|
37
60
|
/**
|
|
38
61
|
* Parsed `*.md` frontmatter per directory, keyed by file mtime so a request never re-reads the whole
|
|
39
62
|
* directory. Only the `---` block's own `key: value` lines are read: no YAML library, because the
|
|
@@ -125,19 +148,36 @@ function capsEffortLevels(provider) {
|
|
|
125
148
|
return provider.caps?.effortLevels;
|
|
126
149
|
}
|
|
127
150
|
/**
|
|
128
|
-
* The `@effort` suffix a generated file's `model:` carries: `@
|
|
129
|
-
* offers
|
|
130
|
-
*
|
|
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.
|
|
131
157
|
*/
|
|
132
158
|
function effortSuffix(provider, modelId) {
|
|
133
159
|
const modelLevels = provider.models?.find((m) => m.id === modelId)?.effortLevels;
|
|
134
160
|
const levels = modelLevels !== undefined ? modelLevels : capsEffortLevels(provider);
|
|
135
|
-
return levels
|
|
161
|
+
return levels && levels.length > 0 ? "@high" : "";
|
|
162
|
+
}
|
|
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.`;
|
|
136
173
|
}
|
|
137
|
-
function fileContent(name, modelId, provider, suffix) {
|
|
174
|
+
function fileContent(name, modelId, title, provider, suffix, limitTools, imagePort) {
|
|
138
175
|
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
|
-
|
|
176
|
+
`to customise, copy to another name. Set effort with [[ripple: ${name}@<level>]] on the first line. ` +
|
|
177
|
+
titlePrefix(title, suffix);
|
|
178
|
+
const tools = limitTools ? `tools: ${TOOLS.join(", ")}\n` : "";
|
|
179
|
+
const body = imagePort === null ? BODY : `${BODY}\n${imageLine(imagePort)}`;
|
|
180
|
+
return `---\nname: ${name}\ndescription: ${description}\nmodel: ${modelId}${suffix}\n${tools}---\n${body}\n`;
|
|
141
181
|
}
|
|
142
182
|
function readManifest(file) {
|
|
143
183
|
try {
|
|
@@ -196,9 +236,10 @@ export function syncAgentFiles(cfg, dir, log, opts = {}) {
|
|
|
196
236
|
log?.warn(`agent files: "${model.id}" and "${clash.modelId}" both map to "${name}"; skipping "${model.id}"`);
|
|
197
237
|
continue;
|
|
198
238
|
}
|
|
199
|
-
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) });
|
|
200
240
|
}
|
|
201
241
|
}
|
|
242
|
+
const imagePort = Object.values(cfg.providers).some((p) => p.type === "chatgpt") ? adminPort(cfg) : null;
|
|
202
243
|
const nextOwned = [];
|
|
203
244
|
for (const [name, t] of targets) {
|
|
204
245
|
if (handNames.has(name)) {
|
|
@@ -206,7 +247,7 @@ export function syncAgentFiles(cfg, dir, log, opts = {}) {
|
|
|
206
247
|
continue;
|
|
207
248
|
}
|
|
208
249
|
const file = path.join(dir, `${name}.md`);
|
|
209
|
-
const content = fileContent(name, t.modelId, t.provider, t.suffix);
|
|
250
|
+
const content = fileContent(name, t.modelId, t.title, t.provider, t.suffix, cfg.cli.limitWorkerTools === true, imagePort);
|
|
210
251
|
const existing = scanned.get(`${name}.md`);
|
|
211
252
|
if (existing) {
|
|
212
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"`);
|
|
@@ -381,6 +412,8 @@ export function validate(c) {
|
|
|
381
412
|
errors.push("listen.port out of range");
|
|
382
413
|
if (c.cli.agentFiles !== undefined && typeof c.cli.agentFiles !== "boolean")
|
|
383
414
|
errors.push("cli.agentFiles must be true or false");
|
|
415
|
+
if (c.cli.limitWorkerTools !== undefined && typeof c.cli.limitWorkerTools !== "boolean")
|
|
416
|
+
errors.push("cli.limitWorkerTools must be true or false");
|
|
384
417
|
if (c.listen.openaiPort !== undefined && !(c.listen.openaiPort >= 0 && c.listen.openaiPort < 65536))
|
|
385
418
|
errors.push("listen.openaiPort out of range");
|
|
386
419
|
return errors;
|
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,10 +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(),
|
|
167
|
+
image: (req, signal) => proxy.generateImage(req, signal),
|
|
168
|
+
modelCatalog: () => loadModelCatalog(),
|
|
157
169
|
shutdown: () => beginDrain("shutdown requested"),
|
|
158
170
|
});
|
|
159
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)" },
|