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.
Files changed (45) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.ko.md +43 -2
  3. package/README.md +60 -4
  4. package/dist/app/package.json +1 -1
  5. package/dist/cli/src/claude-auth.js +35 -1
  6. package/dist/cli/src/codex.js +23 -0
  7. package/dist/cli/src/hooks/agent-title.js +58 -29
  8. package/dist/cli/src/index.js +89 -1
  9. package/dist/cli/src/runtime.js +3 -0
  10. package/dist/cli/src/settings.js +49 -0
  11. package/dist/mod/.claude-plugin/plugin.json +1 -0
  12. package/dist/mod/hooks/hooks.json +1 -0
  13. package/dist/mod/hooks/register.tsx +309 -0
  14. package/dist/mod/hooks/worker.ts +49 -0
  15. package/dist/mod/tsconfig.json +3 -0
  16. package/dist/mod/types/index.d.ts +30 -0
  17. package/dist/router/src/admin.js +497 -34
  18. package/dist/router/src/agents.js +50 -9
  19. package/dist/router/src/catalog.js +80 -0
  20. package/dist/router/src/compat.js +27 -1
  21. package/dist/router/src/config.js +33 -0
  22. package/dist/router/src/index.js +13 -1
  23. package/dist/router/src/ingress/server.js +3 -2
  24. package/dist/router/src/picker.js +33 -2
  25. package/dist/router/src/presets.js +2 -1
  26. package/dist/router/src/providers/chatgpt/index.js +154 -15
  27. package/dist/router/src/providers/chatgpt/translate.js +34 -6
  28. package/dist/router/src/providers/google/accounts.js +345 -0
  29. package/dist/router/src/providers/google/antigravity.js +392 -0
  30. package/dist/router/src/providers/google/index.js +617 -0
  31. package/dist/router/src/providers/google/login.js +249 -0
  32. package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
  33. package/dist/router/src/providers/google/translate.js +539 -0
  34. package/dist/router/src/providers/google/transport.js +118 -0
  35. package/dist/router/src/providers/openai/translate.js +3 -3
  36. package/dist/router/src/proxy.js +99 -1
  37. package/dist/router/src/requestlog.js +2 -0
  38. package/dist/router/src/routing.js +22 -0
  39. package/dist/router/src/version.js +1 -1
  40. package/dist/ui/app.js +436 -44
  41. package/dist/ui/i18n.js +88 -4
  42. package/dist/ui/index.html +10 -0
  43. package/dist/ui/style.css +4 -0
  44. package/docs/ARCHITECTURE.md +300 -3
  45. 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: `@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.
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?.includes("medium") ? "@medium" : "";
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
- return `---\nname: ${name}\ndescription: ${description}\nmodel: ${modelId}${suffix}\n---\n${BODY}\n`;
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) => Math.abs(candidate.index - requested) < Math.abs(best.index - requested) ? candidate : best).value;
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;
@@ -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 bytes = sendJson(res, 400, openAiError(`${provider.type === "chatgpt" ? "ChatGPT" : "OpenAI-compatible"} provider is not available through OpenAI ingress`, "invalid_request_error", "unsupported_provider"));
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
- /** Mutates `json` in place; returns what was done for logging. */
27
- export function injectPickerModels(json, extra, contextWindow) {
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)" },