clauderipple 0.2.0 → 0.3.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 +76 -0
- package/README.ko.md +48 -4
- package/README.md +58 -4
- package/dist/cli/src/claude-auth.js +3 -2
- package/dist/cli/src/codex.js +20 -1
- package/dist/cli/src/hooks/agent-title.js +1 -1
- package/dist/cli/src/index.js +4 -4
- package/dist/cli/src/schtasks.js +43 -1
- package/dist/cli/src/settings.js +73 -6
- package/dist/router/src/admin.js +489 -56
- package/dist/router/src/agents.js +250 -0
- package/dist/router/src/bootstrap.js +24 -8
- package/dist/router/src/capabilities.js +214 -0
- package/dist/router/src/compat.js +5 -1
- package/dist/router/src/config.js +264 -11
- package/dist/router/src/index.js +14 -1
- package/dist/router/src/ingress/server.js +24 -14
- package/dist/router/src/picker.js +14 -6
- package/dist/router/src/pool.js +233 -0
- package/dist/router/src/presets.js +156 -1
- package/dist/router/src/providers/anthropic-account-pool.js +139 -0
- package/dist/router/src/providers/anthropic-accounts.js +281 -0
- package/dist/router/src/providers/chatgpt/catalog.js +97 -0
- package/dist/router/src/providers/chatgpt/index.js +343 -12
- package/dist/router/src/providers/chatgpt/sse.js +4 -0
- package/dist/router/src/providers/chatgpt/translate.js +156 -14
- package/dist/router/src/providers/claude-oauth.js +61 -19
- package/dist/router/src/providers/openai/index.js +55 -11
- package/dist/router/src/providers/openai/translate.js +82 -14
- package/dist/router/src/providers/retry.js +88 -0
- package/dist/router/src/proxy.js +697 -82
- package/dist/router/src/requestlog.js +5 -2
- package/dist/router/src/routing.js +151 -17
- package/dist/router/src/version.js +1 -1
- package/dist/router/src/websearch.js +307 -0
- package/dist/router/src/x509.js +7 -2
- package/dist/ui/app.js +740 -160
- package/dist/ui/i18n.js +14 -6
- package/dist/ui/index.html +18 -5
- package/dist/ui/presets-fallback.js +2 -0
- package/dist/ui/style.css +133 -9
- package/docs/ARCHITECTURE.md +381 -20
- package/package.json +5 -1
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
const DEFAULT_MAX_BYTES = 20 * 1024 * 1024;
|
|
6
6
|
const MAX_CARRY = 64 * 1024;
|
|
7
7
|
function emptyBucket() {
|
|
8
|
-
return { count: 0, ok: 0, failed: 0, input: 0, cached: 0, output: 0, cacheHitPercent: 0, avgMs: 0 };
|
|
8
|
+
return { count: 0, ok: 0, failed: 0, input: 0, cached: 0, cacheWrite: 0, output: 0, cacheHitPercent: 0, avgMs: 0 };
|
|
9
9
|
}
|
|
10
10
|
function isRecord(value) {
|
|
11
11
|
if (!value || typeof value !== "object")
|
|
@@ -75,8 +75,11 @@ export class RequestLog {
|
|
|
75
75
|
if (record.usage) {
|
|
76
76
|
bucket.input += record.usage.input;
|
|
77
77
|
bucket.cached += record.usage.cached;
|
|
78
|
+
bucket.cacheWrite += record.usage.cacheWrite ?? 0;
|
|
78
79
|
bucket.output += record.usage.output;
|
|
79
|
-
|
|
80
|
+
// A cache write is input the model read fresh, so it counts against the hit rate. Leaving it
|
|
81
|
+
// out read a turn that wrote 2,218 tokens and uncached 2 as a 100% hit (2026-09-23).
|
|
82
|
+
cacheBases.set(bucket, (cacheBases.get(bucket) ?? 0) + record.usage.input + record.usage.cached + (record.usage.cacheWrite ?? 0));
|
|
80
83
|
}
|
|
81
84
|
};
|
|
82
85
|
for (const record of this.records) {
|
|
@@ -3,11 +3,19 @@
|
|
|
3
3
|
// "claude-opus-4-8" → routes["claude-opus-4-8"] (picker-slot alias)
|
|
4
4
|
// "gpt-5.6-sol" → direct rule by prefix, model unchanged
|
|
5
5
|
// "gpt-5.6-sol@medium" → same, effort forced to medium
|
|
6
|
-
// "
|
|
7
|
-
//
|
|
6
|
+
// "kimi-k3" → the one provider whose `models` carries it, model unchanged
|
|
7
|
+
// (only when no rule matched and exactly one provider claims it)
|
|
8
|
+
// "[[ripple: sol@xhigh]]" or "[[gpt: sol@xhigh]]" at the very top of a user
|
|
9
|
+
// message (a subagent prompt) overrides model/effort; the marker's model is then
|
|
10
|
+
// placed by its own prefix rule or its one declaring provider. A slot ignores it.
|
|
11
|
+
// Quoted anywhere else, a marker is prose (issue #13).
|
|
8
12
|
const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
// Anchored: the marker is honoured only at the very top of a user message. A subagent prompt starts
|
|
14
|
+
// with it; a compaction summary that *quotes* one does not, and on 2026-09-20 such a quote in a
|
|
15
|
+
// GPT-6 Astra session's summary re-routed the whole session to `muse` on ChatGPT (400 on every turn).
|
|
16
|
+
const MARKER = /^\s*\[\[\s*(?:ripple|gpt)\s*:\s*([A-Za-z0-9.\-]+)\s*(?:@\s*([A-Za-z]+))?\s*\]\]/;
|
|
17
|
+
/** The raw `[[ripple: <name>@<effort>]]` the body carries, name lowercased. Does not resolve it. */
|
|
18
|
+
function scanMarker(body) {
|
|
11
19
|
const messages = body?.messages;
|
|
12
20
|
if (!Array.isArray(messages))
|
|
13
21
|
return null;
|
|
@@ -27,11 +35,18 @@ export function markerOverride(body, aliases) {
|
|
|
27
35
|
if (hit) {
|
|
28
36
|
const name = hit[1].toLowerCase();
|
|
29
37
|
const effort = hit[2]?.toLowerCase();
|
|
30
|
-
return effort ? {
|
|
38
|
+
return effort ? { name, effort } : { name };
|
|
31
39
|
}
|
|
32
40
|
}
|
|
33
41
|
return null;
|
|
34
42
|
}
|
|
43
|
+
export function markerOverride(body, aliases) {
|
|
44
|
+
const hit = scanMarker(body);
|
|
45
|
+
if (!hit)
|
|
46
|
+
return null;
|
|
47
|
+
const model = aliases[hit.name] ?? hit.name;
|
|
48
|
+
return hit.effort ? { model, effort: hit.effort } : { model };
|
|
49
|
+
}
|
|
35
50
|
export function resolve(model, body, cfg) {
|
|
36
51
|
if (typeof model !== "string")
|
|
37
52
|
return null;
|
|
@@ -42,10 +57,13 @@ export function resolve(model, body, cfg) {
|
|
|
42
57
|
base = model.slice(0, at);
|
|
43
58
|
effort = model.slice(at + 1) || undefined;
|
|
44
59
|
}
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
const ingressOnly = (name) =>
|
|
60
|
+
// Native Anthropic stays ingress-only unless the operator explicitly enables its OAuth account
|
|
61
|
+
// pool. That opt-in is what makes routing useful: the same Messages body goes to Anthropic under a
|
|
62
|
+
// selected account, with affinity and failover. Existing configs keep byte-for-byte passthrough.
|
|
63
|
+
const ingressOnly = (name) => {
|
|
64
|
+
const provider = cfg.providers[name];
|
|
65
|
+
return provider?.type === "anthropic" && !provider.accountPool;
|
|
66
|
+
};
|
|
49
67
|
const direct = cfg.direct.find((d) => base.startsWith(d.prefix));
|
|
50
68
|
if (direct && ingressOnly(direct.provider))
|
|
51
69
|
return null;
|
|
@@ -53,19 +71,135 @@ export function resolve(model, body, cfg) {
|
|
|
53
71
|
const ov = markerOverride(body, cfg.aliases);
|
|
54
72
|
const finalModel = ov?.model ?? base;
|
|
55
73
|
const finalEffort = ov?.effort ?? effort;
|
|
56
|
-
|
|
74
|
+
// The provider follows the model the marker names, not the model the session runs on. A
|
|
75
|
+
// `gpt-6-astra` session delegating to `muse` used to keep `chatgpt` as the provider and send
|
|
76
|
+
// it `muse-spark-1.3-contributor`, which ChatGPT refuses (2026-09-20). The marker's model is
|
|
77
|
+
// placed the way any requested model is: its own prefix rule, else its one declaring provider.
|
|
78
|
+
if (finalModel !== base) {
|
|
79
|
+
const ownDirect = cfg.direct.find((d) => finalModel.startsWith(d.prefix));
|
|
80
|
+
const provider = ownDirect ? ownDirect.provider : soleOwner(finalModel, cfg);
|
|
81
|
+
if (!provider || ingressOnly(provider))
|
|
82
|
+
return null;
|
|
83
|
+
return { provider, model: finalModel, effort: finalEffort, tag: `${model}->${finalModel} (marker)` };
|
|
84
|
+
}
|
|
85
|
+
// The log says when a marker decided: `claude-sonnet-5->gpt-6-astra` with no hint of why cost a
|
|
86
|
+
// reporter twenty minutes (issue #13).
|
|
87
|
+
return { provider: direct.provider, model: finalModel, effort: finalEffort, tag: `${model}->${finalModel}${ov ? " (marker)" : ""}` };
|
|
57
88
|
}
|
|
58
89
|
// The app sends some slots with a dated id (`claude-haiku-4-5-20251001`) and others without
|
|
59
90
|
// (`claude-opus-5`), while the GUI only ever offers the undated form. Match either.
|
|
60
91
|
const route = cfg.routes[base] ?? cfg.routes[base.replace(/-\d{8}$/, "")];
|
|
61
|
-
if (
|
|
92
|
+
if (route) {
|
|
93
|
+
if (ingressOnly(route.provider))
|
|
94
|
+
return null;
|
|
95
|
+
return {
|
|
96
|
+
provider: route.provider,
|
|
97
|
+
model: route.model,
|
|
98
|
+
effort: effort ?? route.effort,
|
|
99
|
+
tag: `${model}->${route.model}`,
|
|
100
|
+
// Only a slot has somewhere else to go. A direct rule names one provider on purpose.
|
|
101
|
+
...(route.fallbacks?.length ? { fallbacks: route.fallbacks } : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// No rule names this model. A provider that carries it in its own `models` list names it just as
|
|
105
|
+
// plainly: that list is what the probe read from the vendor's `/models` and what the GUI ticked.
|
|
106
|
+
// Not using it was why ticking a model in the GUI did nothing until a `direct` rule was also
|
|
107
|
+
// written by hand, and why one config had eighteen of them (2026-09-19: seventeen of those
|
|
108
|
+
// eighteen are exactly this case). Rules keep their priority — this only fills the gap they
|
|
109
|
+
// leave, so nothing that routes today routes differently.
|
|
110
|
+
const ov = markerOverride(body, cfg.aliases);
|
|
111
|
+
const finalModel = ov?.model ?? base;
|
|
112
|
+
const owners = declaredBy(finalModel, cfg);
|
|
113
|
+
// Two providers offering the same id is a question only the operator can answer (one config has
|
|
114
|
+
// `deepseek-v4-pro` on both a direct DeepSeek mapping and OpenCode Go, and they are not the same
|
|
115
|
+
// deal). Guessing would route someone's traffic to a vendor they did not choose, so an ambiguous
|
|
116
|
+
// id keeps requiring the explicit rule it requires today.
|
|
117
|
+
if (owners.length !== 1)
|
|
62
118
|
return null;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
119
|
+
const owner = owners[0];
|
|
120
|
+
// An ingress-only provider declaring a model does not make it a target: it serves the OpenAI
|
|
121
|
+
// ingress with its own credentials (see `ingressOnly` above). This matters more here than
|
|
122
|
+
// anywhere else — a native `anthropic` provider lists the Claude models, and auto-routing those
|
|
123
|
+
// would hijack every Claude request in the session into a provider that answers 400 (ARCHITECTURE
|
|
124
|
+
// §5). Counting it as an owner rather than skipping it is deliberate: if some other provider also
|
|
125
|
+
// offered `claude-opus-5`, that is an ambiguity to be asked about, not a silent redirection of
|
|
126
|
+
// Claude traffic to a third party.
|
|
127
|
+
if (ingressOnly(owner))
|
|
128
|
+
return null;
|
|
129
|
+
return { provider: owner, model: finalModel, effort: ov?.effort ?? effort, tag: `${model}->${finalModel}${ov ? " (marker)" : ""}` };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Providers whose own `models` list carries this id. Dated and undated forms match each other, the
|
|
133
|
+
* same way a slot lookup does. Ingress-only providers are included on purpose — see the call site.
|
|
134
|
+
*/
|
|
135
|
+
/** The one provider declaring this id, or null when none or several do. */
|
|
136
|
+
function soleOwner(id, cfg) {
|
|
137
|
+
const owners = declaredBy(id, cfg);
|
|
138
|
+
return owners.length === 1 ? owners[0] : null;
|
|
139
|
+
}
|
|
140
|
+
export function declaredBy(id, cfg) {
|
|
141
|
+
const undated = id.replace(/-\d{8}$/, "");
|
|
142
|
+
const owners = [];
|
|
143
|
+
for (const [name, provider] of Object.entries(cfg.providers)) {
|
|
144
|
+
const models = provider.models;
|
|
145
|
+
if (!models?.length)
|
|
146
|
+
continue;
|
|
147
|
+
if (models.some((m) => m.id === id || m.id === undated || m.id.replace(/-\d{8}$/, "") === undated))
|
|
148
|
+
owners.push(name);
|
|
149
|
+
}
|
|
150
|
+
return owners;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Why `resolve` returned null, named, or null when the request is not ours to refuse.
|
|
154
|
+
*
|
|
155
|
+
* A model that resolves needs no explanation. A native Claude id is deliberately unrouted — it
|
|
156
|
+
* passes through to Anthropic — so it is never a refusal either. Everything else reaching here is a
|
|
157
|
+
* request that would otherwise be forwarded to Anthropic and come back 404, which is what happened
|
|
158
|
+
* to a whole session on 2026-09-20: an agent file named `deepseek` resolved (through a missing
|
|
159
|
+
* alias) to a model no provider declared, and `PASS` sent thirty of them to Anthropic.
|
|
160
|
+
*
|
|
161
|
+
* Pure: it decides and explains, it does not send.
|
|
162
|
+
*/
|
|
163
|
+
export function unroutableReason(model, body, cfg) {
|
|
164
|
+
if (typeof model !== "string")
|
|
165
|
+
return null;
|
|
166
|
+
if (model.startsWith("claude-"))
|
|
167
|
+
return null;
|
|
168
|
+
const at = model.indexOf("@");
|
|
169
|
+
const base = at > 0 ? model.slice(0, at) : model;
|
|
170
|
+
const marker = scanMarker(body);
|
|
171
|
+
const aliased = marker ? cfg.aliases[marker.name] : undefined;
|
|
172
|
+
const effective = aliased ?? (marker ? marker.name : base);
|
|
173
|
+
const hasDirect = (id) => cfg.direct.some((d) => id.startsWith(d.prefix));
|
|
174
|
+
// Mirror `resolve`. A prefix rule on the requested model honours the marker: the marker's model is
|
|
175
|
+
// then placed by its own prefix rule or its one declaring provider, and a null from `resolve` there
|
|
176
|
+
// is a real "nowhere to send this". Without a marker, a prefix rule or a slot on the requested model
|
|
177
|
+
// decided, and a null can only be the ingress-only drop — pass through, do not refuse. A slot ignores
|
|
178
|
+
// the marker entirely.
|
|
179
|
+
if (hasDirect(base)) {
|
|
180
|
+
if (effective === base || hasDirect(effective))
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
else if (cfg.routes[base] || cfg.routes[base.replace(/-\d{8}$/, "")]) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
const owners = declaredBy(effective, cfg);
|
|
187
|
+
if (owners.length > 1) {
|
|
188
|
+
return `"${effective}" is declared by two providers (${owners.join(", ")}); add a route or direct rule`;
|
|
189
|
+
}
|
|
190
|
+
if (owners.length === 1) {
|
|
191
|
+
const owner = owners[0];
|
|
192
|
+
const provider = cfg.providers[owner];
|
|
193
|
+
if (provider?.type === "anthropic" && !provider.accountPool)
|
|
194
|
+
return `provider "${owner}" is ingress-only`;
|
|
195
|
+
return null; // resolve() would have routed this; nothing to refuse.
|
|
196
|
+
}
|
|
197
|
+
// Nothing declares it. Say which name failed, and whether a marker introduced it.
|
|
198
|
+
if (marker && !aliased)
|
|
199
|
+
return `marker alias "${marker.name}" is not an alias and not an agent name`;
|
|
200
|
+
if (aliased)
|
|
201
|
+
return `marker alias "${marker.name}" resolves to "${aliased}", which no provider declares`;
|
|
202
|
+
return `no provider declares "${effective}"`;
|
|
69
203
|
}
|
|
70
204
|
/** Apply a resolution to a parsed Messages request body (mutates and returns it). */
|
|
71
205
|
export function rewriteBody(json, r, effortClamp) {
|
|
@@ -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.3.0";
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Web search for routed models.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code does not put `web_search` in the main request. `WebSearch` opens a separate side
|
|
4
|
+
// request — system prompt "You are an assistant for performing a web search tool use", one message
|
|
5
|
+
// "Perform a web search for the query: …", one Anthropic `web_search` server tool, `max_uses: 8` —
|
|
6
|
+
// and sends it to `ANTHROPIC_SMALL_FAST_MODEL` when that is set, otherwise to a built-in small
|
|
7
|
+
// model (measured 2026-09-17: an Opus session and a DeepSeek-routed session both landed on
|
|
8
|
+
// `claude-haiku-4-5`).
|
|
9
|
+
//
|
|
10
|
+
// That is why web search costs Anthropic quota no matter which provider answers the session: without
|
|
11
|
+
// a Claude subscription a user on DeepSeek or GPT cannot search at all, which makes a routed setup
|
|
12
|
+
// only half routed. This module answers that side request from the provider's own hosted search —
|
|
13
|
+
// the one the user already pays for.
|
|
14
|
+
//
|
|
15
|
+
// The side request needs no env var to reach us: we terminate TLS for api.anthropic.com, so every
|
|
16
|
+
// request passes through, and the interception happens before routing. Its model id is therefore
|
|
17
|
+
// irrelevant, which is just as well, because it is whatever the CLI picked.
|
|
18
|
+
//
|
|
19
|
+
// The answer must be shaped the way Claude Code parses it: the CLI reads `server_tool_use` blocks
|
|
20
|
+
// to count searches, `web_search_tool_result` blocks for the hits, and `text` blocks for prose,
|
|
21
|
+
// then reports "Did N searches" from `usage.server_tool_use.web_search_requests`. Get the count
|
|
22
|
+
// field wrong and a search that really ran is reported as zero.
|
|
23
|
+
import crypto from "node:crypto";
|
|
24
|
+
const QUERY_PREFIX = "Perform a web search for the query: ";
|
|
25
|
+
function isWebSearchTool(tool) {
|
|
26
|
+
if (!tool || typeof tool !== "object")
|
|
27
|
+
return false;
|
|
28
|
+
const { type, name } = tool;
|
|
29
|
+
// The type carries a date suffix (`web_search_20250305`) that changes with the tool version.
|
|
30
|
+
return typeof type === "string" && type.startsWith("web_search") && name === "web_search";
|
|
31
|
+
}
|
|
32
|
+
function textOf(content) {
|
|
33
|
+
if (typeof content === "string")
|
|
34
|
+
return content;
|
|
35
|
+
if (!Array.isArray(content))
|
|
36
|
+
return "";
|
|
37
|
+
return content
|
|
38
|
+
.map((b) => (b && typeof b === "object" && b.type === "text" ? String(b.text ?? "") : ""))
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.join("\n");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The side request's query, or null when this is an ordinary turn. Recognised by the forced server
|
|
44
|
+
* tool rather than by the model id, because the model id is whatever the user pointed
|
|
45
|
+
* `ANTHROPIC_SMALL_FAST_MODEL` at.
|
|
46
|
+
*/
|
|
47
|
+
export function webSearchQuery(json) {
|
|
48
|
+
const tools = Array.isArray(json.tools) ? json.tools : [];
|
|
49
|
+
const tool = tools.find(isWebSearchTool);
|
|
50
|
+
if (!tool)
|
|
51
|
+
return null;
|
|
52
|
+
// The side request declares that one tool and nothing else — an ordinary turn carries Read, Edit,
|
|
53
|
+
// Bash and the rest — and its single user message is the CLI's fixed sentence. Both are required,
|
|
54
|
+
// because hijacking a turn that wanted a model would be far worse than missing a search.
|
|
55
|
+
//
|
|
56
|
+
// What is deliberately NOT required is a forced `tool_choice`. The CLI source passes
|
|
57
|
+
// `toolChoice: {type:"tool", name:"web_search"}`, but the wire carries `{"type":"auto"}`
|
|
58
|
+
// (measured 2026-09-17 against the live side request). Read the wire, not the source.
|
|
59
|
+
if (tools.length !== 1)
|
|
60
|
+
return null;
|
|
61
|
+
const messages = Array.isArray(json.messages) ? json.messages : [];
|
|
62
|
+
if (messages.length !== 1)
|
|
63
|
+
return null;
|
|
64
|
+
const asked = textOf(messages[0].content).trim();
|
|
65
|
+
if (!asked.startsWith(QUERY_PREFIX))
|
|
66
|
+
return null;
|
|
67
|
+
const query = asked.slice(QUERY_PREFIX.length).trim();
|
|
68
|
+
if (!query)
|
|
69
|
+
return null;
|
|
70
|
+
const strings = (v) => Array.isArray(v) && v.every((x) => typeof x === "string") && v.length > 0 ? v : undefined;
|
|
71
|
+
const allowed = strings(tool.allowed_domains);
|
|
72
|
+
const blocked = strings(tool.blocked_domains);
|
|
73
|
+
return {
|
|
74
|
+
query,
|
|
75
|
+
...(allowed ? { allowedDomains: allowed } : {}),
|
|
76
|
+
...(blocked ? { blockedDomains: blocked } : {}),
|
|
77
|
+
...(typeof tool.max_uses === "number" ? { maxUses: tool.max_uses } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Hits out of an OpenAI-shaped Chat Completions reply that used a web plugin. OpenRouter returns
|
|
82
|
+
* them as `message.annotations[].url_citation` (verified live 2026-09-17: four citations with title
|
|
83
|
+
* and url, plus prose, for `plugins: [{id:"web"}]`). Vendors that answer the same schema without
|
|
84
|
+
* annotations simply yield no hits, and the caller falls back rather than pretending.
|
|
85
|
+
*/
|
|
86
|
+
export function hitsFromAnnotations(message) {
|
|
87
|
+
const m = (message ?? {});
|
|
88
|
+
const annotations = Array.isArray(m.annotations) ? m.annotations : [];
|
|
89
|
+
const hits = [];
|
|
90
|
+
for (const a of annotations) {
|
|
91
|
+
if (!a || typeof a !== "object")
|
|
92
|
+
continue;
|
|
93
|
+
const cite = a.url_citation;
|
|
94
|
+
if (a.type !== "url_citation" || !cite || typeof cite.url !== "string")
|
|
95
|
+
continue;
|
|
96
|
+
hits.push({ title: typeof cite.title === "string" ? cite.title : cite.url, url: cite.url });
|
|
97
|
+
}
|
|
98
|
+
const text = typeof m.content === "string" ? m.content : "";
|
|
99
|
+
return { hits, ...(text ? { text } : {}) };
|
|
100
|
+
}
|
|
101
|
+
/** Same-host duplicates waste the model's attention; the CLI does not de-duplicate for us. */
|
|
102
|
+
export function dedupeHits(hits, limit = 10) {
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const hit of hits) {
|
|
106
|
+
if (!hit?.url || typeof hit.url !== "string")
|
|
107
|
+
continue;
|
|
108
|
+
const key = hit.url.replace(/[#?].*$/, "").replace(/\/+$/, "");
|
|
109
|
+
if (seen.has(key))
|
|
110
|
+
continue;
|
|
111
|
+
seen.add(key);
|
|
112
|
+
out.push({ title: typeof hit.title === "string" && hit.title ? hit.title : hit.url, url: hit.url });
|
|
113
|
+
if (out.length >= limit)
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The content blocks Claude Code's own parser expects: a `server_tool_use` it counts, a
|
|
120
|
+
* `web_search_tool_result` it reads hits from, and any prose as `text`.
|
|
121
|
+
*/
|
|
122
|
+
export function webSearchBlocks(q, outcome) {
|
|
123
|
+
const toolUseId = `srvtoolu_${crypto.randomBytes(12).toString("hex")}`;
|
|
124
|
+
const hits = dedupeHits(outcome.hits);
|
|
125
|
+
const blocks = [
|
|
126
|
+
{ type: "server_tool_use", id: toolUseId, name: "web_search", input: { query: q.query } },
|
|
127
|
+
{
|
|
128
|
+
type: "web_search_tool_result",
|
|
129
|
+
tool_use_id: toolUseId,
|
|
130
|
+
content: hits.map((h) => ({ type: "web_search_result", title: h.title, url: h.url })),
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
const text = outcome.text?.trim();
|
|
134
|
+
if (text)
|
|
135
|
+
blocks.push({ type: "text", text });
|
|
136
|
+
return { blocks, toolUseId };
|
|
137
|
+
}
|
|
138
|
+
/** The error shape the CLI recognises: it prints `Web search error: <code>` rather than showing nothing. */
|
|
139
|
+
export function webSearchErrorBlocks(q, errorCode) {
|
|
140
|
+
const toolUseId = `srvtoolu_${crypto.randomBytes(12).toString("hex")}`;
|
|
141
|
+
return [
|
|
142
|
+
{ type: "server_tool_use", id: toolUseId, name: "web_search", input: { query: q.query } },
|
|
143
|
+
{ type: "web_search_tool_result", tool_use_id: toolUseId, content: { type: "web_search_tool_result_error", error_code: errorCode } },
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* A complete non-streaming Messages response. `usage.server_tool_use.web_search_requests` is what
|
|
148
|
+
* the CLI turns into "Did N searches"; omitting it reports zero for a search that ran.
|
|
149
|
+
*/
|
|
150
|
+
export function webSearchMessage(model, blocks, searches) {
|
|
151
|
+
return {
|
|
152
|
+
id: `msg_${crypto.randomBytes(12).toString("hex")}`,
|
|
153
|
+
type: "message",
|
|
154
|
+
role: "assistant",
|
|
155
|
+
model,
|
|
156
|
+
content: blocks,
|
|
157
|
+
stop_reason: "end_turn",
|
|
158
|
+
stop_sequence: null,
|
|
159
|
+
usage: {
|
|
160
|
+
input_tokens: 0,
|
|
161
|
+
output_tokens: 0,
|
|
162
|
+
cache_read_input_tokens: 0,
|
|
163
|
+
cache_creation_input_tokens: 0,
|
|
164
|
+
server_tool_use: { web_search_requests: searches },
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The same message as a stream. Claude Code assembles the final content from these events and only
|
|
170
|
+
* then parses it, so what matters is that the assembled message matches `webSearchMessage`; the
|
|
171
|
+
* events also drive the "Searching…" progress line.
|
|
172
|
+
*/
|
|
173
|
+
export function webSearchSse(model, blocks, searches) {
|
|
174
|
+
const message = webSearchMessage(model, blocks, searches);
|
|
175
|
+
const frames = [
|
|
176
|
+
{ event: "message_start", data: { type: "message_start", message: { ...message, content: [] } } },
|
|
177
|
+
];
|
|
178
|
+
blocks.forEach((block, index) => {
|
|
179
|
+
if (block.type === "text") {
|
|
180
|
+
frames.push({ event: "content_block_start", data: { type: "content_block_start", index, content_block: { type: "text", text: "" } } });
|
|
181
|
+
frames.push({ event: "content_block_delta", data: { type: "content_block_delta", index, delta: { type: "text_delta", text: String(block.text ?? "") } } });
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
// A server tool block carries no deltas: it is complete the moment it opens.
|
|
185
|
+
frames.push({ event: "content_block_start", data: { type: "content_block_start", index, content_block: block } });
|
|
186
|
+
}
|
|
187
|
+
frames.push({ event: "content_block_stop", data: { type: "content_block_stop", index } });
|
|
188
|
+
});
|
|
189
|
+
frames.push({ event: "message_delta", data: { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: message.usage } });
|
|
190
|
+
frames.push({ event: "message_stop", data: { type: "message_stop" } });
|
|
191
|
+
return frames.map((f) => `event: ${f.event}\ndata: ${JSON.stringify(f.data)}\n\n`).join("");
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* A backend that asks an OpenAI-compatible provider to search with its own hosted web plugin.
|
|
195
|
+
* OpenRouter takes `plugins: [{id:"web"}]` and answers with `url_citation` annotations; `:online`
|
|
196
|
+
* on the model id is the same thing, and is not used here because the plugin form carries the
|
|
197
|
+
* domain filters and result count the side request asked for.
|
|
198
|
+
*/
|
|
199
|
+
export function webPluginBackend(opts) {
|
|
200
|
+
return {
|
|
201
|
+
name: opts.name,
|
|
202
|
+
async search(q, signal) {
|
|
203
|
+
const plugin = { id: "web", max_results: opts.maxResults ?? 5 };
|
|
204
|
+
if (q.allowedDomains)
|
|
205
|
+
plugin.include_domains = q.allowedDomains;
|
|
206
|
+
else if (q.blockedDomains)
|
|
207
|
+
plugin.exclude_domains = q.blockedDomains;
|
|
208
|
+
const body = {
|
|
209
|
+
model: opts.model,
|
|
210
|
+
plugins: [plugin],
|
|
211
|
+
messages: [{ role: "user", content: `${QUERY_PREFIX}${q.query}` }],
|
|
212
|
+
max_tokens: 700,
|
|
213
|
+
};
|
|
214
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
215
|
+
const res = await doFetch(`${opts.url.replace(/\/+$/, "")}/v1/chat/completions`, {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: { "content-type": "application/json", ...opts.headers },
|
|
218
|
+
body: JSON.stringify(body),
|
|
219
|
+
...(signal ? { signal } : {}),
|
|
220
|
+
});
|
|
221
|
+
if (!res.ok)
|
|
222
|
+
throw new Error(`${opts.name} web search: HTTP ${res.status}`);
|
|
223
|
+
const json = await res.json();
|
|
224
|
+
const outcome = hitsFromAnnotations(json.choices?.[0]?.message);
|
|
225
|
+
// No citations means the plugin did not run. Say so rather than handing back a confident
|
|
226
|
+
// summary with nothing behind it.
|
|
227
|
+
if (outcome.hits.length === 0)
|
|
228
|
+
throw new Error(`${opts.name} web search: no citations returned`);
|
|
229
|
+
return outcome;
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/** Hits out of an Anthropic `web_search_tool_result` block's content. */
|
|
234
|
+
export function hitsFromServerToolResult(content) {
|
|
235
|
+
const hits = [];
|
|
236
|
+
for (const block of Array.isArray(content) ? content : []) {
|
|
237
|
+
if (!block || typeof block !== "object")
|
|
238
|
+
continue;
|
|
239
|
+
const { type, title, url } = block;
|
|
240
|
+
if (type !== "web_search_result" || typeof url !== "string")
|
|
241
|
+
continue;
|
|
242
|
+
hits.push({ title: typeof title === "string" && title ? title : url, url });
|
|
243
|
+
}
|
|
244
|
+
return hits;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* A provider that runs Anthropic's `web_search` server tool itself, asked in Anthropic's own shape.
|
|
248
|
+
*
|
|
249
|
+
* DeepSeek's own endpoint does this (measured; see `serverTools` in compat.ts), and for such a
|
|
250
|
+
* provider the side request needs no translation at all — what comes back is already the shape
|
|
251
|
+
* Claude Code parses. The hits are still read out and rebuilt through the one path every backend
|
|
252
|
+
* shares, so a provider that answers politely with nothing is caught here rather than by the user.
|
|
253
|
+
* That is the failure this whole module exists to prevent: a search that returns prose instead of
|
|
254
|
+
* results looks like an answer.
|
|
255
|
+
*/
|
|
256
|
+
export function anthropicServerToolBackend(opts) {
|
|
257
|
+
return {
|
|
258
|
+
name: opts.name,
|
|
259
|
+
async search(q, signal) {
|
|
260
|
+
const tool = { type: "web_search_20250305", name: "web_search" };
|
|
261
|
+
if (q.maxUses)
|
|
262
|
+
tool.max_uses = q.maxUses;
|
|
263
|
+
if (q.allowedDomains)
|
|
264
|
+
tool.allowed_domains = q.allowedDomains;
|
|
265
|
+
else if (q.blockedDomains)
|
|
266
|
+
tool.blocked_domains = q.blockedDomains;
|
|
267
|
+
const body = {
|
|
268
|
+
model: opts.model,
|
|
269
|
+
max_tokens: 1024,
|
|
270
|
+
system: [{ type: "text", text: "You are an assistant for performing a web search tool use" }],
|
|
271
|
+
messages: [{ role: "user", content: `${QUERY_PREFIX}${q.query}` }],
|
|
272
|
+
tools: [tool],
|
|
273
|
+
tool_choice: { type: "tool", name: "web_search" },
|
|
274
|
+
};
|
|
275
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
276
|
+
const res = await doFetch(`${opts.url.replace(/\/+$/, "")}/v1/messages`, {
|
|
277
|
+
method: "POST",
|
|
278
|
+
headers: { "content-type": "application/json", "anthropic-version": "2023-06-01", ...opts.headers },
|
|
279
|
+
body: JSON.stringify(body),
|
|
280
|
+
...(signal ? { signal } : {}),
|
|
281
|
+
});
|
|
282
|
+
if (!res.ok)
|
|
283
|
+
throw new Error(`${opts.name} web search: HTTP ${res.status}`);
|
|
284
|
+
const json = await res.json();
|
|
285
|
+
const hits = [];
|
|
286
|
+
const prose = [];
|
|
287
|
+
for (const block of Array.isArray(json.content) ? json.content : []) {
|
|
288
|
+
if (!block || typeof block !== "object")
|
|
289
|
+
continue;
|
|
290
|
+
const b = block;
|
|
291
|
+
if (b.type === "web_search_tool_result")
|
|
292
|
+
hits.push(...hitsFromServerToolResult(b.content));
|
|
293
|
+
else if (b.type === "text" && typeof b.text === "string")
|
|
294
|
+
prose.push(b.text);
|
|
295
|
+
}
|
|
296
|
+
// No result blocks means the provider did not run the tool, whatever else it said. A model
|
|
297
|
+
// told to search with no tool it can reach will narrate one instead — an OpenCode Go DeepSeek
|
|
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.
|
|
300
|
+
const deduped = dedupeHits(hits).slice(0, opts.maxResults ?? 10);
|
|
301
|
+
if (deduped.length === 0)
|
|
302
|
+
throw new Error(`${opts.name} web search: no results returned`);
|
|
303
|
+
const text = prose.join("").trim();
|
|
304
|
+
return text ? { hits: deduped, text } : { hits: deduped };
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
package/dist/router/src/x509.js
CHANGED
|
@@ -128,10 +128,15 @@ function pem(label, der) {
|
|
|
128
128
|
function newKeyPair() {
|
|
129
129
|
return crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
|
|
130
130
|
}
|
|
131
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Serial numbers must be positive and unpredictable: 16 random bytes, top bit cleared and the next
|
|
133
|
+
* one set. Without that second bit one serial in 128 led with 0x00, which the DER encoding strips
|
|
134
|
+
* or re-pads, so its length varied and Node reported it without the zero (`8C5C…`) — a positive
|
|
135
|
+
* serial that read as negative and failed the x509 test at random. 126 random bits remain.
|
|
136
|
+
*/
|
|
132
137
|
function serial() {
|
|
133
138
|
const b = crypto.randomBytes(16);
|
|
134
|
-
b[0] = b[0] & 0x7f;
|
|
139
|
+
b[0] = (b[0] & 0x7f) | 0x40;
|
|
135
140
|
return b;
|
|
136
141
|
}
|
|
137
142
|
function signCert(tbs, caKey) {
|