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.
- package/CHANGELOG.md +42 -0
- package/README.es.md +48 -0
- package/README.ja.md +42 -0
- package/README.ko.md +39 -0
- package/README.md +44 -0
- package/README.pt-BR.md +46 -0
- package/README.zh-CN.md +40 -0
- package/dist/app/dist/claude-desktop.js +31 -0
- package/dist/app/dist/main.js +127 -8
- package/dist/app/dist/status-model.js +12 -0
- package/dist/app/dist/update-model.js +32 -0
- package/dist/app/dist/window.js +10 -3
- package/dist/app/package.json +1 -1
- package/dist/cli/src/index.js +5 -1
- package/dist/mod/hooks/i18n.ts +6 -0
- package/dist/mod/hooks/modes.ts +15 -8
- package/dist/mod/hooks/register.tsx +67 -19
- package/dist/mod/hooks/settings.js +37 -2
- package/dist/mod/hooks/settings.ts +31 -3
- package/dist/mod/hooks/ui/agents.tsx +1 -1
- package/dist/mod/hooks/ui/art.ts +8 -3
- package/dist/mod/hooks/ui/band.tsx +6 -2
- package/dist/mod/hooks/ui/flat.ts +11 -8
- package/dist/mod/hooks/ui/look.ts +3 -1
- package/dist/mod/hooks/ui/view.ts +2 -0
- package/dist/mod/hooks/workers.ts +8 -1
- package/dist/mod/tests/delegation.test.tsx +50 -0
- package/dist/mod/tests/register.test.tsx +138 -1
- package/dist/mod/tests/ui.test.tsx +6 -3
- package/dist/mod/types/index.d.ts +14 -0
- package/dist/router/src/admin.js +41 -76
- package/dist/router/src/autoupdate.js +299 -0
- package/dist/router/src/bootstrap.js +3 -2
- package/dist/router/src/cli-control.js +65 -0
- package/dist/router/src/config.js +70 -11
- package/dist/router/src/index.js +11 -1
- package/dist/router/src/picker.js +25 -1
- package/dist/router/src/presets.js +21 -13
- package/dist/router/src/providers/chatgpt/index.js +11 -0
- package/dist/router/src/providers/google/index.js +7 -2
- package/dist/router/src/providers/google/transport.js +16 -1
- package/dist/router/src/proxy.js +46 -6
- package/dist/router/src/requestlog.js +3 -2
- package/dist/router/src/version.js +1 -1
- package/dist/router/src/websearch.js +2 -2
- package/dist/ui/app.js +247 -42
- package/dist/ui/i18n.js +154 -18
- package/dist/ui/style.css +21 -0
- package/docs/ARCHITECTURE.md +61 -11
- 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
|
|
12
|
+
* The provider's own key wherever its headers carry it, and every header that is not that one.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
|
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
|
-
*
|
|
85
|
-
*
|
|
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
|
}
|
package/dist/router/src/index.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
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
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
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, {
|
|
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: {
|
|
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
|
},
|
package/dist/router/src/proxy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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 (
|
|
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.
|
|
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;
|
|
300
|
-
//
|
|
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`);
|