clauderipple 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.ko.md +18 -2
  3. package/README.md +27 -5
  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 +46 -1
  9. package/dist/cli/src/picker.js +19 -11
  10. package/dist/cli/src/runtime.js +3 -0
  11. package/dist/cli/src/settings.js +49 -0
  12. package/dist/mod/.claude-plugin/plugin.json +1 -0
  13. package/dist/mod/hooks/hooks.json +1 -0
  14. package/dist/mod/hooks/register.tsx +309 -0
  15. package/dist/mod/hooks/worker.ts +49 -0
  16. package/dist/mod/tsconfig.json +3 -0
  17. package/dist/mod/types/index.d.ts +30 -0
  18. package/dist/router/src/admin.js +411 -32
  19. package/dist/router/src/agents.js +24 -8
  20. package/dist/router/src/catalog.js +80 -0
  21. package/dist/router/src/compat.js +27 -1
  22. package/dist/router/src/config.js +31 -0
  23. package/dist/router/src/index.js +12 -1
  24. package/dist/router/src/ingress/server.js +3 -2
  25. package/dist/router/src/picker.js +33 -2
  26. package/dist/router/src/presets.js +2 -1
  27. package/dist/router/src/providers/chatgpt/index.js +77 -18
  28. package/dist/router/src/providers/chatgpt/translate.js +34 -6
  29. package/dist/router/src/providers/google/accounts.js +345 -0
  30. package/dist/router/src/providers/google/antigravity.js +392 -0
  31. package/dist/router/src/providers/google/index.js +617 -0
  32. package/dist/router/src/providers/google/login.js +249 -0
  33. package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
  34. package/dist/router/src/providers/google/translate.js +539 -0
  35. package/dist/router/src/providers/google/transport.js +118 -0
  36. package/dist/router/src/providers/openai/translate.js +3 -3
  37. package/dist/router/src/proxy.js +116 -9
  38. package/dist/router/src/requestlog.js +2 -0
  39. package/dist/router/src/routing.js +22 -0
  40. package/dist/router/src/version.js +1 -1
  41. package/dist/ui/app.js +420 -45
  42. package/dist/ui/i18n.js +76 -4
  43. package/dist/ui/index.html +5 -0
  44. package/dist/ui/style.css +4 -0
  45. package/docs/ARCHITECTURE.md +307 -4
  46. 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: `@medium` when the provider or the model
151
- * offers medium reasoning, otherwise none. An empty `model.effortLevels` disables the provider
152
- * 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.
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?.includes("medium") ? "@medium" : "";
161
+ return levels && levels.length > 0 ? "@high" : "";
158
162
  }
159
- function fileContent(name, modelId, provider, suffix, limitTools, imagePort) {
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) => 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"`);
@@ -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 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)" },
@@ -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";
@@ -95,13 +97,36 @@ export function rateLimitsFromHeaders(h) {
95
97
  if (!primary)
96
98
  return null;
97
99
  const secondary = window("secondary");
100
+ const flag = (k) => {
101
+ const v = h.get(k)?.toLowerCase();
102
+ return v === "true" ? true : v === "false" ? false : undefined;
103
+ };
104
+ const credits = creditsSnapshot(flag("x-codex-credits-has-credits"), flag("x-codex-credits-unlimited"), h.get("x-codex-credits-balance"), undefined);
98
105
  return {
99
106
  type: "codex.rate_limits",
100
107
  plan_type: h.get("x-codex-plan-type") ?? undefined,
101
108
  rate_limits: { primary, secondary: secondary && secondary.window_minutes ? secondary : null },
109
+ ...(credits ? { credits } : {}),
102
110
  at: Date.now(),
103
111
  };
104
112
  }
113
+ /**
114
+ * The account's purchased credits, which the backend draws on once a plan window is used up — the
115
+ * account keeps answering at 100%. Measured 2026-10-06: response headers carry
116
+ * `x-codex-credits-has-credits: False` / `-unlimited: False` / `-balance: 0` (Python-style
117
+ * booleans), and `/wham/usage` carries `credits: {has_credits, unlimited, overage_limit_reached,
118
+ * balance}`. Null when the backend said nothing about credits.
119
+ */
120
+ function creditsSnapshot(hasCredits, unlimited, balance, overageReached) {
121
+ if (typeof hasCredits !== "boolean" && typeof unlimited !== "boolean")
122
+ return null;
123
+ return {
124
+ has_credits: hasCredits === true,
125
+ unlimited: unlimited === true,
126
+ ...(typeof balance === "string" || typeof balance === "number" ? { balance: String(balance) } : {}),
127
+ ...(typeof overageReached === "boolean" ? { overage_limit_reached: overageReached } : {}),
128
+ };
129
+ }
105
130
  /**
106
131
  * Map the `/wham/usage` JSON body onto the same shape as `rateLimitsFromHeaders`, so `/api/status`
107
132
  * and the GUI read one thing whether the snapshot came from response headers or the active call.
@@ -129,10 +154,13 @@ export function rateLimitsFromUsage(body) {
129
154
  if (!primary)
130
155
  return null;
131
156
  const secondary = win(b?.rate_limit?.secondary_window);
157
+ const c = b?.credits;
158
+ const credits = c ? creditsSnapshot(c.has_credits, c.unlimited, c.balance, c.overage_limit_reached) : null;
132
159
  return {
133
160
  type: "codex.rate_limits",
134
161
  plan_type: typeof b?.plan_type === "string" ? b.plan_type : undefined,
135
162
  rate_limits: { primary, secondary: secondary && secondary.window_minutes ? secondary : null },
163
+ ...(credits ? { credits } : {}),
136
164
  at: Date.now(),
137
165
  };
138
166
  }
@@ -180,9 +208,14 @@ function windowResetMs(w, now) {
180
208
  /**
181
209
  * How long an account is out, from a rate-limit snapshot: the latest reset among the windows it
182
210
  * has used up, since it is usable only once every full window has reset. Undefined when no window
183
- * is full — the account is not out, whatever else the snapshot says.
211
+ * is full — the account is not out, whatever else the snapshot says — and undefined when the
212
+ * account has credits to spend past the window: the backend keeps serving it, and when the credits
213
+ * run out it says so with a 429, which rests the account the ordinary way.
184
214
  */
185
- export function exhaustedForMs(snapshot, now = Date.now()) {
215
+ export function exhaustedForMs(snapshot, now = Date.now(), creditsCount = true) {
216
+ const credits = snapshot?.credits;
217
+ if (creditsCount && credits && (credits.has_credits === true || credits.unlimited === true) && credits.overage_limit_reached !== true)
218
+ return undefined;
186
219
  const limits = (snapshot?.rate_limits ?? null);
187
220
  let out;
188
221
  for (const w of [limits?.primary, limits?.secondary]) {
@@ -760,7 +793,8 @@ export class ChatGptAdapter {
760
793
  const headerRecord = Object.fromEntries(upstream.headers.entries());
761
794
  const snapshot = rateLimitsFromHeaders(upstream.headers);
762
795
  const retryHeader = headerRecord["retry-after"] ? retryAfterMs({ "retry-after": headerRecord["retry-after"] }) : undefined;
763
- const waitMs = retryHeader ?? exhaustedForMs(snapshot) ?? retryAfterMs(headerRecord);
796
+ // The account was refused, so whatever credits the headers report did not carry it past the window.
797
+ const waitMs = retryHeader ?? exhaustedForMs(snapshot, Date.now(), false) ?? retryAfterMs(headerRecord);
764
798
  // A refusal here is a rest, never a pool quarantine: "sign in again" is the store's to say
765
799
  // (needsReauth, above), and a quarantine would outlive the new token a sign-in brings.
766
800
  const verdict = this.pool.penalise(this.name, credential.id, credentialRefused ? 403 : status, waitMs, text);
@@ -1018,12 +1052,41 @@ export class ChatGptAdapter {
1018
1052
  const parser = new SseParser();
1019
1053
  let bytes = 0;
1020
1054
  let ping = null;
1021
- if (wantStream) {
1055
+ // Keep early rejection as an HTTP error until an actual content block arrives.
1056
+ // A 200 followed by an error loses the status Claude uses for overflow recovery.
1057
+ const pendingEvents = [];
1058
+ const openStream = () => {
1022
1059
  res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
1023
- for (const ev of mapper.start())
1060
+ };
1061
+ const sendEvents = (events) => {
1062
+ if (!wantStream)
1063
+ return;
1064
+ pendingEvents.push(...events);
1065
+ if (!res.headersSent) {
1066
+ if (mapper.failure?.type === "invalid_request_error")
1067
+ return;
1068
+ if (!pendingEvents.some((e) => e.event !== "message_start"))
1069
+ return;
1070
+ openStream();
1071
+ }
1072
+ for (const ev of pendingEvents.splice(0))
1024
1073
  bytes += write(res, formatSse(ev));
1074
+ };
1075
+ // The hold waits for upstream's first item, which normally follows the request within a second
1076
+ // — and an overflow rejection comes before it. Should it not come, the client must still hear
1077
+ // something: past this deadline the stream opens with message_start and the pings keep it alive,
1078
+ // as they did before the hold.
1079
+ let holdDeadline = null;
1080
+ if (wantStream) {
1081
+ holdDeadline = setTimeout(() => {
1082
+ if (res.headersSent || res.writableEnded || mapper.failure)
1083
+ return;
1084
+ openStream();
1085
+ for (const ev of [...pendingEvents.splice(0), ...mapper.start()])
1086
+ bytes += write(res, formatSse(ev));
1087
+ }, streamHold.ms);
1025
1088
  ping = setInterval(() => {
1026
- if (!res.writableEnded)
1089
+ if (res.headersSent && !res.writableEnded)
1027
1090
  bytes += write(res, formatSse({ event: "ping", data: { type: "ping" } }));
1028
1091
  }, PING_MS);
1029
1092
  }
@@ -1039,9 +1102,7 @@ export class ChatGptAdapter {
1039
1102
  if (mapper.rateLimits && mapper.rateLimits !== this.rateLimitsByAccount.get(credential.ownerId))
1040
1103
  this.noteRateLimits(credential, mapper.rateLimits);
1041
1104
  this.rememberInput(cacheKey, mapper.usage);
1042
- if (wantStream)
1043
- for (const o of outEvents)
1044
- bytes += write(res, formatSse(o));
1105
+ sendEvents(outEvents);
1045
1106
  if (mapper.isFinished)
1046
1107
  break;
1047
1108
  }
@@ -1055,22 +1116,20 @@ export class ChatGptAdapter {
1055
1116
  const tail = parser.sawDone
1056
1117
  ? mapper.finish()
1057
1118
  : mapper.fail(`${model}: upstream stream ended before the response completed`, "server_is_overloaded");
1058
- if (wantStream)
1059
- for (const o of tail)
1060
- bytes += write(res, formatSse(o));
1119
+ sendEvents(tail);
1061
1120
  }
1062
1121
  }
1063
1122
  catch (e) {
1064
1123
  if (!ac.signal.aborted) {
1065
1124
  const tail = mapper.fail(`stream interrupted: ${e.message}`, "server_is_overloaded");
1066
- if (wantStream)
1067
- for (const o of tail)
1068
- bytes += write(res, formatSse(o));
1125
+ sendEvents(tail);
1069
1126
  }
1070
1127
  }
1071
1128
  finally {
1072
1129
  if (ping)
1073
1130
  clearInterval(ping);
1131
+ if (holdDeadline)
1132
+ clearTimeout(holdDeadline);
1074
1133
  res.off("close", onClose);
1075
1134
  try {
1076
1135
  await reader.cancel();
@@ -1080,8 +1139,8 @@ export class ChatGptAdapter {
1080
1139
  }
1081
1140
  }
1082
1141
  const failure = mapper.failure;
1083
- const failedStatus = failure?.type === "overloaded_error" ? 529 : failure?.type === "rate_limit_error" ? 429 : 502;
1084
- if (!wantStream) {
1142
+ const failedStatus = failure?.type === "invalid_request_error" ? 400 : failure?.type === "overloaded_error" ? 529 : failure?.type === "rate_limit_error" ? 429 : 502;
1143
+ if (!wantStream || (failure && !res.headersSent)) {
1085
1144
  // A failed turn is an error here too, not a 200 carrying whatever arrived before it failed.
1086
1145
  const msg = failure ? anthropicError(failedStatus, failure.type, failure.message).body : JSON.stringify(mapper.message());
1087
1146
  res.writeHead(failure ? failedStatus : 200, { "content-type": "application/json", "content-length": String(Buffer.byteLength(msg)) }).end(msg);
@@ -1097,7 +1156,7 @@ export class ChatGptAdapter {
1097
1156
  bytes,
1098
1157
  note: failure
1099
1158
  ? `${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}`,
1159
+ : `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason}${upstreamReq.service_tier ? " fast" : ""}`,
1101
1160
  usage: {
1102
1161
  input: u.input_tokens,
1103
1162
  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
- // These do not arrive today. Claude Code runs its web search as a separate side request on a fixed
165
- // small model — measured 2026-09-17: an Opus session and a DeepSeek-routed session both sent it to
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 = systemText(req.system);
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 type = code === "server_is_overloaded" ? "overloaded_error" : code === "rate_limit_exceeded" || code === "usage_limit_reached" ? "rate_limit_error" : "api_error";
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
  }