clauderipple 0.11.0 → 0.12.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 (50) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.es.md +48 -0
  3. package/README.ja.md +42 -0
  4. package/README.ko.md +39 -0
  5. package/README.md +44 -0
  6. package/README.pt-BR.md +46 -0
  7. package/README.zh-CN.md +40 -0
  8. package/dist/app/dist/claude-desktop.js +31 -0
  9. package/dist/app/dist/main.js +127 -8
  10. package/dist/app/dist/status-model.js +12 -0
  11. package/dist/app/dist/update-model.js +32 -0
  12. package/dist/app/dist/window.js +10 -3
  13. package/dist/app/package.json +1 -1
  14. package/dist/cli/src/index.js +5 -1
  15. package/dist/mod/hooks/i18n.ts +6 -0
  16. package/dist/mod/hooks/modes.ts +15 -8
  17. package/dist/mod/hooks/register.tsx +67 -19
  18. package/dist/mod/hooks/settings.js +37 -2
  19. package/dist/mod/hooks/settings.ts +31 -3
  20. package/dist/mod/hooks/ui/agents.tsx +1 -1
  21. package/dist/mod/hooks/ui/art.ts +8 -3
  22. package/dist/mod/hooks/ui/band.tsx +6 -2
  23. package/dist/mod/hooks/ui/flat.ts +11 -8
  24. package/dist/mod/hooks/ui/look.ts +3 -1
  25. package/dist/mod/hooks/ui/view.ts +2 -0
  26. package/dist/mod/hooks/workers.ts +8 -1
  27. package/dist/mod/tests/delegation.test.tsx +50 -0
  28. package/dist/mod/tests/register.test.tsx +138 -1
  29. package/dist/mod/tests/ui.test.tsx +6 -3
  30. package/dist/mod/types/index.d.ts +14 -0
  31. package/dist/router/src/admin.js +41 -76
  32. package/dist/router/src/autoupdate.js +299 -0
  33. package/dist/router/src/bootstrap.js +3 -2
  34. package/dist/router/src/cli-control.js +65 -0
  35. package/dist/router/src/config.js +70 -11
  36. package/dist/router/src/index.js +11 -1
  37. package/dist/router/src/picker.js +25 -1
  38. package/dist/router/src/presets.js +21 -13
  39. package/dist/router/src/providers/chatgpt/index.js +11 -0
  40. package/dist/router/src/providers/google/index.js +7 -2
  41. package/dist/router/src/providers/google/transport.js +16 -1
  42. package/dist/router/src/proxy.js +46 -6
  43. package/dist/router/src/requestlog.js +3 -2
  44. package/dist/router/src/version.js +1 -1
  45. package/dist/router/src/websearch.js +2 -2
  46. package/dist/ui/app.js +247 -42
  47. package/dist/ui/i18n.js +154 -18
  48. package/dist/ui/style.css +21 -0
  49. package/docs/ARCHITECTURE.md +61 -11
  50. package/package.json +1 -1
@@ -9,15 +9,12 @@ import path from "node:path";
9
9
  import { PRESETS } from "./presets.js";
10
10
  import { hostProblem, portProblem } from "./remote-host.js";
11
11
  /**
12
- * The provider's own key, re-sent under the header another wire on the same account expects.
12
+ * The provider's own key wherever its headers carry it, and every header that is not that one.
13
13
  *
14
- * The key is never restated in a model entry, so there is exactly one copy of it to rotate and one
15
- * to leak. Every other header the provider set is kept: only the auth one is replaced, because
16
- * sending both conventions at once would hand the endpoint a credential it did not ask for.
14
+ * Both auth conventions in use are recognised (`x-api-key` and `Authorization: Bearer`), because
15
+ * one account's headers may describe either depending on the endpoint it was configured for.
17
16
  */
18
- function reauthorized(headers, kind) {
19
- if (!headers)
20
- return headers;
17
+ function credentialIn(headers) {
21
18
  const kept = {};
22
19
  let key;
23
20
  for (const [name, value] of Object.entries(headers)) {
@@ -32,6 +29,19 @@ function reauthorized(headers, kind) {
32
29
  }
33
30
  kept[name] = value;
34
31
  }
32
+ return key === undefined ? { kept } : { key, kept };
33
+ }
34
+ /**
35
+ * The provider's own key, re-sent under the header another wire on the same account expects.
36
+ *
37
+ * The key is never restated in a model entry, so there is exactly one copy of it to rotate and one
38
+ * to leak. Every other header the provider set is kept: only the auth one is replaced, because
39
+ * sending both conventions at once would hand the endpoint a credential it did not ask for.
40
+ */
41
+ function reauthorized(headers, kind) {
42
+ if (!headers)
43
+ return headers;
44
+ const { key, kept } = credentialIn(headers);
35
45
  // Nothing recognisable to move: leave the headers exactly as they were rather than inventing an
36
46
  // empty credential, which would read to the endpoint as a missing key instead of a config error.
37
47
  if (key === undefined)
@@ -60,6 +70,28 @@ export function providerFor(provider, model) {
60
70
  if (!entry || (entry.wire === undefined && entry.url === undefined && entry.authHeader === undefined))
61
71
  return provider;
62
72
  const url = entry.url ?? ("url" in provider ? provider.url : undefined);
73
+ // A Gemini endpoint on the same key and the same provider. The Google adapter is an ordinary
74
+ // `google` provider now, so the model is folded into one here rather than growing a fourth
75
+ // adapter: the key moves from the provider's headers to `apiKey` (its convention on this wire is
76
+ // `x-goog-api-key`, never a bearer), and every header that is not the auth one travels along.
77
+ if (entry.wire === "google") {
78
+ const { key, kept } = credentialIn("headers" in provider && provider.headers ? provider.headers : {});
79
+ const apiKey = "apiKey" in provider && typeof provider.apiKey === "string" ? provider.apiKey : key;
80
+ const sessionHeader = "sessionHeader" in provider ? provider.sessionHeader : undefined;
81
+ return {
82
+ type: "google",
83
+ auth: "api-key",
84
+ ...(apiKey !== undefined ? { apiKey } : {}),
85
+ url: url ?? "",
86
+ ...(sessionHeader ? { sessionHeader } : {}),
87
+ ...(Object.keys(kept).length > 0 ? { headers: kept } : {}),
88
+ ...("models" in provider && provider.models ? { models: provider.models } : {}),
89
+ ...("identity" in provider && provider.identity !== undefined ? { identity: provider.identity } : {}),
90
+ ...("instructionsAppend" in provider && provider.instructionsAppend ? { instructionsAppend: provider.instructionsAppend } : {}),
91
+ ...("limits" in provider && provider.limits !== undefined ? { limits: provider.limits } : {}),
92
+ ...("preset" in provider && provider.preset ? { preset: provider.preset } : {}),
93
+ };
94
+ }
63
95
  const headers = entry.authHeader
64
96
  ? reauthorized("headers" in provider ? provider.headers : undefined, entry.authHeader)
65
97
  : ("headers" in provider ? provider.headers : undefined);
@@ -81,8 +113,9 @@ export function providerFor(provider, model) {
81
113
  /**
82
114
  * The Anthropic endpoint a web search for `model` runs on, when the provider's preset measured that
83
115
  * its plan searches there although the model is spoken to on another wire (`anthropicSearch`).
84
- * OpenCode Go's DeepSeek models answer on Chat Completions, which has no search, while the same key
85
- * on the plan's Anthropic endpoint runs the server tool (2026-10-07).
116
+ * No preset lists a model today: OpenCode Go's, once believed to search for DeepSeek on its
117
+ * Anthropic endpoint, never did (corrected 2026-10-21), so the search is refused there rather
118
+ * than fabricated. DeepSeek's search works only on DeepSeek's own API.
86
119
  */
87
120
  export function searchProviderFor(provider, model) {
88
121
  const presetId = provider && "preset" in provider ? provider.preset : undefined;
@@ -224,7 +257,7 @@ export function headerValueProblem(value) {
224
257
  return null;
225
258
  }
226
259
  /** Named once because three provider kinds report it, and a drifting copy would describe the wrong shape. */
227
- const MODELS_SHAPE = 'models must be entries with a string id and optional name, effortLevels, url, wire ("chat" | "responses" | "anthropic") and authHeader ("x-api-key" | "authorization-bearer")';
260
+ const MODELS_SHAPE = 'models must be entries with a string id and optional name, effortLevels, url, wire ("chat" | "responses" | "anthropic" | "google") and authHeader ("x-api-key" | "authorization-bearer")';
228
261
  function validModels(models) {
229
262
  return Array.isArray(models) && models.every((model) => {
230
263
  if (model === null || typeof model !== "object")
@@ -238,7 +271,7 @@ function validModels(models) {
238
271
  return false;
239
272
  // A model's own wire, endpoint and auth convention: wrong here and the request goes out in the
240
273
  // wrong shape to the wrong path, which reads as a provider that rejects a working key.
241
- if (m.wire !== undefined && m.wire !== "chat" && m.wire !== "responses" && m.wire !== "anthropic")
274
+ if (m.wire !== undefined && m.wire !== "chat" && m.wire !== "responses" && m.wire !== "anthropic" && m.wire !== "google")
242
275
  return false;
243
276
  if (m.url !== undefined && (typeof m.url !== "string" || !/^https?:\/\//.test(m.url)))
244
277
  return false;
@@ -331,6 +364,8 @@ function foldSplitProviders(c) {
331
364
  }
332
365
  export function validate(c) {
333
366
  const errors = [];
367
+ if (c.autoUpdate !== undefined && typeof c.autoUpdate !== "boolean")
368
+ errors.push("autoUpdate must be true or false");
334
369
  if (c.autoModeClassifier !== undefined) {
335
370
  const target = c.autoModeClassifier;
336
371
  if (!target || typeof target !== "object")
@@ -347,6 +382,23 @@ export function validate(c) {
347
382
  errors.push("autoModeClassifier: effort must be a string");
348
383
  }
349
384
  }
385
+ if (c.webSearch !== undefined) {
386
+ // Hand-edited config.json reaches here as whatever JSON held, not the declared type.
387
+ const target = c.webSearch;
388
+ if (!target || typeof target !== "object" || Array.isArray(target))
389
+ errors.push("webSearch must be an object");
390
+ else {
391
+ if (typeof target.provider !== "string" || !target.provider.trim())
392
+ errors.push("webSearch: missing provider");
393
+ else if (!c.providers[target.provider])
394
+ errors.push(`webSearch: unknown provider "${target.provider}"`);
395
+ if (typeof target.model !== "string" || !target.model.trim())
396
+ errors.push("webSearch: missing model");
397
+ if (target.maxResults !== undefined && (!Number.isInteger(target.maxResults) || target.maxResults <= 0)) {
398
+ errors.push("webSearch: maxResults must be a positive integer");
399
+ }
400
+ }
401
+ }
350
402
  for (const [alias, r] of Object.entries(c.routes)) {
351
403
  if (!c.providers[r.provider])
352
404
  errors.push(`route ${alias}: unknown provider "${r.provider}"`);
@@ -481,6 +533,13 @@ export function validate(c) {
481
533
  errors.push(`provider ${name}: url must start with http:// or https://`);
482
534
  if (p.defaultEffort !== undefined && typeof p.defaultEffort !== "string")
483
535
  errors.push(`provider ${name}: defaultEffort must be a string`);
536
+ if (p.sessionHeader !== undefined && (typeof p.sessionHeader !== "string" || !p.sessionHeader))
537
+ errors.push(`provider ${name}: sessionHeader must be a non-empty string`);
538
+ if (p.headers !== undefined && (!p.headers || typeof p.headers !== "object" || Array.isArray(p.headers) || Object.values(p.headers).some((value) => typeof value !== "string"))) {
539
+ errors.push(`provider ${name}: headers must be a string record`);
540
+ }
541
+ if (p.preset !== undefined && typeof p.preset !== "string")
542
+ errors.push(`provider ${name}: preset must be a string`);
484
543
  if (p.models !== undefined && !validModels(p.models)) {
485
544
  errors.push(`provider ${name}: ${MODELS_SHAPE}`);
486
545
  }
@@ -11,6 +11,8 @@ import { ingressModels } from "./ingress/models.js";
11
11
  import { ConfigStore, awakeSettings, configPath, homeDir, terminateHosts, tetherSettings } from "./config.js";
12
12
  import { AwakeController, execRunner } from "./awake.js";
13
13
  import { TetherController } from "./tether.js";
14
+ import { AutoUpdater } from "./autoupdate.js";
15
+ import { runCli, startCliDetached } from "./cli-control.js";
14
16
  import { powerPlatform } from "./power.js";
15
17
  import { defaultAgentDir, syncAgentFiles } from "./agents.js";
16
18
  import { CertStore } from "./certs.js";
@@ -125,6 +127,10 @@ const activity = () => {
125
127
  ...(lastEndedAt !== undefined ? { lastEndedAt } : {}),
126
128
  };
127
129
  };
130
+ const autoUpdater = new AutoUpdater({
131
+ home, version: VERSION, enabled: () => store.get().autoUpdate !== false,
132
+ activity, log, runCli, startCliDetached,
133
+ });
128
134
  const awake = new AwakeController({
129
135
  platform: process.platform,
130
136
  run: powerRunner,
@@ -158,6 +164,7 @@ function beginDrain(reason) {
158
164
  if (draining)
159
165
  return;
160
166
  draining = true;
167
+ autoUpdater.stop();
161
168
  log.info(`${reason}: draining, proxy=${proxy.stats.messagesInFlight} ingress=${ingress.stats.messagesInFlight} model calls`);
162
169
  let lastReported = -1;
163
170
  // Each drain() closes the shared hold gate first (hold.ts): a call held for a network switch is
@@ -223,6 +230,7 @@ proxy
223
230
  // (2026-09-20). Ask once now, after listen(), and never wait for the answer.
224
231
  proxy.chatgptRefreshAll();
225
232
  const adminDeps = {
233
+ autoUpdater,
226
234
  config: () => store.get(),
227
235
  configFile: configPath(),
228
236
  log: log,
@@ -253,7 +261,7 @@ proxy
253
261
  claudeModels: () => proxy.claudeModels.list(),
254
262
  opencodeUsage: (name) => proxy.opencodeLimits(name),
255
263
  grokUsage: (name) => proxy.grokLimits(name),
256
- image: (req, signal) => proxy.generateImage(req, signal),
264
+ image: (req, signal, meta) => proxy.generateImage(req, signal, meta),
257
265
  modelCatalog: () => loadModelCatalog(),
258
266
  shutdown: () => beginDrain("shutdown requested"),
259
267
  remote: {
@@ -277,6 +285,8 @@ proxy
277
285
  // The first awake tick is the reconcile: a bit a previous process set is kept or cleared now.
278
286
  void awake.start().catch((e) => log.warn(`awake: ${e.message}`));
279
287
  tether.start();
288
+ if (!draining)
289
+ autoUpdater.start();
280
290
  setImmediate(premintLeaves);
281
291
  // A minute in, once start-up traffic has settled: first the catalogue's context windows are
282
292
  // written into models already saved without one (a one-shot pass), then a few one-token requests
@@ -52,7 +52,31 @@ function narrowThinking(entry, levels) {
52
52
  }
53
53
  /** Resolve aliases/routes before advertising an entry; a chat-looking alias can target a judge. */
54
54
  export function chatPickerModels(cfg) {
55
- return cfg.cli.extraModels.filter(entry => !isJudgeOnlyModel(entry.model) && !isJudgeOnlyModel(resolve(entry.model, {}, cfg)?.model ?? cfg.aliases[entry.model.split("@")[0]] ?? ""));
55
+ return cfg.cli.extraModels
56
+ .filter(entry => !isJudgeOnlyModel(entry.model) && !isJudgeOnlyModel(resolve(entry.model, {}, cfg)?.model ?? cfg.aliases[entry.model.split("@")[0]] ?? ""))
57
+ .map(entry => {
58
+ if (entry.contextWindow)
59
+ return entry;
60
+ const window = providerModelWindow(cfg, entry.model);
61
+ return window ? { ...entry, contextWindow: window } : entry;
62
+ });
63
+ }
64
+ /**
65
+ * The window a provider declared for `model` (its `/models` context length, or one set by hand),
66
+ * by id without an `@effort` suffix or through an alias. A picker entry saved without a window of
67
+ * its own used to fall to `cli.autoCompactWindow` although its provider knew better: a 1M MiniMax
68
+ * subagent compacted twice at ~240K (2026-10-21).
69
+ */
70
+ export function providerModelWindow(cfg, model) {
71
+ const base = model.split("@")[0];
72
+ const ids = new Set([base, cfg.aliases[base] ?? base]);
73
+ for (const provider of Object.values(cfg.providers)) {
74
+ for (const entry of ("models" in provider && provider.models) || []) {
75
+ if (ids.has(entry.id) && typeof entry.contextWindow === "number" && entry.contextWindow > 0)
76
+ return entry.contextWindow;
77
+ }
78
+ }
79
+ return undefined;
56
80
  }
57
81
  /**
58
82
  * Mutates `json` in place; returns what was done for logging. `effortFor` names the levels a model
@@ -176,13 +176,14 @@ export const PRESETS = [
176
176
  },
177
177
  // Docs: https://opencode.ai/docs/go/
178
178
  //
179
- // One subscription, one key, one `/models` catalog, three endpoints by which wire each model
179
+ // One subscription, one key, one `/models` catalog, four endpoint shapes by which wire each model
180
180
  // speaks: `/responses` for Muse Spark, Grok and GPT; `/chat/completions` for GLM, Kimi, DeepSeek,
181
- // LongCat and MiMo; `/messages` (Anthropic) for MiniMax and Qwen. This used to be three presets
182
- // because a provider carried one url and one wire; now a model carries its own wire, url and auth
183
- // header (`ProviderModel`), so the account is one preset and `providerFor` folds the override in
184
- // when the model is known. The provider-level values below are the Responses base — which is where
185
- // Muse Spark lives, and the wire a model with no override gets.
181
+ // LongCat and MiMo; `/messages` (Anthropic) for MiniMax and Qwen; and Gemini 4 Argon on Google's
182
+ // `generateContent` under `/inference/go/google`. This used to be three presets because a provider
183
+ // carried one url and one wire; now a model carries its own wire, url and auth header
184
+ // (`ProviderModel`), so the account is one preset and `providerFor` folds the override in when the
185
+ // model is known. The provider-level values below are the Responses base — which is where Muse
186
+ // Spark lives, and the wire a model with no override gets.
186
187
  //
187
188
  // `x-opencode-session` is not optional at all: the vendor answers 400 `MissingSessionID` without
188
189
  // it — "cannot be routed efficiently" — so it gates the request rather than just the cache.
@@ -255,6 +256,12 @@ export const PRESETS = [
255
256
  { id: "qwen3.7-max", name: "Qwen3.7 Max", wire: "anthropic", url: "https://opencode.ai/zen/go", authHeader: "x-api-key" },
256
257
  { id: "qwen3.7-plus", name: "Qwen3.7 Plus", wire: "anthropic", url: "https://opencode.ai/zen/go", authHeader: "x-api-key" },
257
258
  { id: "qwen3.6-plus", name: "Qwen3.6 Plus", wire: "anthropic", url: "https://opencode.ai/zen/go", authHeader: "x-api-key" },
259
+ // Gemini, on Google's own generateContent wire rather than either OpenAI one. Its endpoint is
260
+ // a different origin path and takes the key on `x-goog-api-key`; the docs give the model's
261
+ // full URL as https://opencode.ai/inference/go/google/v1beta/models/<id> (read 2026-10-21).
262
+ // `providerFor` folds it into an ordinary google provider, and the transport appends
263
+ // `/v1beta/models/<id>:generateContent` to this base (providers/google/transport.ts).
264
+ { id: "gemini-4-argon-contributor", name: "Gemini 4 Argon (Contributor)", wire: "google", url: "https://opencode.ai/inference/go/google" },
258
265
  ],
259
266
  // Measured 2026-09-18 against the live endpoint, one effort at a time: none, minimal, low,
260
267
  // medium, high and xhigh are accepted; **max and ultra are refused** with invalid_request_error.
@@ -263,13 +270,14 @@ export const PRESETS = [
263
270
  // to the Responses models above; the Chat and Anthropic ones carry their own empty ladder.
264
271
  effortLevels: ["none", "minimal", "low", "medium", "high", "xhigh"],
265
272
  thinking: "none",
266
- // The DeepSeek models are served on Chat Completions, which ignores every search option, but
267
- // the same key on the Anthropic endpoint runs `web_search_20250305` for them: Claude Code's own
268
- // side request came back with a `web_search_tool_result` of ten real pages and
269
- // `web_search_requests: 1` (measured 2026-10-07; a bearer header there answers "Missing API
270
- // key"). Kimi K3, MiniMax M3 and Qwen3.8 Flash take the tool on that endpoint and never search;
271
- // GLM and MiMo are not served there at all.
272
- anthropicSearch: { url: "https://opencode.ai/zen/go", authHeader: "x-api-key", models: ["deepseek-v4.1-flash", "deepseek-v4-flash", "deepseek-v4-pro"] },
273
+ // No model on this plan searches. The DeepSeek models are served on Chat Completions, which
274
+ // ignores every search option, and DeepSeek's search has only ever worked on DeepSeek's own API.
275
+ // A 2026-10-07 note here claimed the plan's Anthropic endpoint ran `web_search_20250305` for
276
+ // them; it was wrong — the user reports it never searched, and on 2026-10-21 the relay answered
277
+ // HTTP 200 with no `web_search_tool_result` hits ("opencode-go web search: no results
278
+ // returned"). The list stays empty so `/api/status` offers none of them as searchable and a
279
+ // search routed there is refused instead of fabricated.
280
+ anthropicSearch: { url: "https://opencode.ai/zen/go", authHeader: "x-api-key", models: [] },
273
281
  verified: true,
274
282
  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.",
275
283
  docsUrl: "https://opencode.ai/docs/go/",
@@ -503,6 +503,7 @@ export class ChatGptAdapter {
503
503
  const decoder = new TextDecoder();
504
504
  let image;
505
505
  let text = "";
506
+ let usage;
506
507
  try {
507
508
  for (;;) {
508
509
  const { done, value } = await reader.read();
@@ -516,6 +517,15 @@ export class ChatGptAdapter {
516
517
  if (item?.type === "image_generation_call" && typeof item.result === "string" && item.result)
517
518
  image = item;
518
519
  }
520
+ if (event.type === "response.completed") {
521
+ // The same conversion the translated turns use: `input_tokens` is what the prompt read,
522
+ // `cached_tokens` the part the backend served from its cache.
523
+ const u = event.response?.usage;
524
+ if (u) {
525
+ const cached = u.input_tokens_details?.cached_tokens ?? 0;
526
+ usage = { input: (u.input_tokens ?? 0) - cached, cached, output: u.output_tokens ?? 0 };
527
+ }
528
+ }
519
529
  if (event.type === "error" || event.type === "response.failed") {
520
530
  const error = (event.error ?? event.response?.error);
521
531
  throw new Error(`ChatGPT image generation: ${String(error?.message ?? "backend error")}`);
@@ -539,6 +549,7 @@ export class ChatGptAdapter {
539
549
  ...(str(image.size) ? { size: str(image.size) } : {}),
540
550
  ...(str(image.quality) ? { quality: str(image.quality) } : {}),
541
551
  ...(str(image.revised_prompt) ? { revisedPrompt: str(image.revised_prompt) } : {}),
552
+ ...(usage ? { usage } : {}),
542
553
  };
543
554
  }
544
555
  /** One in-flight lookup shared by every caller (the status route and the startup refresh). */
@@ -407,7 +407,12 @@ export class GoogleAdapter {
407
407
  // before usage arrives, so announce an estimate and correct it in message_delta.
408
408
  const key = conversationKey(json);
409
409
  const startInput = Math.max(estimateTokens(json), this.lastInputByKey.get(key) ?? 0);
410
- const transport = createTransport(this.cfg.auth, { ...(this.cfg.apiKey ? { apiKey: this.cfg.apiKey } : {}), ...(this.cfg.url ? { url: this.cfg.url } : {}) });
410
+ const transport = createTransport(this.cfg.auth, {
411
+ ...(this.cfg.apiKey ? { apiKey: this.cfg.apiKey } : {}),
412
+ ...(this.cfg.url ? { url: this.cfg.url } : {}),
413
+ ...(this.cfg.sessionHeader ? { sessionHeader: this.cfg.sessionHeader } : {}),
414
+ ...(this.cfg.headers ? { headers: this.cfg.headers } : {}),
415
+ });
411
416
  if (this.cfg.auth === "antigravity") {
412
417
  return this.handleAntigravity(res, json, upstreamRequest, transport, model, useEffort, wantStream, key, startInput, host, signatures);
413
418
  }
@@ -419,7 +424,7 @@ export class GoogleAdapter {
419
424
  }
420
425
  let built;
421
426
  try {
422
- built = transport.build(upstreamRequest, { model, stream: wantStream });
427
+ built = transport.build(upstreamRequest, { model, stream: wantStream, conversationKey: key });
423
428
  }
424
429
  catch (error) {
425
430
  const out = anthropicError(400, "invalid_request_error", error.message);
@@ -11,6 +11,7 @@
11
11
  // (src/adapters/google.ts:1056-1160, src/adapters/google-antigravity-wire.ts) and is **not measured
12
12
  // live** — this environment has no Antigravity subscription. See docs/ARCHITECTURE.md §4d.
13
13
  import { antigravityRequestId, antigravitySessionId, antigravityUserAgent, GOOGLE_OAUTH, resolveAntigravityWireModel } from "./antigravity.js";
14
+ import { VERSION } from "../../version.js";
14
15
  const DEFAULT_BASE = "https://generativelanguage.googleapis.com";
15
16
  /** The AI Studio key: the provider's own, or the environment names Google's own SDKs read. Never logged. */
16
17
  export function resolveApiKey(configured) {
@@ -98,6 +99,12 @@ export function createTransport(mode, opts) {
98
99
  }
99
100
  const base = googleBaseUrl(opts.url);
100
101
  const apiKey = resolveApiKey(opts.apiKey);
102
+ // A provider that asks for a conversation header is not AI Studio; it is a relay such as OpenCode
103
+ // Go, which also asks clients to name themselves with a distinctive User-Agent rather than an
104
+ // SDK/library one (https://opencode.ai/docs/go/, read 2026-10-21). Plain AI Studio requests keep
105
+ // the client's own User-Agent, so nothing about this changes for them.
106
+ const sessionHeader = opts.sessionHeader?.trim() || undefined;
107
+ const userAgent = sessionHeader ? `clauderipple/${VERSION}` : undefined;
101
108
  return {
102
109
  kind: "api-key",
103
110
  build(req, o) {
@@ -107,7 +114,15 @@ export function createTransport(mode, opts) {
107
114
  const query = o.stream ? "?alt=sse" : "";
108
115
  return {
109
116
  url: `${base}/v1beta/models/${encodeURIComponent(o.model)}:${method}${query}`,
110
- headers: { "content-type": "application/json", accept: o.stream ? "text/event-stream" : "application/json", "x-goog-api-key": apiKey },
117
+ headers: {
118
+ "content-type": "application/json",
119
+ accept: o.stream ? "text/event-stream" : "application/json",
120
+ ...(userAgent ? { "user-agent": userAgent } : {}),
121
+ ...(sessionHeader && o.conversationKey ? { [sessionHeader]: o.conversationKey } : {}),
122
+ ...(opts.headers ?? {}),
123
+ // Kept after the vendor's own headers so nothing can shadow the credential on this wire.
124
+ "x-goog-api-key": apiKey,
125
+ },
111
126
  body: JSON.stringify(req),
112
127
  };
113
128
  },
@@ -36,7 +36,7 @@ import { conversationKey } from "./providers/chatgpt/translate.js";
36
36
  import { providerFor, searchProviderFor, terminateHosts } from "./config.js";
37
37
  import { chatPickerModels, injectPickerModels, isBootstrapPath } from "./picker.js";
38
38
  import { effortLevelsForModel } from "./admin.js";
39
- import { ResponseUsageTap } from "./requestlog.js";
39
+ import { ResponseUsageTap, requestId } from "./requestlog.js";
40
40
  import { credentialHeaderValues, redactErrorText, redactHeaders } from "./redact.js";
41
41
  import { ClaudeAccountAuthPool } from "./providers/anthropic-account-pool.js";
42
42
  import { ClaudeModelCatalog } from "./providers/anthropic-models.js";
@@ -275,16 +275,56 @@ export class Proxy {
275
275
  /**
276
276
  * One image for `/api/image`, on the first chatgpt provider and the first model it declares. The
277
277
  * model only runs the tool call — the image comes from the backend's own image model — so which
278
- * one it is barely matters.
278
+ * one it is barely matters. The call is recorded like a proxied request, success or failure, so
279
+ * the dashboard's usage and request list show the subscription's image traffic.
279
280
  */
280
- generateImage(req, signal) {
281
+ async generateImage(req, signal, meta) {
281
282
  const first = Object.entries(this.deps.config().providers).find(([, p]) => p.type === "chatgpt");
282
283
  if (!first || first[1].type !== "chatgpt")
283
- return Promise.reject(new Error("image generation needs a ChatGPT provider; add one in the dashboard"));
284
+ throw new Error("image generation needs a ChatGPT provider; add one in the dashboard");
284
285
  const model = first[1].models?.[0]?.id;
285
286
  if (!model)
286
- return Promise.reject(new Error(`provider ${first[0]} declares no models`));
287
- return this.chatgpt(first[0], first[1]).generateImage(model, req, signal);
287
+ throw new Error(`provider ${first[0]} declares no models`);
288
+ const at = new Date().toISOString();
289
+ const started = Date.now();
290
+ try {
291
+ const image = await this.chatgpt(first[0], first[1]).generateImage(model, req, signal);
292
+ const record = {
293
+ kind: "image",
294
+ source: model,
295
+ target: "image",
296
+ provider: first[0],
297
+ stream: false,
298
+ id: requestId(),
299
+ at,
300
+ ms: Date.now() - started,
301
+ ok: true,
302
+ status: 200,
303
+ ...(image.usage ? { usage: image.usage } : {}),
304
+ note: `${image.size ?? "?"} ${image.quality ?? "?"} ${image.format}, ${req.images?.length ?? 0} reference(s)`,
305
+ ...(meta?.session ? { session: meta.session } : {}),
306
+ };
307
+ this.deps.requests.add(record);
308
+ return image;
309
+ }
310
+ catch (e) {
311
+ const record = {
312
+ kind: "image",
313
+ source: model,
314
+ target: "image",
315
+ provider: first[0],
316
+ stream: false,
317
+ id: requestId(),
318
+ at,
319
+ ms: Date.now() - started,
320
+ ok: false,
321
+ status: signal?.aborted ? "aborted" : 502,
322
+ note: e.message.slice(0, 300),
323
+ ...(meta?.session ? { session: meta.session } : {}),
324
+ };
325
+ this.deps.requests.add(record);
326
+ throw e;
327
+ }
288
328
  }
289
329
  /** Dashboard action: put one resting ChatGPT account back into rotation now. */
290
330
  chatgptClearCooldown(ownerId) {
@@ -10,7 +10,7 @@ function isRecord(value) {
10
10
  return false;
11
11
  const r = value;
12
12
  return typeof r.id === "string" && typeof r.at === "string" && typeof r.ms === "number" && typeof r.ok === "boolean" &&
13
- (r.kind === "messages" || r.kind === "count_tokens" || r.kind === "other") && typeof r.source === "string" &&
13
+ (r.kind === "messages" || r.kind === "count_tokens" || r.kind === "other" || r.kind === "image") && typeof r.source === "string" &&
14
14
  typeof r.target === "string" && typeof r.provider === "string" && typeof r.stream === "boolean";
15
15
  }
16
16
  /** Bounded request history, persisted as JSONL so a router restart preserves recent entries. */
@@ -63,12 +63,13 @@ export class RequestLog {
63
63
  }
64
64
  list(n, filter = {}) {
65
65
  const limit = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;
66
+ const kind = filter.kind === undefined ? undefined : Array.isArray(filter.kind) ? filter.kind : [filter.kind];
66
67
  const out = [];
67
68
  for (let i = this.records.length - 1; i >= 0 && out.length < limit; i--) {
68
69
  const record = this.records[i];
69
70
  if (filter.provider && record.provider !== filter.provider)
70
71
  continue;
71
- if (filter.kind && record.kind !== filter.kind)
72
+ if (kind && !kind.includes(record.kind))
72
73
  continue;
73
74
  if (filter.session && record.session !== filter.session)
74
75
  continue;
@@ -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.11.0";
8
+ export const VERSION = "0.12.0";
@@ -296,8 +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; 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.
299
+ // (measured 2026-09-18; its Anthropic endpoint returns no hits either — 2026-10-21). Treat
300
+ // that as the failure it is.
301
301
  const deduped = dedupeHits(hits).slice(0, opts.maxResults ?? 10);
302
302
  if (deduped.length === 0)
303
303
  throw new Error(`${opts.name} web search: no results returned`);