clauderipple 0.8.7 → 0.8.9

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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.9 — 2026-10-08
4
+
5
+ ### Fixed
6
+
7
+ - **New Claude models appear on their own.** The Claude model list was built into ClaudeRipple,
8
+ so Opus 5.5, Sonnet 5.5 and Haiku 5.5 never showed up. It now comes from Anthropic's model list
9
+ for your account, refreshed hourly. When a newer model ships in a family you already use (say
10
+ Opus 5.5 after Opus 5), it is added to your Claude provider on its own. Untick it and it stays
11
+ unticked.
12
+
13
+ ## 0.8.8 — 2026-10-07
14
+
15
+ ### Fixed
16
+
17
+ - **Web search works with OpenCode Go's DeepSeek on the small/fast slot.** With that slot on
18
+ DeepSeek V4.1 Flash, V4 Flash or V4 Pro through OpenCode Go, every `WebSearch` was refused
19
+ and the Clients screen marked those models "no web search here". The same plan runs the search on
20
+ its Anthropic endpoint, so the router now sends searches for these models there. Their other
21
+ requests are unchanged.
22
+
3
23
  ## 0.8.7 — 2026-10-07
4
24
 
5
25
  ### Fixed
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "private": true,
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
4
4
  "type": "commonjs"
5
5
  }
@@ -23,13 +23,14 @@ import path from "node:path";
23
23
  import http from "node:http";
24
24
  import { execFile, spawn } from "node:child_process";
25
25
  import { fileURLToPath } from "node:url";
26
- import { homeDir, validate } from "./config.js";
26
+ import { homeDir, searchProviderFor, validate } from "./config.js";
27
27
  import { PRESETS } from "./presets.js";
28
28
  import { resolveCompatibleCaps } from "./compat.js";
29
29
  import { CHATGPT_FALLBACK_MODELS } from "./providers/chatgpt/catalog.js";
30
30
  import { measureModel, refusedByPlan } from "./capabilities.js";
31
31
  import { catalogEntry } from "./catalog.js";
32
32
  import { ClaudeCodeAuthStore, nativeAnthropicHeaders } from "./providers/anthropic.js";
33
+ import { fetchClaudeModels, newClaudeReleases } from "./providers/anthropic-models.js";
33
34
  import { codexEnabled, codexHome } from "../../cli/src/codex.js";
34
35
  import { openBrowser } from "../../cli/src/browser.js";
35
36
  import { caTrusted, currentAppProxy } from "../../cli/src/picker.js";
@@ -171,18 +172,27 @@ export function effortLevelsForModel(cfg, modelId) {
171
172
  }
172
173
  return undefined;
173
174
  }
175
+ // Only for when Anthropic's own list cannot be had (no sign-in yet, offline): the live list is
176
+ // `deps.claudeModels`, and a release after this table is cut appears there by itself (2026-10-08).
174
177
  const CLAUDE_MODEL_FALLBACK = [
175
- { id: "claude-fable-5-1", name: "Fable 5.1" },
178
+ { id: "claude-haiku-5-5", name: "Haiku 5.5" },
179
+ { id: "claude-sonnet-5-5", name: "Sonnet 5.5" },
176
180
  { id: "claude-opus-5-5", name: "Opus 5.5" },
181
+ { id: "claude-fable-5-1", name: "Fable 5.1" },
177
182
  { id: "claude-opus-5", name: "Opus 5" },
178
183
  { id: "claude-sonnet-5", name: "Sonnet 5" },
179
- { id: "claude-haiku-4-5", name: "Haiku 4.5" },
180
184
  { id: "claude-fable-5", name: "Fable 5" },
181
185
  { id: "claude-opus-4-8", name: "Opus 4.8" },
182
186
  { id: "claude-opus-4-7", name: "Opus 4.7" },
183
- { id: "claude-opus-4-6", name: "Opus 4.6" },
184
187
  { id: "claude-sonnet-4-6", name: "Sonnet 4.6" },
188
+ { id: "claude-opus-4-6", name: "Opus 4.6" },
189
+ { id: "claude-haiku-4-5", name: "Haiku 4.5" },
185
190
  ];
191
+ /** The account's Claude models, or the table above when Anthropic's list cannot be had. */
192
+ async function claudeModelList(deps) {
193
+ const live = await deps.claudeModels?.().catch(() => null);
194
+ return live?.length ? live.map(({ line: _line, createdAt: _createdAt, ...model }) => ({ ...model, name: model.name ?? model.id })) : CLAUDE_MODEL_FALLBACK;
195
+ }
186
196
  export function adminPort(cfg) {
187
197
  return cfg.admin?.port ?? cfg.listen.port + 1;
188
198
  }
@@ -318,6 +328,11 @@ async function buildStatus(deps, opts = {}) {
318
328
  // events — a backend the router does not have, so a search still cannot go through it.
319
329
  // Naming the provider's own capability instead would be a promise the router cannot keep.
320
330
  ...(providerCanSearch(name, cfg) ? { webSearch: true } : {}),
331
+ // A provider whose plan searches on another endpoint does so for some models only.
332
+ ...(() => {
333
+ const models = (("models" in p && p.models) || []).map((m) => typeof m === "string" ? m : m.id).filter((id) => searchProviderFor(p, id));
334
+ return models.length ? { webSearchModels: models } : {};
335
+ })(),
321
336
  // Reaching the host says nothing about being able to use it: a chatgpt provider with no
322
337
  // credentials is not "connected", and calling it that sends the user off believing it works.
323
338
  ...(p.type === "chatgpt" ? { needsLogin: !chatgptSignedIn(p.auth) } : {}),
@@ -633,7 +648,9 @@ function claudeAuthStore(deps) {
633
648
  return new ClaudeCodeAuthStore(undefined, { home: homeDir(), ...(deps.observedClaudeCodeAuth ? { observed: deps.observedClaudeCodeAuth } : {}) });
634
649
  }
635
650
  async function probeAnthropicApiKey(apiKey, probeFetch = fetchWithTimeout) {
636
- const models = CLAUDE_MODEL_FALLBACK;
651
+ // The key's own model list where it answers; a key that cannot list is still tested below.
652
+ const listed = await fetchClaudeModels("https://api.anthropic.com", { "x-api-key": apiKey }, probeFetch).catch(() => []);
653
+ const models = listed.length ? listed.map(({ line: _line, createdAt: _createdAt, ...model }) => model) : CLAUDE_MODEL_FALLBACK;
637
654
  const label = "messages endpoint";
638
655
  try {
639
656
  const response = await probeFetch("https://api.anthropic.com/v1/messages", {
@@ -656,14 +673,14 @@ async function probeAnthropicApiKey(apiKey, probeFetch = fetchWithTimeout) {
656
673
  return { ok: false, auth: "unreachable", models, error: `${label}: ${errorText(e)}` };
657
674
  }
658
675
  }
659
- function probeClaudeCodeAuth(deps) {
676
+ async function probeClaudeCodeAuth(deps) {
660
677
  const source = claudeAuthStore(deps).describeSource();
661
678
  // Our own accounts are reported separately: a Claude Desktop session can outrank them, and without
662
679
  // this the screen would answer a finished sign-in with the source it was already showing.
663
680
  const accounts = listClaudeAccounts(homeDir());
664
681
  const hasUsableAccount = accounts.some((account) => !account.needsReauth && account.expiresAt > Date.now());
665
682
  const signedIn = accounts.length > 0 ? "oauth" : readClaudeAuthFile(homeDir())?.source ?? null;
666
- return { ok: source !== null || hasUsableAccount, auth: source !== null || hasUsableAccount ? "ok" : "missing", source, signedIn, accountCount: accounts.length, models: CLAUDE_MODEL_FALLBACK };
683
+ return { ok: source !== null || hasUsableAccount, auth: source !== null || hasUsableAccount ? "ok" : "missing", source, signedIn, accountCount: accounts.length, models: await claudeModelList(deps) };
667
684
  }
668
685
  /** The reasoning levels a `GET /v1beta/models` entry says the model takes, or none to offer. */
669
686
  function googleEffortLevels(entry) {
@@ -1084,6 +1101,55 @@ export function measureUnsettledModels(deps) {
1084
1101
  }
1085
1102
  return started;
1086
1103
  }
1104
+ /**
1105
+ * Declare each Claude release the account has gained on every native Anthropic provider that
1106
+ * declares an older model of the same family (`newClaudeReleases`). The declared list was whatever
1107
+ * was ticked when the provider was saved, so Opus 5.5, Sonnet 5.5 and Haiku 5.5 stayed off it for
1108
+ * weeks while Claude Code had them (2026-10-08). A model offered once is remembered in
1109
+ * `claude-models-offered.json` and never offered again, so unticking a new model sticks.
1110
+ * Returns the `provider/model` ids it declared.
1111
+ */
1112
+ export async function adoptNewClaudeModels(deps) {
1113
+ const live = await deps.claudeModels?.().catch(() => null);
1114
+ if (!live?.length)
1115
+ return [];
1116
+ const stateFile = path.join(homeDir(), "claude-models-offered.json");
1117
+ let offered = new Set();
1118
+ try {
1119
+ const saved = JSON.parse(fs.readFileSync(stateFile, "utf8"));
1120
+ if (Array.isArray(saved.offered))
1121
+ offered = new Set(saved.offered.filter((id) => typeof id === "string"));
1122
+ }
1123
+ catch { /* first run: nothing offered yet */ }
1124
+ let current;
1125
+ try {
1126
+ current = JSON.parse(fs.readFileSync(deps.configFile, "utf8"));
1127
+ }
1128
+ catch (e) {
1129
+ deps.log.warn(`admin: claude models not adopted: could not read config: ${errorText(e)}`);
1130
+ return [];
1131
+ }
1132
+ const added = [];
1133
+ for (const [name, provider] of Object.entries(current.providers)) {
1134
+ if (provider.type !== "anthropic" || !provider.models?.length)
1135
+ continue;
1136
+ for (const model of newClaudeReleases(live, provider.models.map((entry) => entry.id), offered)) {
1137
+ provider.models.push({ id: model.id, name: model.name ?? model.id });
1138
+ added.push(`${name}/${model.id}`);
1139
+ }
1140
+ }
1141
+ if (added.length) {
1142
+ const errors = validate(current);
1143
+ if (errors.length > 0) {
1144
+ deps.log.warn(`admin: claude models not adopted: config did not validate: ${errors[0]}`);
1145
+ return [];
1146
+ }
1147
+ writeConfigFile(deps.configFile, current);
1148
+ deps.log.info(`admin: declared new Claude model(s): ${added.join(", ")}`);
1149
+ }
1150
+ fs.writeFileSync(stateFile, JSON.stringify({ offered: [...new Set([...offered, ...live.map((model) => model.id)])].sort() }, null, 2) + "\n");
1151
+ return added;
1152
+ }
1087
1153
  /** Run one provider measurement job to completion. Detached: the request that started it has already answered. */
1088
1154
  async function runMeasureJob(jobId, providerName, models, deps) {
1089
1155
  const job = MEASURE_JOBS.get(jobId);
@@ -1140,7 +1206,7 @@ async function runMeasureJob(jobId, providerName, models, deps) {
1140
1206
  MEASURE_JOBS.set(jobId, { ...job, state: "failed", error: errorText(e), finishedAt: Date.now() });
1141
1207
  }
1142
1208
  }
1143
- function pickerModels(deps) {
1209
+ async function pickerModels(deps) {
1144
1210
  const last = deps.picker?.().last;
1145
1211
  if (last && typeof last === "object" && Array.isArray(last.surfaces)) {
1146
1212
  const entries = last.surfaces
@@ -1166,7 +1232,7 @@ function pickerModels(deps) {
1166
1232
  if (models.length > 0)
1167
1233
  return { models, source: "picker" };
1168
1234
  }
1169
- return { models: CLAUDE_MODEL_FALLBACK, source: "fallback" };
1235
+ return { models: await claudeModelList(deps), source: "fallback" };
1170
1236
  }
1171
1237
  function tailLines(file, n) {
1172
1238
  if (!fs.existsSync(file))
@@ -1288,7 +1354,7 @@ export function startAdmin(deps) {
1288
1354
  return;
1289
1355
  }
1290
1356
  if (pathname === "/api/claude-models" && method === "GET") {
1291
- sendJson(res, 200, pickerModels(deps));
1357
+ sendJson(res, 200, await pickerModels(deps));
1292
1358
  return;
1293
1359
  }
1294
1360
  if (pathname === "/api/effort-levels" && method === "GET") {
@@ -1319,7 +1385,7 @@ export function startAdmin(deps) {
1319
1385
  const probe = parsed;
1320
1386
  if (probe.type === "anthropic") {
1321
1387
  if (probe.auth === "claude-code") {
1322
- sendJson(res, 200, probeClaudeCodeAuth(deps));
1388
+ sendJson(res, 200, await probeClaudeCodeAuth(deps));
1323
1389
  return;
1324
1390
  }
1325
1391
  if (probe.auth === "api-key") {
@@ -6,6 +6,7 @@
6
6
  import fs from "node:fs";
7
7
  import os from "node:os";
8
8
  import path from "node:path";
9
+ import { PRESETS } from "./presets.js";
9
10
  /**
10
11
  * The provider's own key, re-sent under the header another wire on the same account expects.
11
12
  *
@@ -76,6 +77,27 @@ export function providerFor(provider, model) {
76
77
  }
77
78
  return { ...shared, type: "openai-compatible", url: url ?? "", ...(entry.wire ? { wire: entry.wire } : {}) };
78
79
  }
80
+ /**
81
+ * The Anthropic endpoint a web search for `model` runs on, when the provider's preset measured that
82
+ * its plan searches there although the model is spoken to on another wire (`anthropicSearch`).
83
+ * OpenCode Go's DeepSeek models answer on Chat Completions, which has no search, while the same key
84
+ * on the plan's Anthropic endpoint runs the server tool (2026-10-07).
85
+ */
86
+ export function searchProviderFor(provider, model) {
87
+ const presetId = provider && "preset" in provider ? provider.preset : undefined;
88
+ const search = presetId ? PRESETS.find((entry) => entry.id === presetId)?.anthropicSearch : undefined;
89
+ if (!provider || !search || !search.models.includes(model))
90
+ return undefined;
91
+ const headers = reauthorized("headers" in provider ? provider.headers : undefined, search.authHeader);
92
+ const sessionHeader = "sessionHeader" in provider ? provider.sessionHeader : undefined;
93
+ return {
94
+ type: "anthropic-compatible",
95
+ url: search.url,
96
+ ...(headers ? { headers } : {}),
97
+ ...(sessionHeader ? { sessionHeader } : {}),
98
+ ...(presetId ? { preset: presetId } : {}),
99
+ };
100
+ }
79
101
  export const PICKER_HOSTS_DEFAULT = ["claude.ai"];
80
102
  export function terminateHosts(c) {
81
103
  const hosts = [c.upstream];
@@ -14,7 +14,7 @@ import { CertStore } from "./certs.js";
14
14
  import { Logger } from "./log.js";
15
15
  import { UpstreamHealth, EXIT_UPSTREAM_UNREACHABLE } from "./health.js";
16
16
  import { Proxy } from "./proxy.js";
17
- import { measureUnsettledModels, startAdmin } from "./admin.js";
17
+ import { adoptNewClaudeModels, measureUnsettledModels, startAdmin } from "./admin.js";
18
18
  import { loadModelCatalog } from "./catalog.js";
19
19
  import { RequestLog } from "./requestlog.js";
20
20
  import { OpenAiIngress } from "./ingress/server.js";
@@ -164,6 +164,7 @@ proxy
164
164
  picker: () => ({ enabled: !!store.get().picker?.enabled, hosts: terminateHosts(store.get()).slice(1), last: proxy.lastPickerInjection }),
165
165
  observedClaudeCodeAuth,
166
166
  claudeUsage: () => proxy.claudeUsage.snapshot(),
167
+ claudeModels: () => proxy.claudeModels.list(),
167
168
  image: (req, signal) => proxy.generateImage(req, signal),
168
169
  modelCatalog: () => loadModelCatalog(),
169
170
  shutdown: () => beginDrain("shutdown requested"),
@@ -174,6 +175,11 @@ proxy
174
175
  // A minute in, once start-up traffic has settled: a few one-token requests per model whose
175
176
  // effort nobody has measured yet, so effort works without the owner re-saving the provider.
176
177
  setTimeout(() => measureUnsettledModels(adminDeps), 60_000).unref();
178
+ // Claude releases reach the declared models without a ClaudeRipple release: checked a minute in,
179
+ // then every six hours (the catalogue itself is cached for an hour).
180
+ const adopt = () => void adoptNewClaudeModels(adminDeps).catch((e) => log.warn(`claude models: ${e.message}`));
181
+ setTimeout(adopt, 60_000).unref();
182
+ setInterval(adopt, 6 * 60 * 60_000).unref();
177
183
  })
178
184
  .catch((e) => {
179
185
  log.error(`listen failed: ${e.message}`);
@@ -260,6 +260,13 @@ export const PRESETS = [
260
260
  // to the Responses models above; the Chat and Anthropic ones carry their own empty ladder.
261
261
  effortLevels: ["none", "minimal", "low", "medium", "high", "xhigh"],
262
262
  thinking: "none",
263
+ // The DeepSeek models are served on Chat Completions, which ignores every search option, but
264
+ // the same key on the Anthropic endpoint runs `web_search_20250305` for them: Claude Code's own
265
+ // side request came back with a `web_search_tool_result` of ten real pages and
266
+ // `web_search_requests: 1` (measured 2026-10-07; a bearer header there answers "Missing API
267
+ // key"). Kimi K3, MiniMax M3 and Qwen3.8 Flash take the tool on that endpoint and never search;
268
+ // GLM and MiMo are not served there at all.
269
+ anthropicSearch: { url: "https://opencode.ai/zen/go", authHeader: "x-api-key", models: ["deepseek-v4.1-flash", "deepseek-v4-flash", "deepseek-v4-pro"] },
263
270
  verified: true,
264
271
  notes: "One subscription and key, three endpoints by wire. Measured 2026-09-18. Responses: Muse Spark answers, effort ladder tops out at xhigh, prompt cache reached 96% on a repeated turn. Chat: glm-5.3, glm-5.3-flash, kimi-k3, kimi-k2.7-code, deepseek-v4.1-flash, deepseek-v4-pro, longcat-2.0 and mimo-v2.5-pro all answered. Anthropic: minimax-m3, qwen3.8-max and qwen3.8-flash answered with the prompt cache at 99% on a repeated turn, while union-alpha, the free row, answered \"Model is unavailable\". The Muse Spark Contributor tier is ~90% cheaper because Meta states those interactions improve its products, and OpenCode refuses the model until the workspace opts in (403 DataPolicyError); it is also limited to some regions.",
265
272
  docsUrl: "https://opencode.ai/docs/go/",
@@ -0,0 +1,125 @@
1
+ const EFFORT_ORDER = ["low", "medium", "high", "xhigh", "max"];
2
+ /**
3
+ * The active models in a `/v1/models` body. A dated id (`claude-haiku-4-5-20251001`) is folded to
4
+ * its alias, which the API takes as well and which is the id the rest of ClaudeRipple and Claude
5
+ * Code's own aliases use.
6
+ */
7
+ export function claudeModelsFromBody(body) {
8
+ const data = body?.data;
9
+ if (!Array.isArray(data))
10
+ return [];
11
+ const out = [];
12
+ for (const raw of data) {
13
+ if (!raw || typeof raw !== "object")
14
+ continue;
15
+ const m = raw;
16
+ if (typeof m.id !== "string" || !m.id.startsWith("claude-"))
17
+ continue;
18
+ if (m.lifecycle !== undefined && m.lifecycle !== "active")
19
+ continue;
20
+ const id = m.id.replace(/-\d{8}$/, "");
21
+ if (out.some((entry) => entry.id === id))
22
+ continue;
23
+ const name = typeof m.display_name === "string" ? m.display_name.replace(/^Claude\s+/, "") : id;
24
+ const effort = m.capabilities?.effort;
25
+ const supported = (key) => {
26
+ const value = effort?.[key];
27
+ return typeof value === "object" && value !== null && value.supported === true;
28
+ };
29
+ const entry = { id, name };
30
+ // Haiku 4.5 reports the effort block with every level unsupported: it takes no effort at all.
31
+ if (effort && typeof effort === "object")
32
+ entry.effortLevels = effort.supported === true ? EFFORT_ORDER.filter(supported) : [];
33
+ if (typeof m.max_input_tokens === "number" && m.max_input_tokens > 0)
34
+ entry.contextWindow = m.max_input_tokens;
35
+ if (typeof m.line === "string")
36
+ entry.line = m.line;
37
+ const created = typeof m.created_at === "string" ? Date.parse(m.created_at) : NaN;
38
+ if (Number.isFinite(created))
39
+ entry.createdAt = created;
40
+ out.push(entry);
41
+ }
42
+ return out;
43
+ }
44
+ /**
45
+ * The account's model list, fetched at most once per `ttlMs` (an hour by default: Anthropic ships a
46
+ * model every few weeks). A failed lookup keeps the last good list, and null means none was ever
47
+ * had, so a caller falls back to its own.
48
+ */
49
+ export class ClaudeModelCatalog {
50
+ last = null;
51
+ fetchedAt = Number.NEGATIVE_INFINITY;
52
+ inFlight = null;
53
+ source;
54
+ opts;
55
+ constructor(source, opts) {
56
+ this.source = source;
57
+ this.opts = opts;
58
+ }
59
+ list() {
60
+ const now = (this.opts.now ?? Date.now)();
61
+ if (now - this.fetchedAt < (this.opts.ttlMs ?? 60 * 60_000))
62
+ return Promise.resolve(this.last);
63
+ this.inFlight ??= this.fetch().finally(() => {
64
+ this.fetchedAt = (this.opts.now ?? Date.now)();
65
+ this.inFlight = null;
66
+ });
67
+ return this.inFlight;
68
+ }
69
+ async fetch() {
70
+ let credential;
71
+ try {
72
+ credential = (await this.source.credentials())[0];
73
+ }
74
+ catch (e) {
75
+ this.opts.warn?.(`claude models: no credentials: ${e.message}`);
76
+ return this.last;
77
+ }
78
+ if (!credential)
79
+ return this.last;
80
+ try {
81
+ const models = await fetchClaudeModels(`https://${this.opts.upstream()}`, credential.headers, this.opts.fetch, this.opts.timeoutMs);
82
+ if (models.length)
83
+ this.last = models;
84
+ }
85
+ catch (e) {
86
+ this.opts.warn?.(`claude models: ${e.message}`);
87
+ }
88
+ return this.last;
89
+ }
90
+ }
91
+ /** One `GET /v1/models` with the given credentials. Throws on anything but a usable answer. */
92
+ export async function fetchClaudeModels(origin, headers, fetchImpl = fetch, timeoutMs = 8000) {
93
+ const res = await fetchImpl(`${origin}/v1/models?limit=100`, {
94
+ method: "GET",
95
+ headers: { "anthropic-version": "2023-06-01", ...headers, accept: "application/json" },
96
+ signal: AbortSignal.timeout(timeoutMs),
97
+ });
98
+ // The status alone: the body of an auth failure is not worth the risk of echoing.
99
+ if (!res.ok)
100
+ throw new Error(`HTTP ${res.status}`);
101
+ return claudeModelsFromBody(await res.json());
102
+ }
103
+ /**
104
+ * The models to add to a provider's declared list: each one released after the newest model it
105
+ * already declares in the same family, and not offered before. A family the provider does not
106
+ * declare at all is left out — someone who unticked every Sonnet did not ask for the next one — and
107
+ * so is anything in `offered`, so a model unticked after it arrived does not come back.
108
+ */
109
+ export function newClaudeReleases(live, declared, offered) {
110
+ const byId = new Map(live.map((model) => [model.id, model]));
111
+ const newest = new Map();
112
+ for (const id of declared) {
113
+ const model = byId.get(id.replace(/-\d{8}$/, ""));
114
+ if (!model?.line || model.createdAt === undefined)
115
+ continue;
116
+ newest.set(model.line, Math.max(newest.get(model.line) ?? Number.NEGATIVE_INFINITY, model.createdAt));
117
+ }
118
+ const declaredIds = new Set(declared.map((id) => id.replace(/-\d{8}$/, "")));
119
+ return live.filter((model) => {
120
+ if (declaredIds.has(model.id) || offered.has(model.id) || !model.line || model.createdAt === undefined)
121
+ return false;
122
+ const floor = newest.get(model.line);
123
+ return floor !== undefined && model.createdAt > floor;
124
+ });
125
+ }
@@ -31,12 +31,13 @@ import { ChatGptAdapter } from "./providers/chatgpt/index.js";
31
31
  import { OpenAiCompatibleAdapter } from "./providers/openai/index.js";
32
32
  import { GoogleAdapter } from "./providers/google/index.js";
33
33
  import { conversationKey } from "./providers/chatgpt/translate.js";
34
- import { providerFor, terminateHosts } from "./config.js";
34
+ import { providerFor, searchProviderFor, terminateHosts } from "./config.js";
35
35
  import { injectPickerModels, isBootstrapPath } from "./picker.js";
36
36
  import { effortLevelsForModel } from "./admin.js";
37
37
  import { ResponseUsageTap } from "./requestlog.js";
38
38
  import { credentialHeaderValues, redactErrorText, redactHeaders } from "./redact.js";
39
39
  import { ClaudeAccountAuthPool } from "./providers/anthropic-account-pool.js";
40
+ import { ClaudeModelCatalog } from "./providers/anthropic-models.js";
40
41
  import { ClaudeUsage } from "./providers/anthropic-usage.js";
41
42
  import { dropForeignThinking } from "./thinking.js";
42
43
  const MAX_BODY = 64 * 1024 * 1024;
@@ -188,6 +189,8 @@ export class Proxy {
188
189
  claudeAccounts;
189
190
  /** Each Claude subscription account's usage, for the admin status (never a token). */
190
191
  claudeUsage;
192
+ /** The Claude models the signed-in account can use, from Anthropic's own list. */
193
+ claudeModels;
191
194
  deps;
192
195
  /**
193
196
  * Cooldowns, quarantines and conversation stickiness for provider credentials. In memory: a
@@ -357,6 +360,10 @@ export class Proxy {
357
360
  upstream: () => this.deps.config().upstream,
358
361
  warn: (message) => deps.log.warn(message),
359
362
  });
363
+ this.claudeModels = new ClaudeModelCatalog(this.claudeAccounts, {
364
+ upstream: () => this.deps.config().upstream,
365
+ warn: (message) => deps.log.warn(message),
366
+ });
360
367
  this.httpServer = http.createServer({ maxHeaderSize: 64 * 1024 }, (req, res) => {
361
368
  void this.handle(req, res);
362
369
  });
@@ -724,7 +731,9 @@ export class Proxy {
724
731
  // search of a `smallFast` slot pointed at Luna while the Clients screen called that provider
725
732
  // searchable (2026-10-07: 41 searches failed with nothing on screen to say why). A configured
726
733
  // backend on this same provider has already had its try above.
727
- const own = cfg.providers[route.provider]?.type === "chatgpt" && cfg.webSearch?.provider !== route.provider;
734
+ // The same holds for a plan that searches on another of its endpoints (`anthropicSearch`).
735
+ const own = (cfg.providers[route.provider]?.type === "chatgpt" || searchProviderFor(cfg.providers[route.provider], route.model) !== undefined) &&
736
+ cfg.webSearch?.provider !== route.provider;
728
737
  if (own && (await this.serveWebSearch(res, json, search, { provider: route.provider, model: route.model }, record, finish)))
729
738
  return;
730
739
  const blocks = webSearchErrorBlocks(search, "unavailable");
@@ -1358,8 +1367,9 @@ export class Proxy {
1358
1367
  return false;
1359
1368
  }
1360
1369
  // Which backend is chosen below depends on the wire the search model speaks, which may not be
1361
- // the provider's own.
1362
- const provider = providerFor(configured, settings.model);
1370
+ // the provider's own — or on the endpoint its plan searches on, which may not be the model's.
1371
+ const searchVia = searchProviderFor(configured, settings.model);
1372
+ const provider = searchVia ?? providerFor(configured, settings.model);
1363
1373
  const url = "url" in provider && typeof provider.url === "string" ? provider.url : undefined;
1364
1374
  if (!url && provider.type !== "chatgpt") {
1365
1375
  this.deps.log.warn(`web search: provider ${settings.provider} has no url; leaving the request alone`);
@@ -1368,10 +1378,12 @@ export class Proxy {
1368
1378
  // Which backend depends on how the provider is spoken to, not on the vendor. An
1369
1379
  // anthropic-compatible one is asked in Anthropic's own shape and hands back the blocks the CLI
1370
1380
  // already parses; an openai-compatible one is asked through its chat web plugin.
1381
+ // OpenCode Go refuses a request without its session header outright (400 MissingSessionID).
1382
+ const sessionHeader = "sessionHeader" in provider ? provider.sessionHeader : undefined;
1371
1383
  const common = {
1372
1384
  name: settings.provider,
1373
1385
  url: url ?? "",
1374
- headers: ("headers" in provider && provider.headers) || {},
1386
+ headers: { ...(("headers" in provider && provider.headers) || {}), ...(sessionHeader ? { [sessionHeader]: crypto.randomUUID() } : {}) },
1375
1387
  model: settings.model,
1376
1388
  ...(settings.maxResults ? { maxResults: settings.maxResults } : {}),
1377
1389
  };
@@ -1380,7 +1392,7 @@ export class Proxy {
1380
1392
  // Refuse before sending rather than after. A provider that cannot run the server tool is sent
1381
1393
  // "perform a web search" with no tool attached, and a model told to search with nothing to
1382
1394
  // search with narrates a tool call instead — an answer shaped like success, holding nothing.
1383
- if (!this.canRunServerTools(settings.provider, this.deps.config())) {
1395
+ if (!searchVia && !this.canRunServerTools(settings.provider, this.deps.config())) {
1384
1396
  this.deps.log.warn(`web search: provider ${settings.provider} does not run server tools; leaving the request alone`);
1385
1397
  return false;
1386
1398
  }
@@ -5,4 +5,4 @@
5
5
  // from the manifests. Until 0.1.1 the router and the CLI each carried their own literal, and both
6
6
  // still said "0.1.0" in the 0.1.1 release: after an update nobody could tell which router was
7
7
  // running, and the tray app had nothing to compare (2026-09-15, reported from a Windows install).
8
- export const VERSION = "0.8.7";
8
+ export const VERSION = "0.8.9";
@@ -296,7 +296,8 @@ export function anthropicServerToolBackend(opts) {
296
296
  // No result blocks means the provider did not run the tool, whatever else it said. A model
297
297
  // told to search with no tool it can reach will narrate one instead — an OpenCode Go DeepSeek
298
298
  // answered this exact request with its own tool-call markup as plain text and zero searches
299
- // (measured 2026-09-18). Treat that as the failure it is.
299
+ // (measured 2026-09-18; that same model on the plan's Anthropic endpoint, with its x-api-key
300
+ // header, does search — 2026-10-07). Treat that as the failure it is.
300
301
  const deduped = dedupeHits(hits).slice(0, opts.maxResults ?? 10);
301
302
  if (deduped.length === 0)
302
303
  throw new Error(`${opts.name} web search: no results returned`);
package/dist/ui/app.js CHANGED
@@ -519,8 +519,12 @@ const MODEL_SLOTS = ["smallFast", "subagent", "main"];
519
519
  // A provider the router measured as unable to search. Only the router can know this — it comes from
520
520
  // the status snapshot, never from the model's name — and the `smallFast` slot is exactly where it
521
521
  // matters, because a search sent to such a model comes back as invented prose or a visible failure.
522
- function providerCannotSearch(name) {
523
- return Boolean(status && status.providers && status.providers[name] && status.providers[name].webSearch !== true);
522
+ // A plan that searches on another of its endpoints does so for the models it names only.
523
+ function providerCannotSearch(name, model) {
524
+ const entry = status && status.providers && status.providers[name];
525
+ if (!entry || entry.webSearch === true) return false;
526
+ const id = String(model || "").replace(/@[^@]*$/, "");
527
+ return !(Array.isArray(entry.webSearchModels) && entry.webSearchModels.includes(id));
524
528
  }
525
529
 
526
530
  function renderModelSlots() {
@@ -535,7 +539,7 @@ function renderModelSlots() {
535
539
  if (chosen[slot] === model.id) option.selected = true;
536
540
  // `smallFast` is what a WebSearch runs on, so an inability to search is fatal there and
537
541
  // merely worth knowing everywhere else.
538
- if (slot === "smallFast" && providerCannotSearch(group.name)) {
542
+ if (slot === "smallFast" && providerCannotSearch(group.name, model.id)) {
539
543
  option.textContent += ` — ${t("slots.noWebSearch")}`;
540
544
  }
541
545
  select.appendChild(option);
@@ -572,7 +576,7 @@ async function saveModelSlots() {
572
576
  // screen was already guilty of once. The label on the option warns before the fact; this catches
573
577
  // a session that already had it selected.
574
578
  const owners = models.smallFast ? providersOffering(next, models.smallFast) : [];
575
- if (owners.length && providerCannotSearch(owners[0])) toast(t("slots.noWebSearchWarn"), true);
579
+ if (owners.length && providerCannotSearch(owners[0], models.smallFast)) toast(t("slots.noWebSearchWarn"), true);
576
580
  else toast(t("slots.slotSaved"));
577
581
  } catch (error) { toast(t("common.saveFailed"), true, error.message); }
578
582
  }
@@ -1791,7 +1795,10 @@ function openAnthropicProviderForm(options) {
1791
1795
  response.error ? el("div", { class: "small", text: response.error.replace(/^no-credits:\s*/, "") }) : null,
1792
1796
  ].filter(Boolean));
1793
1797
  if (Array.isArray(response.models) && response.models.length) {
1794
- foundModels = response.models;
1798
+ // Anthropic's own list, so a release shows up here without a ClaudeRipple update. A saved
1799
+ // model the list no longer carries stays visible and ticked rather than dropped on save.
1800
+ const listed = new Set(response.models.map((model) => model.id));
1801
+ foundModels = [...response.models, ...modelsOf(existing).filter((model) => !listed.has(model.id))];
1795
1802
  selected = new Set(existing ? modelsOf(existing).map((model) => model.id) : foundModels.map((model) => model.id));
1796
1803
  renderModels();
1797
1804
  }
@@ -1799,6 +1806,9 @@ function openAnthropicProviderForm(options) {
1799
1806
  finally { if (formActive) probeButton.disabled = false; }
1800
1807
  }
1801
1808
  probeButton.addEventListener("click", () => void runProbe());
1809
+ // The Claude login needs nothing typed, so fetch the account's model list as soon as the form
1810
+ // opens: a saved provider otherwise showed only what was ticked when it was first saved.
1811
+ if (auth.value === "claude-code") setTimeout(() => void runProbe(), 0);
1802
1812
  const modeHint = hint("");
1803
1813
  function syncModeHint() { modeHint.textContent = auth.value === "claude-code" && accountPool ? t("providers.anthropicPoolRouting") : t("providers.anthropicIngressOnly"); }
1804
1814
  auth.addEventListener("change", syncModeHint);
@@ -3,15 +3,18 @@
3
3
  // Used only while the router is starting up or an older router does not yet expose
4
4
  // /api/presets and /api/claude-models. The API response replaces these lists.
5
5
  const FALLBACK_CLAUDE_MODELS = [
6
+ { id: "claude-haiku-5-5", name: "Haiku 5.5" },
7
+ { id: "claude-sonnet-5-5", name: "Sonnet 5.5" },
8
+ { id: "claude-opus-5-5", name: "Opus 5.5" },
6
9
  { id: "claude-fable-5-1", name: "Fable 5.1" },
7
10
  { id: "claude-opus-5", name: "Opus 5" },
8
11
  { id: "claude-sonnet-5", name: "Sonnet 5" },
9
- { id: "claude-haiku-4-5", name: "Haiku 4.5" },
10
12
  { id: "claude-fable-5", name: "Fable 5" },
11
13
  { id: "claude-opus-4-8", name: "Opus 4.8" },
12
14
  { id: "claude-opus-4-7", name: "Opus 4.7" },
13
- { id: "claude-opus-4-6", name: "Opus 4.6" },
14
15
  { id: "claude-sonnet-4-6", name: "Sonnet 4.6" },
16
+ { id: "claude-opus-4-6", name: "Opus 4.6" },
17
+ { id: "claude-haiku-4-5", name: "Haiku 4.5" },
15
18
  ];
16
19
 
17
20
  const CHATGPT_MODELS = [
@@ -208,6 +208,18 @@ request went, and a `Pane` holds the request log.
208
208
 
209
209
  ## 4. Provider adapters
210
210
 
211
+ - **The Claude model list is Anthropic's own (2026-10-08).** The native `anthropic`
212
+ provider's models came from a constant in admin.ts, so each release after it was cut
213
+ was missing from the provider form, the mapping table and the declared list until a
214
+ ClaudeRipple release caught up — Opus 5.5 (Sep 21), Sonnet 5.5 (Sep 28) and Haiku 5.5
215
+ (Oct 7) all were, while Claude Code itself had them. `GET /v1/models` answers the Claude
216
+ Code login (the observed headers) as well as an API key, newest first, with each model's
217
+ effort ladder, context window and lifecycle (measured: 14 active models). The router
218
+ caches it for an hour (`ClaudeModelCatalog`); the constant is only the fallback when no
219
+ sign-in answers. A release is also **declared** on its own, every six hours: added to a
220
+ native provider that already declares an older model of the same family, and remembered
221
+ in `claude-models-offered.json` so a model unticked afterwards is not added again.
222
+
211
223
  - **Declared models route themselves (2026-09-19).** A provider's `models` list —
212
224
  filled by the probe from the vendor's `/models`, ticked in the GUI — now decides
213
225
  routing when no rule matched: a model exactly one provider carries goes there,
@@ -506,7 +518,16 @@ request went, and a `Pane` holds the request log.
506
518
  runs OpenAI's hosted `web_search` and cites real pages, but its wire is OpenAI-shaped while
507
519
  its hits arrive as Responses events (`web_search_call`, `url_citation`), a backend the router
508
520
  does not have. DeepSeek's own endpoint and the ChatGPT provider run Anthropic's server tool;
509
- OpenCode Go's chat and Anthropic endpoints, and every `openai-compatible` provider, do not.
521
+ OpenCode Go's chat endpoint, and every `openai-compatible` provider, do not.
522
+ - **OpenCode Go's Anthropic endpoint does, for DeepSeek only** (corrected 2026-10-07). The
523
+ line above once said it did not. Re-measured with the key in `x-api-key` (a bearer there
524
+ answers "Missing API key"), Claude Code's own side request to `/zen/go/v1/messages` came back
525
+ with ten real hits and `web_search_requests: 1` for deepseek-v4.1-flash, deepseek-v4-flash
526
+ and deepseek-v4-pro; Kimi K3, MiniMax M3 and Qwen3.8 Flash took the tool and never searched;
527
+ GLM and MiMo are not served there. The preset lists those three as `anthropicSearch`, and a
528
+ search routed to one of them (a `smallFast` slot on OpenCode Go's DeepSeek) is relayed there
529
+ by `anthropicServerToolBackend`, though the model's turns stay on Chat Completions. The
530
+ Clients screen reads `webSearchModels` from `/api/status` to stop calling them unsearchable.
510
531
  - **A model that cannot search does not merely decline — it invents.** Measured 2026-09-21
511
532
  against OpenCode Go's chat endpoint: `plugins: [{id: "web"}]` is accepted and ignored (HTTP
512
533
  200, no `annotations`, cache reporting no lookup), and the model answers a search-shaped
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clauderipple",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
4
4
  "description": "Use GPT (any ChatGPT plan with Codex), DeepSeek and 400+ other models in the Claude Desktop Code tab and Claude Code while staying signed in to your Claude subscription — no third-party gateway mode. Several ChatGPT accounts that take over when one runs out, in Codex too.",
5
5
  "license": "GPL-3.0-only",
6
6
  "workspaces": [