clauderipple 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +77 -0
- package/README.ko.md +18 -2
- package/README.md +27 -5
- package/dist/app/package.json +1 -1
- package/dist/cli/src/claude-auth.js +35 -1
- package/dist/cli/src/codex.js +23 -0
- package/dist/cli/src/hooks/agent-title.js +58 -29
- package/dist/cli/src/index.js +46 -1
- package/dist/cli/src/picker.js +19 -11
- package/dist/cli/src/runtime.js +3 -0
- package/dist/cli/src/settings.js +49 -0
- package/dist/mod/.claude-plugin/plugin.json +1 -0
- package/dist/mod/hooks/hooks.json +1 -0
- package/dist/mod/hooks/register.tsx +309 -0
- package/dist/mod/hooks/worker.ts +49 -0
- package/dist/mod/tsconfig.json +3 -0
- package/dist/mod/types/index.d.ts +30 -0
- package/dist/router/src/admin.js +411 -32
- package/dist/router/src/agents.js +24 -8
- package/dist/router/src/catalog.js +80 -0
- package/dist/router/src/compat.js +27 -1
- package/dist/router/src/config.js +31 -0
- package/dist/router/src/index.js +12 -1
- package/dist/router/src/ingress/server.js +3 -2
- package/dist/router/src/picker.js +33 -2
- package/dist/router/src/presets.js +2 -1
- package/dist/router/src/providers/chatgpt/index.js +77 -18
- package/dist/router/src/providers/chatgpt/translate.js +34 -6
- package/dist/router/src/providers/google/accounts.js +345 -0
- package/dist/router/src/providers/google/antigravity.js +392 -0
- package/dist/router/src/providers/google/index.js +617 -0
- package/dist/router/src/providers/google/login.js +249 -0
- package/dist/router/src/providers/google/thoughtSignatures.js +47 -0
- package/dist/router/src/providers/google/translate.js +539 -0
- package/dist/router/src/providers/google/transport.js +118 -0
- package/dist/router/src/providers/openai/translate.js +3 -3
- package/dist/router/src/proxy.js +116 -9
- package/dist/router/src/requestlog.js +2 -0
- package/dist/router/src/routing.js +22 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +420 -45
- package/dist/ui/i18n.js +76 -4
- package/dist/ui/index.html +5 -0
- package/dist/ui/style.css +4 -0
- package/docs/ARCHITECTURE.md +307 -4
- package/package.json +1 -1
|
@@ -15,6 +15,7 @@ import path from "node:path";
|
|
|
15
15
|
import { homeDir } from "./config.js";
|
|
16
16
|
import { declaredBy } from "./routing.js";
|
|
17
17
|
import { adminPort } from "./admin.js";
|
|
18
|
+
import { shortName } from "../../cli/src/hooks/agent-title.js";
|
|
18
19
|
/** Where Claude Code's Agent tool reads its worker definitions. */
|
|
19
20
|
export function defaultAgentDir() {
|
|
20
21
|
return path.join(os.homedir(), ".claude", "agents");
|
|
@@ -147,18 +148,33 @@ function capsEffortLevels(provider) {
|
|
|
147
148
|
return provider.caps?.effortLevels;
|
|
148
149
|
}
|
|
149
150
|
/**
|
|
150
|
-
* The `@effort` suffix a generated file's `model:` carries: `@
|
|
151
|
-
* offers
|
|
152
|
-
*
|
|
151
|
+
* The `@effort` suffix a generated file's `model:` carries: `@high` when the provider or the model
|
|
152
|
+
* offers any reasoning effort, otherwise none. A ladder without `high` is fine — the request is
|
|
153
|
+
* clamped to the nearest level the model takes (compat.ts). It used to be `@medium`, which a ladder
|
|
154
|
+
* from the catalogue such as `low`/`high`/`max` does not hold, and the operator wants workers at
|
|
155
|
+
* high unless a marker says otherwise (2026-10-02). An empty `model.effortLevels` disables the
|
|
156
|
+
* provider fallback (config.ts), so it is honoured rather than ignored.
|
|
153
157
|
*/
|
|
154
158
|
function effortSuffix(provider, modelId) {
|
|
155
159
|
const modelLevels = provider.models?.find((m) => m.id === modelId)?.effortLevels;
|
|
156
160
|
const levels = modelLevels !== undefined ? modelLevels : capsEffortLevels(provider);
|
|
157
|
-
return levels
|
|
161
|
+
return levels && levels.length > 0 ? "@high" : "";
|
|
158
162
|
}
|
|
159
|
-
|
|
163
|
+
/**
|
|
164
|
+
* What the parent is asked to start the Agent `description` with. The Desktop task panel names only
|
|
165
|
+
* Claude models, and rebuilt from the transcript it shows the description as the parent wrote it,
|
|
166
|
+
* so the agent-title hook's live correction is lost (app web bundle, 2026-09-30); a prefix the
|
|
167
|
+
* parent writes itself survives. Same form as the hook's: the display name's last word, `·level`.
|
|
168
|
+
*/
|
|
169
|
+
function titlePrefix(title, suffix) {
|
|
170
|
+
return suffix
|
|
171
|
+
? `Start the Agent description with "${title}·<level> · " (the marker's level, else ${suffix.slice(1)}) so the app's task list shows the model.`
|
|
172
|
+
: `Start the Agent description with "${title} · " so the app's task list shows the model.`;
|
|
173
|
+
}
|
|
174
|
+
function fileContent(name, modelId, title, provider, suffix, limitTools, imagePort) {
|
|
160
175
|
const description = `${modelId} via ${provider}. Generated by ClaudeRipple from config.json — edits are overwritten; ` +
|
|
161
|
-
`to customise, copy to another name. Set effort with [[ripple: ${name}@<level>]] on the first line
|
|
176
|
+
`to customise, copy to another name. Set effort with [[ripple: ${name}@<level>]] on the first line. ` +
|
|
177
|
+
titlePrefix(title, suffix);
|
|
162
178
|
const tools = limitTools ? `tools: ${TOOLS.join(", ")}\n` : "";
|
|
163
179
|
const body = imagePort === null ? BODY : `${BODY}\n${imageLine(imagePort)}`;
|
|
164
180
|
return `---\nname: ${name}\ndescription: ${description}\nmodel: ${modelId}${suffix}\n${tools}---\n${body}\n`;
|
|
@@ -220,7 +236,7 @@ export function syncAgentFiles(cfg, dir, log, opts = {}) {
|
|
|
220
236
|
log?.warn(`agent files: "${model.id}" and "${clash.modelId}" both map to "${name}"; skipping "${model.id}"`);
|
|
221
237
|
continue;
|
|
222
238
|
}
|
|
223
|
-
targets.set(name, { modelId: model.id, provider: providerName, suffix: effortSuffix(provider, model.id) });
|
|
239
|
+
targets.set(name, { modelId: model.id, title: shortName(model.name ?? model.id), provider: providerName, suffix: effortSuffix(provider, model.id) });
|
|
224
240
|
}
|
|
225
241
|
}
|
|
226
242
|
const imagePort = Object.values(cfg.providers).some((p) => p.type === "chatgpt") ? adminPort(cfg) : null;
|
|
@@ -231,7 +247,7 @@ export function syncAgentFiles(cfg, dir, log, opts = {}) {
|
|
|
231
247
|
continue;
|
|
232
248
|
}
|
|
233
249
|
const file = path.join(dir, `${name}.md`);
|
|
234
|
-
const content = fileContent(name, t.modelId, t.provider, t.suffix, cfg.cli.limitWorkerTools === true, imagePort);
|
|
250
|
+
const content = fileContent(name, t.modelId, t.title, t.provider, t.suffix, cfg.cli.limitWorkerTools === true, imagePort);
|
|
235
251
|
const existing = scanned.get(`${name}.md`);
|
|
236
252
|
if (existing) {
|
|
237
253
|
let current = null;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// What OpenCode itself knows about each model, read from the catalogue its own client is built on.
|
|
2
|
+
//
|
|
3
|
+
// OpenCode's docs publish an endpoint per model and no effort ladder at all, and its `/models`
|
|
4
|
+
// reports ids alone, so `capabilities.ts` established both by sending requests: about ten per
|
|
5
|
+
// model, one after another. With upstream latency swinging from 0.5s to 25s on identical one-token
|
|
6
|
+
// requests (glm-5.3-flash, measured 2026-10-02), three models took 2m33s.
|
|
7
|
+
//
|
|
8
|
+
// models.dev (https://models.dev/api.json, maintained by the team behind OpenCode) states both for
|
|
9
|
+
// every model OpenCode serves: `reasoning_options` lists the effort values, and `provider.npm` names
|
|
10
|
+
// the SDK the OpenCode client speaks to it with. It is also more accurate than asking: a 200 only
|
|
11
|
+
// says a level was not refused, and glm-5.3-flash answered 200 to seven levels where the catalogue
|
|
12
|
+
// lists `low`/`high`/`max` — the rest were accepted and, as far as anything can tell, ignored. And
|
|
13
|
+
// muse-spark-1.3-contributor names `max` in its own error message, refuses it when sent, and the
|
|
14
|
+
// catalogue never lists it.
|
|
15
|
+
//
|
|
16
|
+
// The catalogue is community-maintained and a new model can arrive a day late; a model it does not
|
|
17
|
+
// list is measured exactly as before.
|
|
18
|
+
/** Which models.dev provider describes the endpoint a preset points at. Only presets whose base URL
|
|
19
|
+
* is the one the catalogue entry names (`api`) are listed, so the catalogue never describes a
|
|
20
|
+
* different endpoint from the one the request goes to. */
|
|
21
|
+
const CATALOG_PROVIDER = {
|
|
22
|
+
"opencode-go": "opencode-go", // api https://opencode.ai/zen/go/v1
|
|
23
|
+
"opencode-zen": "opencode", // api https://opencode.ai/zen/v1
|
|
24
|
+
};
|
|
25
|
+
export const CATALOG_URL = "https://models.dev/api.json";
|
|
26
|
+
const CATALOG_TIMEOUT_MS = 10_000;
|
|
27
|
+
/** The catalogue changes when a model is added, not minute to minute. */
|
|
28
|
+
const CATALOG_TTL_MS = 60 * 60 * 1000;
|
|
29
|
+
/** The SDK packages that map onto a wire this router sends. Any other (`@ai-sdk/google`, …) says
|
|
30
|
+
* nothing we can use, and leaves the wire to measurement. */
|
|
31
|
+
function wireOf(npm) {
|
|
32
|
+
if (npm === "@ai-sdk/openai-compatible")
|
|
33
|
+
return "chat";
|
|
34
|
+
if (npm === "@ai-sdk/openai")
|
|
35
|
+
return "responses";
|
|
36
|
+
if (npm === "@ai-sdk/anthropic")
|
|
37
|
+
return "anthropic";
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
/** One model as the catalogue describes it, or nothing when the preset has no catalogue or the model is not in it. */
|
|
41
|
+
export function catalogEntry(catalog, preset, modelId) {
|
|
42
|
+
const providerId = preset ? CATALOG_PROVIDER[preset] : undefined;
|
|
43
|
+
if (!providerId || !catalog || typeof catalog !== "object")
|
|
44
|
+
return undefined;
|
|
45
|
+
const provider = catalog[providerId];
|
|
46
|
+
const model = provider?.models?.[modelId];
|
|
47
|
+
if (!model || typeof model !== "object")
|
|
48
|
+
return undefined;
|
|
49
|
+
// A model with no SDK of its own is spoken to with the provider's.
|
|
50
|
+
const wire = wireOf(model.provider?.npm ?? provider?.npm);
|
|
51
|
+
let effortLevels;
|
|
52
|
+
if (Array.isArray(model.reasoning_options)) {
|
|
53
|
+
const effort = model.reasoning_options.find((option) => option?.type === "effort" && Array.isArray(option.values));
|
|
54
|
+
effortLevels = effort ? effort.values.filter((value) => typeof value === "string") : [];
|
|
55
|
+
}
|
|
56
|
+
else if (model.reasoning === false) {
|
|
57
|
+
effortLevels = [];
|
|
58
|
+
}
|
|
59
|
+
return { ...(wire ? { wire } : {}), ...(effortLevels ? { effortLevels } : {}) };
|
|
60
|
+
}
|
|
61
|
+
let cached;
|
|
62
|
+
/**
|
|
63
|
+
* The catalogue, fetched at most once an hour. Undefined when it cannot be had: the caller measures
|
|
64
|
+
* instead, so a catalogue outage costs time and never a wrong answer.
|
|
65
|
+
*/
|
|
66
|
+
export async function loadModelCatalog(fetchImpl = fetch) {
|
|
67
|
+
if (cached && Date.now() - cached.at < CATALOG_TTL_MS)
|
|
68
|
+
return cached.catalog;
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetchImpl(CATALOG_URL, { signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS) });
|
|
71
|
+
if (!response.ok)
|
|
72
|
+
return cached?.catalog;
|
|
73
|
+
const catalog = await response.json();
|
|
74
|
+
cached = { at: Date.now(), catalog };
|
|
75
|
+
return catalog;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return cached?.catalog;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Sanitise Claude Code's Anthropic-only request extensions for providers that expose
|
|
2
2
|
// an Anthropic-shaped Messages endpoint without implementing Anthropic's full feature set.
|
|
3
3
|
// Pure: callers receive a new object and an audit list; the input is never mutated.
|
|
4
|
+
import { droppedServerToolNote } from "./providers/chatgpt/translate.js";
|
|
4
5
|
export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max", "ultra"];
|
|
5
6
|
export const STRICT_COMPAT_CAPS = {
|
|
6
7
|
effortLevels: [],
|
|
@@ -28,6 +29,10 @@ export function forwardCompatibleHeader(name, caps) {
|
|
|
28
29
|
/**
|
|
29
30
|
* Return the nearest supported canonical effort level. Unknown requested levels fall back
|
|
30
31
|
* to the provider's lowest listed level, which is safer than forwarding a rejected value.
|
|
32
|
+
*
|
|
33
|
+
* Equally near on both sides goes up: `medium` on a `low`/`high`/`max` ladder — the shape most
|
|
34
|
+
* OpenCode Go models list (models.dev, 2026-10-02) — is `high`. Asking for medium means "more
|
|
35
|
+
* than low", and rounding it down cost a worker its reasoning for the sake of a tie.
|
|
31
36
|
*/
|
|
32
37
|
export function clampEffort(effort, supported) {
|
|
33
38
|
if (supported.includes(effort))
|
|
@@ -38,7 +43,11 @@ export function clampEffort(effort, supported) {
|
|
|
38
43
|
.filter((entry) => entry.index >= 0);
|
|
39
44
|
if (requested < 0 || canonical.length === 0)
|
|
40
45
|
return supported[0] ?? effort;
|
|
41
|
-
return canonical.reduce((best, candidate) =>
|
|
46
|
+
return canonical.reduce((best, candidate) => {
|
|
47
|
+
const distance = Math.abs(candidate.index - requested);
|
|
48
|
+
const bestDistance = Math.abs(best.index - requested);
|
|
49
|
+
return distance < bestDistance || (distance === bestDistance && candidate.index > best.index) ? candidate : best;
|
|
50
|
+
}).value;
|
|
42
51
|
}
|
|
43
52
|
function stripCacheControl(value) {
|
|
44
53
|
if (Array.isArray(value)) {
|
|
@@ -77,6 +86,15 @@ function stripCacheControl(value) {
|
|
|
77
86
|
// translated providers drop it for the same reason (`systemText` in providers/chatgpt/translate.ts,
|
|
78
87
|
// measured 2026-09-13), and it means nothing to a provider that is not Anthropic.
|
|
79
88
|
const BILLING_BLOCK = /^x-anthropic-billing-header:/;
|
|
89
|
+
function appendSystemText(system, text) {
|
|
90
|
+
if (system === undefined || system === null || system === "")
|
|
91
|
+
return text;
|
|
92
|
+
if (typeof system === "string")
|
|
93
|
+
return `${system}\n\n${text}`;
|
|
94
|
+
if (Array.isArray(system))
|
|
95
|
+
return [...system, { type: "text", text }];
|
|
96
|
+
return system;
|
|
97
|
+
}
|
|
80
98
|
function stripBillingHeader(system) {
|
|
81
99
|
if (typeof system === "string") {
|
|
82
100
|
const value = system.replace(/^x-anthropic-billing-header:[^\n]*\n*/, "");
|
|
@@ -183,6 +201,14 @@ export function sanitizeForCompatible(json, caps) {
|
|
|
183
201
|
delete out.tool_choice;
|
|
184
202
|
changes.push("tool_choice");
|
|
185
203
|
}
|
|
204
|
+
// The instruction text for a dropped tool stays in the prompt unless withdrawn (see the note's
|
|
205
|
+
// own comment). Appended after the caller's blocks, without cache_control, so the prefix up to
|
|
206
|
+
// Claude Code's breakpoints is untouched.
|
|
207
|
+
const note = droppedServerToolNote(droppedNames);
|
|
208
|
+
if (note) {
|
|
209
|
+
out.system = appendSystemText(out.system, note);
|
|
210
|
+
changes.push("server_tool_note");
|
|
211
|
+
}
|
|
186
212
|
}
|
|
187
213
|
if ("system" in out) {
|
|
188
214
|
const next = stripBillingHeader(out.system);
|
|
@@ -239,6 +239,22 @@ function foldSplitProviders(c) {
|
|
|
239
239
|
}
|
|
240
240
|
export function validate(c) {
|
|
241
241
|
const errors = [];
|
|
242
|
+
if (c.autoModeClassifier !== undefined) {
|
|
243
|
+
const target = c.autoModeClassifier;
|
|
244
|
+
if (!target || typeof target !== "object")
|
|
245
|
+
errors.push("autoModeClassifier must be an object");
|
|
246
|
+
else {
|
|
247
|
+
const provider = c.providers[target.provider];
|
|
248
|
+
if (!provider)
|
|
249
|
+
errors.push("autoModeClassifier: unknown provider");
|
|
250
|
+
else if (provider.type === "anthropic" && !provider.accountPool)
|
|
251
|
+
errors.push("autoModeClassifier: provider is ingress-only");
|
|
252
|
+
if (typeof target.model !== "string" || !target.model.trim())
|
|
253
|
+
errors.push("autoModeClassifier: missing model");
|
|
254
|
+
if (target.effort !== undefined && typeof target.effort !== "string")
|
|
255
|
+
errors.push("autoModeClassifier: effort must be a string");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
242
258
|
for (const [alias, r] of Object.entries(c.routes)) {
|
|
243
259
|
if (!c.providers[r.provider])
|
|
244
260
|
errors.push(`route ${alias}: unknown provider "${r.provider}"`);
|
|
@@ -360,6 +376,21 @@ export function validate(c) {
|
|
|
360
376
|
errors.push(`provider ${name}: caps must contain effortLevels?: string[], reasoning?: "effort"|"none"`);
|
|
361
377
|
}
|
|
362
378
|
}
|
|
379
|
+
else if (p.type === "google") {
|
|
380
|
+
if (p.auth !== "api-key" && p.auth !== "antigravity")
|
|
381
|
+
errors.push(`provider ${name}: auth must be "api-key" or "antigravity"`);
|
|
382
|
+
// The api-key is required, but a key may also come from the environment. The router, not the
|
|
383
|
+
// GUI save, is where that is settled — so this only refuses a key of the wrong type.
|
|
384
|
+
if (p.apiKey !== undefined && typeof p.apiKey !== "string")
|
|
385
|
+
errors.push(`provider ${name}: apiKey must be a string`);
|
|
386
|
+
if (p.url !== undefined && !/^https?:\/\//.test(p.url))
|
|
387
|
+
errors.push(`provider ${name}: url must start with http:// or https://`);
|
|
388
|
+
if (p.defaultEffort !== undefined && typeof p.defaultEffort !== "string")
|
|
389
|
+
errors.push(`provider ${name}: defaultEffort must be a string`);
|
|
390
|
+
if (p.models !== undefined && !validModels(p.models)) {
|
|
391
|
+
errors.push(`provider ${name}: ${MODELS_SHAPE}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
363
394
|
else if (p.type === "anthropic") {
|
|
364
395
|
if (p.auth !== "api-key" && p.auth !== "claude-code")
|
|
365
396
|
errors.push(`provider ${name}: auth must be "api-key" or "claude-code"`);
|
package/dist/router/src/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Exit codes: 0 clean, 2 bad config/certs, 75 upstream unreachable (supervisor should restart).
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
|
-
import { codexEnabled, writeCodexCatalog } from "../../cli/src/codex.js";
|
|
9
|
+
import { codexEnabled, followCodexCache, writeCodexCatalog } from "../../cli/src/codex.js";
|
|
10
10
|
import { ingressModels } from "./ingress/models.js";
|
|
11
11
|
import { ConfigStore, configPath, homeDir, terminateHosts } from "./config.js";
|
|
12
12
|
import { defaultAgentDir, syncAgentFiles } from "./agents.js";
|
|
@@ -15,6 +15,7 @@ import { Logger } from "./log.js";
|
|
|
15
15
|
import { UpstreamHealth, EXIT_UPSTREAM_UNREACHABLE } from "./health.js";
|
|
16
16
|
import { Proxy } from "./proxy.js";
|
|
17
17
|
import { startAdmin } from "./admin.js";
|
|
18
|
+
import { loadModelCatalog } from "./catalog.js";
|
|
18
19
|
import { RequestLog } from "./requestlog.js";
|
|
19
20
|
import { OpenAiIngress } from "./ingress/server.js";
|
|
20
21
|
import { ObservedClaudeCodeAuth } from "./providers/anthropic-observed.js";
|
|
@@ -123,6 +124,9 @@ setInterval(() => {
|
|
|
123
124
|
const oi = ingress.stats;
|
|
124
125
|
log.info(`stats proxy=${s.started}/${s.completed}/${s.failed} inFlight=${s.inFlight} ingress=${oi.started}/${oi.completed}/${oi.failed} inFlight=${oi.inFlight} consecutiveUpstreamFailures=${health.consecutiveFailures}`);
|
|
125
126
|
}, statsEvery).unref();
|
|
127
|
+
// The config callback above rewrites the Codex catalog when *our* models change; this covers
|
|
128
|
+
// Codex's side (a new OpenAI model arriving in its cache).
|
|
129
|
+
followCodexCache(() => ingressModels(store.get()), undefined, undefined, (e) => log.warn(`codex catalog: ${e.message}`));
|
|
126
130
|
proxy
|
|
127
131
|
.listen()
|
|
128
132
|
.then(async () => {
|
|
@@ -150,11 +154,18 @@ proxy
|
|
|
150
154
|
accounts: () => proxy.chatgptAccounts(),
|
|
151
155
|
clearCooldown: (id) => proxy.chatgptClearCooldown(id),
|
|
152
156
|
}),
|
|
157
|
+
google: () => ({
|
|
158
|
+
accounts: () => proxy.googleAccounts(),
|
|
159
|
+
models: (name) => proxy.googleFetchModels(name),
|
|
160
|
+
clearCooldown: (id) => proxy.googleClearCooldown(id),
|
|
161
|
+
recheckVerification: (id) => proxy.googleRecheckVerification(id),
|
|
162
|
+
}),
|
|
153
163
|
credentials: () => proxy.credentialHealth(),
|
|
154
164
|
picker: () => ({ enabled: !!store.get().picker?.enabled, hosts: terminateHosts(store.get()).slice(1), last: proxy.lastPickerInjection }),
|
|
155
165
|
observedClaudeCodeAuth,
|
|
156
166
|
claudeUsage: () => proxy.claudeUsage.snapshot(),
|
|
157
167
|
image: (req, signal) => proxy.generateImage(req, signal),
|
|
168
|
+
modelCatalog: () => loadModelCatalog(),
|
|
158
169
|
shutdown: () => beginDrain("shutdown requested"),
|
|
159
170
|
});
|
|
160
171
|
log.info(`clauderipple admin GUI on http://127.0.0.1:${admin.port}/`);
|
|
@@ -332,8 +332,9 @@ export class OpenAiIngress {
|
|
|
332
332
|
// is an OpenAI wire, which the refusal below would otherwise turn away on the provider's type
|
|
333
333
|
// alone. One subscription is one provider, so the model decides.
|
|
334
334
|
const provider = providerFor(configured, route.model);
|
|
335
|
-
if (provider.type === "chatgpt" || provider.type === "openai-compatible") {
|
|
336
|
-
const
|
|
335
|
+
if (provider.type === "chatgpt" || provider.type === "openai-compatible" || provider.type === "google") {
|
|
336
|
+
const label = provider.type === "chatgpt" ? "ChatGPT" : provider.type === "google" ? "Google" : "OpenAI-compatible";
|
|
337
|
+
const bytes = sendJson(res, 400, openAiError(`${label} provider is not available through OpenAI ingress`, "invalid_request_error", "unsupported_provider"));
|
|
337
338
|
finish(400, bytes, { note: `model ${requested} -> ${provider.type} provider unsupported` });
|
|
338
339
|
return;
|
|
339
340
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// entry as a template and override the identifying fields. Surfaces are matched by id
|
|
10
10
|
// heuristically (anything that is not the claude.ai chat surface); the ids we saw are
|
|
11
11
|
// logged once so the heuristic can be tightened.
|
|
12
|
+
import { clampEffort } from "./compat.js";
|
|
12
13
|
export const BOOTSTRAP_PATHS = ["/edge-api/bootstrap", "/api/bootstrap"];
|
|
13
14
|
export function isBootstrapPath(path) {
|
|
14
15
|
return BOOTSTRAP_PATHS.some((p) => path === p || path.startsWith(p + "/") || path.startsWith(p + "?"));
|
|
@@ -23,8 +24,35 @@ function isClaudeEntry(m) {
|
|
|
23
24
|
function pickTemplate(models) {
|
|
24
25
|
return models.find((m) => isClaudeEntry(m) && !m.disabled && m.section !== "deprecated" && m.section !== "legacy") ?? models.find(isClaudeEntry) ?? null;
|
|
25
26
|
}
|
|
26
|
-
/**
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The effort menu for one routed model: the template's own options, narrowed to the levels the
|
|
29
|
+
* model takes.
|
|
30
|
+
*
|
|
31
|
+
* The app builds the menu per entry from `thinking.effort_options` (`effortOptionsFor: e =>
|
|
32
|
+
* …effort_options ?? []` in the Code tab bundle, read 2026-10-02), so a cloned Claude entry offered
|
|
33
|
+
* Claude's whole ladder for a model that takes three of it. The template's option objects are kept
|
|
34
|
+
* as they are — they carry the app's localised labels, and an id the app does not know (its list is
|
|
35
|
+
* low/medium/high/xhigh/max) is not invented here. `recommended` moves to high, or the level nearest
|
|
36
|
+
* it: when the selected level is not on the list, the app switches to the recommended one (`uw`).
|
|
37
|
+
* A model that takes none of them gets no `thinking` at all, which the app reads as "no effort".
|
|
38
|
+
*/
|
|
39
|
+
function narrowThinking(entry, levels) {
|
|
40
|
+
const thinking = entry.thinking;
|
|
41
|
+
if (!thinking || !Array.isArray(thinking.effort_options))
|
|
42
|
+
return;
|
|
43
|
+
const kept = thinking.effort_options.filter((option) => typeof option.id === "string" && levels.includes(option.id));
|
|
44
|
+
if (kept.length === 0) {
|
|
45
|
+
delete entry.thinking;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const preferred = clampEffort("high", kept.map((option) => option.id));
|
|
49
|
+
entry.thinking = { ...thinking, effort_options: kept.map(({ recommended: _recommended, ...option }) => (option.id === preferred ? { ...option, recommended: true } : option)) };
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Mutates `json` in place; returns what was done for logging. `effortFor` names the levels a model
|
|
53
|
+
* takes (by its id without an `@effort` suffix); undefined leaves the template's menu as it is.
|
|
54
|
+
*/
|
|
55
|
+
export function injectPickerModels(json, extra, contextWindow, effortFor) {
|
|
28
56
|
const result = { injected: 0, surfaces: [] };
|
|
29
57
|
const msc = json.model_selector_config;
|
|
30
58
|
if (!Array.isArray(msc) || extra.length === 0)
|
|
@@ -60,6 +88,9 @@ export function injectPickerModels(json, extra, contextWindow) {
|
|
|
60
88
|
for (const k of ["disabled", "disabled_reason", "notice", "selection_notice", "badge", "badge_tooltip", "tooltip", "minimum_tier", "is_default"])
|
|
61
89
|
delete entry[k];
|
|
62
90
|
entry.section = "main";
|
|
91
|
+
const levels = effortFor?.(e.model.split("@")[0]);
|
|
92
|
+
if (levels)
|
|
93
|
+
narrowThinking(entry, levels);
|
|
63
94
|
surface.models.push(entry);
|
|
64
95
|
result.injected++;
|
|
65
96
|
}
|
|
@@ -207,7 +207,8 @@ export const PRESETS = [
|
|
|
207
207
|
// assumption rather than a measurement, and it was wrong: deepseek-v4.1-flash accepts all eight
|
|
208
208
|
// levels including max, and mimo-v2.6-pro accepts none/low/medium/high (measured 2026-09-22).
|
|
209
209
|
// It could not correct itself either — an empty ladder is never measured. Left unstated, each
|
|
210
|
-
// model's ladder is established on the first save.
|
|
210
|
+
// model's ladder is established on the first save: from models.dev when it lists the model
|
|
211
|
+
// (catalog.ts — OpenCode's own catalogue, which does state ladders), by request otherwise.
|
|
211
212
|
fallbackModels: [
|
|
212
213
|
// Responses — the preset's own wire, so these carry no override.
|
|
213
214
|
{ id: "muse-spark-1.3-contributor", name: "Muse Spark 1.3 (Contributor)" },
|
|
@@ -15,6 +15,8 @@ import path from "node:path";
|
|
|
15
15
|
import { homeDir } from "../../config.js";
|
|
16
16
|
export const DEFAULT_BASE = "https://chatgpt.com/backend-api";
|
|
17
17
|
const PING_MS = 15_000;
|
|
18
|
+
/** How long a stream may hold its headers waiting for upstream's first item (see `handle`). Tests shorten it. */
|
|
19
|
+
export const streamHold = { ms: 5_000 };
|
|
18
20
|
/** Active quota lookup. Measured 2026-09-20: GET {base}/wham/usage → 200 JSON. The binary also
|
|
19
21
|
* carries `/api/codex/usage`, but that path answers 403 here; `wham` is the one that works. */
|
|
20
22
|
const USAGE_PATH = "/wham/usage";
|
|
@@ -95,13 +97,36 @@ export function rateLimitsFromHeaders(h) {
|
|
|
95
97
|
if (!primary)
|
|
96
98
|
return null;
|
|
97
99
|
const secondary = window("secondary");
|
|
100
|
+
const flag = (k) => {
|
|
101
|
+
const v = h.get(k)?.toLowerCase();
|
|
102
|
+
return v === "true" ? true : v === "false" ? false : undefined;
|
|
103
|
+
};
|
|
104
|
+
const credits = creditsSnapshot(flag("x-codex-credits-has-credits"), flag("x-codex-credits-unlimited"), h.get("x-codex-credits-balance"), undefined);
|
|
98
105
|
return {
|
|
99
106
|
type: "codex.rate_limits",
|
|
100
107
|
plan_type: h.get("x-codex-plan-type") ?? undefined,
|
|
101
108
|
rate_limits: { primary, secondary: secondary && secondary.window_minutes ? secondary : null },
|
|
109
|
+
...(credits ? { credits } : {}),
|
|
102
110
|
at: Date.now(),
|
|
103
111
|
};
|
|
104
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* The account's purchased credits, which the backend draws on once a plan window is used up — the
|
|
115
|
+
* account keeps answering at 100%. Measured 2026-10-06: response headers carry
|
|
116
|
+
* `x-codex-credits-has-credits: False` / `-unlimited: False` / `-balance: 0` (Python-style
|
|
117
|
+
* booleans), and `/wham/usage` carries `credits: {has_credits, unlimited, overage_limit_reached,
|
|
118
|
+
* balance}`. Null when the backend said nothing about credits.
|
|
119
|
+
*/
|
|
120
|
+
function creditsSnapshot(hasCredits, unlimited, balance, overageReached) {
|
|
121
|
+
if (typeof hasCredits !== "boolean" && typeof unlimited !== "boolean")
|
|
122
|
+
return null;
|
|
123
|
+
return {
|
|
124
|
+
has_credits: hasCredits === true,
|
|
125
|
+
unlimited: unlimited === true,
|
|
126
|
+
...(typeof balance === "string" || typeof balance === "number" ? { balance: String(balance) } : {}),
|
|
127
|
+
...(typeof overageReached === "boolean" ? { overage_limit_reached: overageReached } : {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
105
130
|
/**
|
|
106
131
|
* Map the `/wham/usage` JSON body onto the same shape as `rateLimitsFromHeaders`, so `/api/status`
|
|
107
132
|
* and the GUI read one thing whether the snapshot came from response headers or the active call.
|
|
@@ -129,10 +154,13 @@ export function rateLimitsFromUsage(body) {
|
|
|
129
154
|
if (!primary)
|
|
130
155
|
return null;
|
|
131
156
|
const secondary = win(b?.rate_limit?.secondary_window);
|
|
157
|
+
const c = b?.credits;
|
|
158
|
+
const credits = c ? creditsSnapshot(c.has_credits, c.unlimited, c.balance, c.overage_limit_reached) : null;
|
|
132
159
|
return {
|
|
133
160
|
type: "codex.rate_limits",
|
|
134
161
|
plan_type: typeof b?.plan_type === "string" ? b.plan_type : undefined,
|
|
135
162
|
rate_limits: { primary, secondary: secondary && secondary.window_minutes ? secondary : null },
|
|
163
|
+
...(credits ? { credits } : {}),
|
|
136
164
|
at: Date.now(),
|
|
137
165
|
};
|
|
138
166
|
}
|
|
@@ -180,9 +208,14 @@ function windowResetMs(w, now) {
|
|
|
180
208
|
/**
|
|
181
209
|
* How long an account is out, from a rate-limit snapshot: the latest reset among the windows it
|
|
182
210
|
* has used up, since it is usable only once every full window has reset. Undefined when no window
|
|
183
|
-
* is full — the account is not out, whatever else the snapshot says
|
|
211
|
+
* is full — the account is not out, whatever else the snapshot says — and undefined when the
|
|
212
|
+
* account has credits to spend past the window: the backend keeps serving it, and when the credits
|
|
213
|
+
* run out it says so with a 429, which rests the account the ordinary way.
|
|
184
214
|
*/
|
|
185
|
-
export function exhaustedForMs(snapshot, now = Date.now()) {
|
|
215
|
+
export function exhaustedForMs(snapshot, now = Date.now(), creditsCount = true) {
|
|
216
|
+
const credits = snapshot?.credits;
|
|
217
|
+
if (creditsCount && credits && (credits.has_credits === true || credits.unlimited === true) && credits.overage_limit_reached !== true)
|
|
218
|
+
return undefined;
|
|
186
219
|
const limits = (snapshot?.rate_limits ?? null);
|
|
187
220
|
let out;
|
|
188
221
|
for (const w of [limits?.primary, limits?.secondary]) {
|
|
@@ -760,7 +793,8 @@ export class ChatGptAdapter {
|
|
|
760
793
|
const headerRecord = Object.fromEntries(upstream.headers.entries());
|
|
761
794
|
const snapshot = rateLimitsFromHeaders(upstream.headers);
|
|
762
795
|
const retryHeader = headerRecord["retry-after"] ? retryAfterMs({ "retry-after": headerRecord["retry-after"] }) : undefined;
|
|
763
|
-
|
|
796
|
+
// The account was refused, so whatever credits the headers report did not carry it past the window.
|
|
797
|
+
const waitMs = retryHeader ?? exhaustedForMs(snapshot, Date.now(), false) ?? retryAfterMs(headerRecord);
|
|
764
798
|
// A refusal here is a rest, never a pool quarantine: "sign in again" is the store's to say
|
|
765
799
|
// (needsReauth, above), and a quarantine would outlive the new token a sign-in brings.
|
|
766
800
|
const verdict = this.pool.penalise(this.name, credential.id, credentialRefused ? 403 : status, waitMs, text);
|
|
@@ -1018,12 +1052,41 @@ export class ChatGptAdapter {
|
|
|
1018
1052
|
const parser = new SseParser();
|
|
1019
1053
|
let bytes = 0;
|
|
1020
1054
|
let ping = null;
|
|
1021
|
-
|
|
1055
|
+
// Keep early rejection as an HTTP error until an actual content block arrives.
|
|
1056
|
+
// A 200 followed by an error loses the status Claude uses for overflow recovery.
|
|
1057
|
+
const pendingEvents = [];
|
|
1058
|
+
const openStream = () => {
|
|
1022
1059
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
1023
|
-
|
|
1060
|
+
};
|
|
1061
|
+
const sendEvents = (events) => {
|
|
1062
|
+
if (!wantStream)
|
|
1063
|
+
return;
|
|
1064
|
+
pendingEvents.push(...events);
|
|
1065
|
+
if (!res.headersSent) {
|
|
1066
|
+
if (mapper.failure?.type === "invalid_request_error")
|
|
1067
|
+
return;
|
|
1068
|
+
if (!pendingEvents.some((e) => e.event !== "message_start"))
|
|
1069
|
+
return;
|
|
1070
|
+
openStream();
|
|
1071
|
+
}
|
|
1072
|
+
for (const ev of pendingEvents.splice(0))
|
|
1024
1073
|
bytes += write(res, formatSse(ev));
|
|
1074
|
+
};
|
|
1075
|
+
// The hold waits for upstream's first item, which normally follows the request within a second
|
|
1076
|
+
// — and an overflow rejection comes before it. Should it not come, the client must still hear
|
|
1077
|
+
// something: past this deadline the stream opens with message_start and the pings keep it alive,
|
|
1078
|
+
// as they did before the hold.
|
|
1079
|
+
let holdDeadline = null;
|
|
1080
|
+
if (wantStream) {
|
|
1081
|
+
holdDeadline = setTimeout(() => {
|
|
1082
|
+
if (res.headersSent || res.writableEnded || mapper.failure)
|
|
1083
|
+
return;
|
|
1084
|
+
openStream();
|
|
1085
|
+
for (const ev of [...pendingEvents.splice(0), ...mapper.start()])
|
|
1086
|
+
bytes += write(res, formatSse(ev));
|
|
1087
|
+
}, streamHold.ms);
|
|
1025
1088
|
ping = setInterval(() => {
|
|
1026
|
-
if (!res.writableEnded)
|
|
1089
|
+
if (res.headersSent && !res.writableEnded)
|
|
1027
1090
|
bytes += write(res, formatSse({ event: "ping", data: { type: "ping" } }));
|
|
1028
1091
|
}, PING_MS);
|
|
1029
1092
|
}
|
|
@@ -1039,9 +1102,7 @@ export class ChatGptAdapter {
|
|
|
1039
1102
|
if (mapper.rateLimits && mapper.rateLimits !== this.rateLimitsByAccount.get(credential.ownerId))
|
|
1040
1103
|
this.noteRateLimits(credential, mapper.rateLimits);
|
|
1041
1104
|
this.rememberInput(cacheKey, mapper.usage);
|
|
1042
|
-
|
|
1043
|
-
for (const o of outEvents)
|
|
1044
|
-
bytes += write(res, formatSse(o));
|
|
1105
|
+
sendEvents(outEvents);
|
|
1045
1106
|
if (mapper.isFinished)
|
|
1046
1107
|
break;
|
|
1047
1108
|
}
|
|
@@ -1055,22 +1116,20 @@ export class ChatGptAdapter {
|
|
|
1055
1116
|
const tail = parser.sawDone
|
|
1056
1117
|
? mapper.finish()
|
|
1057
1118
|
: mapper.fail(`${model}: upstream stream ended before the response completed`, "server_is_overloaded");
|
|
1058
|
-
|
|
1059
|
-
for (const o of tail)
|
|
1060
|
-
bytes += write(res, formatSse(o));
|
|
1119
|
+
sendEvents(tail);
|
|
1061
1120
|
}
|
|
1062
1121
|
}
|
|
1063
1122
|
catch (e) {
|
|
1064
1123
|
if (!ac.signal.aborted) {
|
|
1065
1124
|
const tail = mapper.fail(`stream interrupted: ${e.message}`, "server_is_overloaded");
|
|
1066
|
-
|
|
1067
|
-
for (const o of tail)
|
|
1068
|
-
bytes += write(res, formatSse(o));
|
|
1125
|
+
sendEvents(tail);
|
|
1069
1126
|
}
|
|
1070
1127
|
}
|
|
1071
1128
|
finally {
|
|
1072
1129
|
if (ping)
|
|
1073
1130
|
clearInterval(ping);
|
|
1131
|
+
if (holdDeadline)
|
|
1132
|
+
clearTimeout(holdDeadline);
|
|
1074
1133
|
res.off("close", onClose);
|
|
1075
1134
|
try {
|
|
1076
1135
|
await reader.cancel();
|
|
@@ -1080,8 +1139,8 @@ export class ChatGptAdapter {
|
|
|
1080
1139
|
}
|
|
1081
1140
|
}
|
|
1082
1141
|
const failure = mapper.failure;
|
|
1083
|
-
const failedStatus = failure?.type === "overloaded_error" ? 529 : failure?.type === "rate_limit_error" ? 429 : 502;
|
|
1084
|
-
if (!wantStream) {
|
|
1142
|
+
const failedStatus = failure?.type === "invalid_request_error" ? 400 : failure?.type === "overloaded_error" ? 529 : failure?.type === "rate_limit_error" ? 429 : 502;
|
|
1143
|
+
if (!wantStream || (failure && !res.headersSent)) {
|
|
1085
1144
|
// A failed turn is an error here too, not a 200 carrying whatever arrived before it failed.
|
|
1086
1145
|
const msg = failure ? anthropicError(failedStatus, failure.type, failure.message).body : JSON.stringify(mapper.message());
|
|
1087
1146
|
res.writeHead(failure ? failedStatus : 200, { "content-type": "application/json", "content-length": String(Buffer.byteLength(msg)) }).end(msg);
|
|
@@ -1097,7 +1156,7 @@ export class ChatGptAdapter {
|
|
|
1097
1156
|
bytes,
|
|
1098
1157
|
note: failure
|
|
1099
1158
|
? `${wantStream ? "mid-stream " : ""}${failure.type}: ${failure.message} (in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens})`
|
|
1100
|
-
: `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason}`,
|
|
1159
|
+
: `in=${u.input_tokens} cached=${u.cache_read_input_tokens} out=${u.output_tokens} stop=${mapper.stopReason}${upstreamReq.service_tier ? " fast" : ""}`,
|
|
1101
1160
|
usage: {
|
|
1102
1161
|
input: u.input_tokens,
|
|
1103
1162
|
cached: u.cache_read_input_tokens,
|
|
@@ -161,10 +161,11 @@ export function toolNameForResponses(name) {
|
|
|
161
161
|
// worst failure shape there is. `compat.ts` has dropped them on the anthropic-compatible path from
|
|
162
162
|
// the start; the rule belongs here too, and is the same rule, not a second one.
|
|
163
163
|
//
|
|
164
|
-
//
|
|
165
|
-
// small model — measured 2026-09-17: an Opus session and a DeepSeek-routed session both sent it
|
|
166
|
-
// `claude-haiku-4-5`, which passes through to Anthropic and never reaches an adapter. Which model
|
|
167
|
-
// that is, is a server-side flag we do not own, so this guards the day it changes.
|
|
164
|
+
// `web_search` does not arrive today. Claude Code runs its web search as a separate side request on a
|
|
165
|
+
// fixed small model — measured 2026-09-17: an Opus session and a DeepSeek-routed session both sent it
|
|
166
|
+
// to `claude-haiku-4-5`, which passes through to Anthropic and never reaches an adapter. Which model
|
|
167
|
+
// that is, is a server-side flag we do not own, so this guards the day it changes. The `advisor`
|
|
168
|
+
// server tool does arrive: see `droppedServerToolNote`.
|
|
168
169
|
export function isServerTool(tool) {
|
|
169
170
|
const type = tool.type;
|
|
170
171
|
return type !== undefined && type !== "custom";
|
|
@@ -177,6 +178,26 @@ export function serverToolNames(tools) {
|
|
|
177
178
|
names.add(t.name);
|
|
178
179
|
return names;
|
|
179
180
|
}
|
|
181
|
+
// Dropping the declaration is half of it (issue #47). Claude Code also tells the model, in text, how
|
|
182
|
+
// to use the tool: the advisor comes with an `isMeta` user message ("# Advisor Tool / You have access
|
|
183
|
+
// to an `advisor` tool…") on every subagent, whatever its `tools:` list says (CLI 2.1.286, measured
|
|
184
|
+
// 2026-10-04: `dropped server tools for gpt-6-astra: advisor` on every worker request). A routed
|
|
185
|
+
// worker reads the instruction, finds no such tool, and says so in each report. The CLI withdraws its
|
|
186
|
+
// own instruction with one line when it removes the tool ("The advisor tool is no longer available;
|
|
187
|
+
// disregard the earlier advisor instructions."); a line does the same here, without matching wording
|
|
188
|
+
// that changes from release to release. Sorted, so one set always yields the same bytes and the
|
|
189
|
+
// cached prefix does not move.
|
|
190
|
+
/** One line withdrawing the instructions for the server tools a request had to drop; "" when none. */
|
|
191
|
+
export function droppedServerToolNote(dropped) {
|
|
192
|
+
if (dropped.size === 0)
|
|
193
|
+
return "";
|
|
194
|
+
const names = [...dropped].sort().map((n) => `\`${n}\``).join(", ");
|
|
195
|
+
return `Not available in this session: ${names}. Anthropic runs these tools on its own servers, so this model cannot call them. Disregard any instruction to use them and carry on without them; there is no need to mention that they are missing.`;
|
|
196
|
+
}
|
|
197
|
+
/** The caller's system text with that line after it, so every translator places it the same way. */
|
|
198
|
+
export function systemWithDroppedToolNote(req) {
|
|
199
|
+
return [systemText(req.system), droppedServerToolNote(serverToolNames(req.tools))].filter(Boolean).join("\n\n");
|
|
200
|
+
}
|
|
180
201
|
/**
|
|
181
202
|
* Mangled name → original, for the tools declared in this request. The model echoes the name it was
|
|
182
203
|
* given and Claude Code matches `tool_use.name` against its own tool list, so the response path has
|
|
@@ -199,7 +220,7 @@ export function toResponsesRequest(req, opts) {
|
|
|
199
220
|
// Constant per (model, effort): changing effort mid-session costs one cache miss, which is acceptable.
|
|
200
221
|
if (opts.identity)
|
|
201
222
|
parts.push(identityLine(opts.model, opts.effort));
|
|
202
|
-
const sys =
|
|
223
|
+
const sys = systemWithDroppedToolNote(req);
|
|
203
224
|
if (sys)
|
|
204
225
|
parts.push(sys);
|
|
205
226
|
if (opts.instructionsAppend)
|
|
@@ -308,6 +329,10 @@ export function toResponsesRequest(req, opts) {
|
|
|
308
329
|
}
|
|
309
330
|
if (tool_choice)
|
|
310
331
|
out.tool_choice = tool_choice;
|
|
332
|
+
// Claude Code's `/fast` arrives as `speed: "fast"` (Anthropic fast mode). The ChatGPT backend's
|
|
333
|
+
// equivalent is the Codex CLI's Fast mode, which bills the plan at a higher rate (Codex docs, Speed).
|
|
334
|
+
if (req.speed === "fast")
|
|
335
|
+
out.service_tier = "priority";
|
|
311
336
|
return out;
|
|
312
337
|
}
|
|
313
338
|
/** Rough token estimate for /v1/messages/count_tokens when the model is not Anthropic's. */
|
|
@@ -506,7 +531,10 @@ export class StreamMapper {
|
|
|
506
531
|
if (this.finished)
|
|
507
532
|
return [];
|
|
508
533
|
this.finished = true;
|
|
509
|
-
const
|
|
534
|
+
const contextOverflow = code === "context_length_exceeded" || /input exceeds the context window/i.test(message);
|
|
535
|
+
const type = contextOverflow ? "invalid_request_error" : code === "server_is_overloaded" ? "overloaded_error" : code === "rate_limit_exceeded" || code === "usage_limit_reached" ? "rate_limit_error" : "api_error";
|
|
536
|
+
if (contextOverflow)
|
|
537
|
+
message = `prompt is too long: ${message}`;
|
|
510
538
|
this.failure = { type, message };
|
|
511
539
|
return [...this.start(), ...this.closeBlock(), { event: "error", data: { type: "error", error: { type, message } } }];
|
|
512
540
|
}
|