@yanzc/claude-pro 0.5.6 → 0.5.8
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/README.md +3 -1
- package/package.json +1 -1
- package/src/agent.js +2 -0
- package/src/api.js +591 -591
- package/src/cache.js +17 -0
- package/src/commands.js +1 -1
- package/src/session.js +8 -0
- package/src/ui/ansi.js +2 -0
- package/src/ui/render.js +8 -3
- package/src/ui/repl.js +2327 -2205
- package/src/ui/screen.js +184 -7
package/src/api.js
CHANGED
|
@@ -1,591 +1,591 @@
|
|
|
1
|
-
// Thin layer over the Anthropic SDK: builds request params from config and turns the
|
|
2
|
-
// raw SSE stream into a fully assembled assistant message while reporting progress.
|
|
3
|
-
|
|
4
|
-
import Anthropic from '@anthropic-ai/sdk';
|
|
5
|
-
import { readClaudeCodeLogin, claudeCodeHeaders, CLAUDE_CODE_API_URL, CLAUDE_CODE_SYSTEM } from './claude-code.js';
|
|
6
|
-
import { refreshClaudeCodeLogin, canRenewLogin } from './oauth.js';
|
|
7
|
-
import { humanDuration, parseRateLimit, parseRetryAfter, planRetry, sleepUntil, timing } from './ratelimit.js';
|
|
8
|
-
import { cacheControl, cachePlanOf, fingerprint } from './cache.js';
|
|
9
|
-
import { readGatewayLogin, fetchGatewayModels, cachedGatewayModel, gatewayEffort, gatewayDefaultEffort } from './gateway.js';
|
|
10
|
-
import { createCodexFetch, codexModels, codexEffort, nearestEffort } from './codex/client.js';
|
|
11
|
-
import { SIGNATURE_PREFIX } from './codex/translate-request.js';
|
|
12
|
-
import { fitImagesIn } from './utils/images.js';
|
|
13
|
-
import { costOf } from './pricing.js';
|
|
14
|
-
|
|
15
|
-
let cached = null;
|
|
16
|
-
|
|
17
|
-
const LOGIN_POLL_MS = 15_000; // how often to look for a refreshed login after a pause
|
|
18
|
-
const LOGIN_RENEW_RETRY_MS = 5 * 60_000; // how often to try renewing it while waiting for one
|
|
19
|
-
|
|
20
|
-
// Renew the subscription login if it needs it. Returns true when the file now holds a new login. A login
|
|
21
|
-
// that cannot be renewed is an error with its reason, in place of the bare "token has expired" -- unless
|
|
22
|
-
// the caller has its own way of dealing with that: `onError` is told and the call returns false (the
|
|
23
|
-
// wait for a login), and so does `force` (a 401: the request is reported as it is).
|
|
24
|
-
async function renewLogin(config, signal, { force = false, onError } = {}) {
|
|
25
|
-
try {
|
|
26
|
-
return (await refreshClaudeCodeLogin(config, { signal, force })).refreshed;
|
|
27
|
-
} catch (err) {
|
|
28
|
-
if (err.name === 'AbortError' || signal?.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError', partial: [] });
|
|
29
|
-
if (onError) { onError(err); return false; }
|
|
30
|
-
if (force) return false;
|
|
31
|
-
throw err;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// The login expiry worth warning about before a long pause: none when this program renews it itself.
|
|
36
|
-
const loginExpiryToWarnAbout = (config, login) => (config.usesClaudeCode && !canRenewLogin(config) ? login?.expiresAt ?? null : null);
|
|
37
|
-
|
|
38
|
-
// The subscription login to use once a pause is over. Its access token lives 8 hours, so after hours of
|
|
39
|
-
// waiting it has most likely expired: it is renewed first, with the refresh token that is stored with it.
|
|
40
|
-
// If that is not possible (the refresh token was refused, the token server cannot be reached for the
|
|
41
|
-
// moment, renewing is switched off) the wait is not wasted by failing at its end: keep looking for a
|
|
42
|
-
// valid login for what is left of the waiting budget, renewing again every few minutes, and say so. A
|
|
43
|
-
// login done in the meantime (running `claude`) is picked up. The file may also be half written at this
|
|
44
|
-
// very moment by another process.
|
|
45
|
-
async function loginAfterPause(config, state, { signal, onEvent }) {
|
|
46
|
-
let spent = 0;
|
|
47
|
-
let renewedAt = 0;
|
|
48
|
-
let renewError = null;
|
|
49
|
-
for (;;) {
|
|
50
|
-
if (!renewedAt || timing.now() - renewedAt >= LOGIN_RENEW_RETRY_MS) {
|
|
51
|
-
renewedAt = timing.now();
|
|
52
|
-
renewError = null;
|
|
53
|
-
await renewLogin(config, signal, { onError: (err) => { renewError = err; } });
|
|
54
|
-
}
|
|
55
|
-
try {
|
|
56
|
-
return readClaudeCodeLogin(config);
|
|
57
|
-
} catch (err) {
|
|
58
|
-
const left = config.rateLimitMaxWait - state.waitedMs;
|
|
59
|
-
if (left < LOGIN_POLL_MS) {
|
|
60
|
-
if (renewError && /access token has expired/.test(err.message)) err.message = renewError.message; // the reason, not just the fact
|
|
61
|
-
if (spent) err.message += ` (Waited ${humanDuration(spent)} for it after the limit reset.)`;
|
|
62
|
-
throw err;
|
|
63
|
-
}
|
|
64
|
-
const now = timing.now();
|
|
65
|
-
onEvent({
|
|
66
|
-
type: 'rate_limit_wait', error: err, delayMs: LOGIN_POLL_MS, resumeAt: now + LOGIN_POLL_MS, resetAt: null, label: 'login',
|
|
67
|
-
polling: true, waitingForLogin: true, giveUpAt: now + left, waitedMs: state.waitedMs, maxWaitMs: config.rateLimitMaxWait,
|
|
68
|
-
renewError: renewError?.message || null,
|
|
69
|
-
});
|
|
70
|
-
state.waitedMs += LOGIN_POLL_MS;
|
|
71
|
-
spent += LOGIN_POLL_MS;
|
|
72
|
-
await sleepUntil(now + LOGIN_POLL_MS, signal);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
let codexFetch = null;
|
|
78
|
-
|
|
79
|
-
// The gateway provider's key, from /login. Without one the request fails the way a refused key does.
|
|
80
|
-
function gatewayKey(config) {
|
|
81
|
-
const login = readGatewayLogin(config.gatewayUrl);
|
|
82
|
-
if (!login) throw new APIError(`Not logged in to the gateway ${config.gatewayUrl}. Run /login and choose the gateway.`, { status: 401, retryable: false });
|
|
83
|
-
return login.apiKey;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function getClient(config, login = null) {
|
|
87
|
-
if (config.usesGateway || config.usesCodex) {
|
|
88
|
-
const gateway = config.usesGateway;
|
|
89
|
-
const baseURL = gateway ? config.gatewayUrl : 'https://codex.invalid';
|
|
90
|
-
const authToken = gateway ? gatewayKey(config) : 'codex-login';
|
|
91
|
-
if (!gateway) codexFetch ||= createCodexFetch({ env: { ...process.env, ...config.env } });
|
|
92
|
-
const key = `${config.provider}|${baseURL}|${authToken}|${config.apiTimeout}`;
|
|
93
|
-
if (cached?.key === key && cached.fetch === globalThis.fetch) return cached.client;
|
|
94
|
-
const client = new Anthropic({
|
|
95
|
-
baseURL,
|
|
96
|
-
authToken,
|
|
97
|
-
apiKey: null,
|
|
98
|
-
timeout: config.apiTimeout,
|
|
99
|
-
maxRetries: 0,
|
|
100
|
-
...(gateway ? {} : { fetch: codexFetch }),
|
|
101
|
-
});
|
|
102
|
-
cached = { key, client, fetch: globalThis.fetch };
|
|
103
|
-
return client;
|
|
104
|
-
}
|
|
105
|
-
if (config.usesClaudeCode) login ||= readClaudeCodeLogin(config);
|
|
106
|
-
// Subscription credentials must only go to Anthropic, regardless of an API
|
|
107
|
-
// endpoint or custom auth headers left over from .env (e.g. DeepSeek).
|
|
108
|
-
const baseURL = login ? CLAUDE_CODE_API_URL : config.baseURL;
|
|
109
|
-
const authToken = login ? login.accessToken : config.authToken;
|
|
110
|
-
const apiKey = login || authToken ? null : (config.apiKey || null);
|
|
111
|
-
const headers = login ? claudeCodeHeaders(login) : config.customHeaders;
|
|
112
|
-
const key = `${baseURL}|${authToken}|${apiKey}|${JSON.stringify(headers)}|${config.apiTimeout}`;
|
|
113
|
-
if (cached?.key === key && cached.fetch === globalThis.fetch) return cached.client;
|
|
114
|
-
const client = new Anthropic({
|
|
115
|
-
baseURL,
|
|
116
|
-
// Prefer the bearer token; pass null explicitly so the SDK never falls back to env vars.
|
|
117
|
-
authToken,
|
|
118
|
-
apiKey,
|
|
119
|
-
defaultHeaders: headers,
|
|
120
|
-
defaultQuery: login ? { beta: 'true' } : undefined,
|
|
121
|
-
timeout: config.apiTimeout,
|
|
122
|
-
maxRetries: 0, // retries are handled in streamMessage so the UI can report them
|
|
123
|
-
|
|
124
|
-
dangerouslyAllowBrowser: false,
|
|
125
|
-
});
|
|
126
|
-
cached = { key, client, fetch: globalThis.fetch };
|
|
127
|
-
return client;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Mark the last block of the latest user message as cacheable (`mark` is the cache_control to put there; null
|
|
131
|
-
// leaves the messages alone). With the markers on the system prompt and the last tool this makes three of the
|
|
132
|
-
// four breakpoints a request may have. Endpoints that don't support prompt caching (e.g. DeepSeek, which caches
|
|
133
|
-
// automatically) ignore them.
|
|
134
|
-
function withCacheControl(messages, mark) {
|
|
135
|
-
if (!mark || !messages.length) return messages;
|
|
136
|
-
const out = messages.slice();
|
|
137
|
-
const idx = out.length - 1;
|
|
138
|
-
const last = out[idx];
|
|
139
|
-
if (last.role !== 'user') return out;
|
|
140
|
-
const content = typeof last.content === 'string' ? [{ type: 'text', text: last.content }] : last.content.slice();
|
|
141
|
-
if (!content.length) return out;
|
|
142
|
-
const tail = content.length - 1;
|
|
143
|
-
content[tail] = { ...content[tail], cache_control: mark };
|
|
144
|
-
out[idx] = { ...last, content };
|
|
145
|
-
return out;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// What an endpoint refused (a gateway that does not know cache_control, say), by endpoint and model: the
|
|
149
|
-
// places (system, tools, messages) it was refused on, and "ttl" when the 1-hour TTL was. Learned from its 400
|
|
150
|
-
// errors. Claude Code carries on without the marker instead of failing the request, and so does this: the
|
|
151
|
-
// marker is left off from then on, so the endpoint is not asked the same thing again.
|
|
152
|
-
const cacheRefused = new Map();
|
|
153
|
-
const cacheKey = (config, modelId, login) => `${login ? 'claude-code' : config.baseURL}|${modelId}`;
|
|
154
|
-
|
|
155
|
-
const PLACES = ['system', 'tools', 'messages'];
|
|
156
|
-
const blocksAt = (params, place) => {
|
|
157
|
-
if (place === 'system') return params.system || [];
|
|
158
|
-
if (place === 'tools') return params.tools || [];
|
|
159
|
-
return (params.messages || []).flatMap((m) => (Array.isArray(m.content) ? m.content : []));
|
|
160
|
-
};
|
|
161
|
-
const hasMark = (params, place) => blocksAt(params, place).some((b) => b.cache_control);
|
|
162
|
-
|
|
163
|
-
// What an endpoint has refused so far, for /status: the places (system, tools, messages) and "ttl".
|
|
164
|
-
export function cacheRefusals(config, modelId) {
|
|
165
|
-
return [...(cacheRefused.get(cacheKey(config, modelId, config.usesClaudeCode)) || [])];
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Take cache_control (or only its ttl) off a request after the endpoint refused it. Returns what was taken off
|
|
169
|
-
// (a set of places, or "ttl"), empty when there was nothing left to take off: then the caller must give up on
|
|
170
|
-
// the request. The refusal is only remembered once a request has got through without it (see streamMessage):
|
|
171
|
-
// an error that merely mentions cache_control must not switch caching off for good.
|
|
172
|
-
function refuseCache(params, message) {
|
|
173
|
-
const text = String(message || '').toLowerCase();
|
|
174
|
-
const taken = new Set();
|
|
175
|
-
if (/\bttl\b/.test(text) && cachePlanOf(params).ttl === '1h') {
|
|
176
|
-
// "a ttl='1h' cache_control block must occur before ...", "ttl: Extra inputs are not permitted": the 5-minute default
|
|
177
|
-
for (const place of PLACES) for (const b of blocksAt(params, place)) if (b.cache_control?.ttl) b.cache_control = { type: 'ephemeral' };
|
|
178
|
-
taken.add('ttl');
|
|
179
|
-
return taken;
|
|
180
|
-
}
|
|
181
|
-
const marked = PLACES.filter((p) => hasMark(params, p));
|
|
182
|
-
const named = marked.filter((p) => new RegExp(`\\b${p}\\b`).test(text));
|
|
183
|
-
// An error that says where is believed. One that does not ("a maximum of 4 blocks ... may be provided") loses
|
|
184
|
-
// one place at a time, the newest first, so that as much of the caching as possible is kept.
|
|
185
|
-
const places = named.length ? named : ['messages', 'tools', 'system'].filter((p) => marked.includes(p)).slice(0, 1);
|
|
186
|
-
for (const place of places) {
|
|
187
|
-
for (const b of blocksAt(params, place)) delete b.cache_control;
|
|
188
|
-
taken.add(place);
|
|
189
|
-
}
|
|
190
|
-
return taken;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Output ceilings the API has told us about, by model id (learned from "max_tokens too large" errors).
|
|
194
|
-
const outputCaps = new Map();
|
|
195
|
-
// Models that refused the effort parameter (Haiku does): it is left out for them from then on.
|
|
196
|
-
const noEffort = new Set();
|
|
197
|
-
export const effortSupported = (modelId) => !noEffort.has(modelId);
|
|
198
|
-
// Models that refused thinking "disabled" (MAX_THINKING_TOKENS=0): Opus 5.5, Fable, Sonnet 5.5, and Opus 5 at a
|
|
199
|
-
// high effort. Their requests leave `thinking` out from then on, which is adaptive thinking.
|
|
200
|
-
const thinkingStaysOn = new Set();
|
|
201
|
-
|
|
202
|
-
// The effort a model uses when none is sent (docs.claude.com, "Effort"): medium on Opus 5.5 and Haiku 5.5, high on
|
|
203
|
-
// the other models that take one; for a GPT model, the gateway's metadata or the local Codex models cache. null for
|
|
204
|
-
// a model that takes none or is not known.
|
|
205
|
-
// The environment that says where the Codex install is: CODEX_HOME may be set in the shell only.
|
|
206
|
-
const codexEnv = (config) => ({ ...process.env, ...(config?.env || {}) });
|
|
207
|
-
const gatewayModelInfo = (config, id) => (config?.usesGateway ? cachedGatewayModel(readGatewayLogin(config.gatewayUrl), id) : null);
|
|
208
|
-
|
|
209
|
-
export function defaultEffort(modelId, config = null) {
|
|
210
|
-
const id = String(modelId || '');
|
|
211
|
-
if (!effortSupported(id)) return null;
|
|
212
|
-
if (/^gpt-/i.test(id)) {
|
|
213
|
-
return gatewayDefaultEffort(gatewayModelInfo(config, id)) || codexModels(codexEnv(config)).models.find((m) => m.slug === id)?.defaultEffort || null;
|
|
214
|
-
}
|
|
215
|
-
if (/^claude-(?:opus|haiku)-5-5(?:-|$)/i.test(id)) return 'medium';
|
|
216
|
-
if (/^claude-(?:fable|mythos|opus-(?:5|4-[5-8])|sonnet-(?:5|4-6))(?:-|$)/i.test(id)) return 'high';
|
|
217
|
-
return null;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// The effort levels Claude takes, as Claude Code's /effort offers them. OpenAI's none and minimal and the ultra of
|
|
221
|
-
// some GPT models are not among them; a level chosen on a GPT model stays chosen after a switch to Claude.
|
|
222
|
-
export const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
223
|
-
|
|
224
|
-
// The level a request for this model carries when `effort` is chosen: the nearest one the model takes. A GPT model
|
|
225
|
-
// lists its own (the gateway's /v1/models, else the local Codex models cache); Claude takes CLAUDE_EFFORTS.
|
|
226
|
-
export function effortToSend(config, modelId, effort) {
|
|
227
|
-
if (!effort) return effort;
|
|
228
|
-
if (/^claude-/i.test(modelId)) return nearestEffort(effort, CLAUDE_EFFORTS);
|
|
229
|
-
if (!/^gpt-/i.test(modelId)) return effort === 'ultra' ? 'max' : effort;
|
|
230
|
-
const info = gatewayModelInfo(config, modelId);
|
|
231
|
-
return info ? gatewayEffort(info, effort) : codexEffort(modelId, effort, codexEnv(config));
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Pull the allowed maximum out of an error such as
|
|
235
|
-
// "max_tokens: 128000 > 64000, which is the maximum allowed number of output tokens for claude-..."
|
|
236
|
-
// "Invalid max_tokens value, the valid range of max_tokens is [1, 8192]"
|
|
237
|
-
export function parseMaxTokensLimit(message) {
|
|
238
|
-
const text = String(message || '');
|
|
239
|
-
if (!/max_tokens|output tokens/i.test(text)) return null;
|
|
240
|
-
const m = /max_tokens:\s*\d+\s*>\s*(\d+)/i.exec(text)
|
|
241
|
-
|| /range of max_tokens is \[\s*\d+\s*,\s*(\d+)\s*\]/i.exec(text)
|
|
242
|
-
|| /(?:maximum|at most|limit(?:ed)? (?:of|to)|<=|less than or equal to)[^\d]{0,40}(\d{3,7})/i.exec(text);
|
|
243
|
-
const n = m ? parseInt(m[1], 10) : 0;
|
|
244
|
-
return n >= 256 ? n : null;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// Thinking blocks made from Codex reasoning (a GPT model's turns, signed "cgw1:...") replay that reasoning on the
|
|
248
|
-
// Codex backend only: Anthropic refuses their signatures ("Invalid `signature` in `thinking` block"), so a
|
|
249
|
-
// conversation that moves from a GPT model to Claude, or to another provider, sends them without those blocks.
|
|
250
|
-
// The session keeps them, for a later switch back. `inTurn`: blocks were taken out of the turn in progress (the
|
|
251
|
-
// tool loop since the last prompt), whose assistant messages Anthropic wants to start with thinking when a thinking
|
|
252
|
-
// budget is set; such a turn's requests leave `thinking` out until the next prompt. That is no thinking on older
|
|
253
|
-
// models and adaptive thinking on those that cannot turn it off (Opus 5.5, Fable, Sonnet 5.5 refuse "disabled"),
|
|
254
|
-
// which takes assistant messages without thinking.
|
|
255
|
-
const isCodexThinking = (b) => b?.type === 'thinking' && typeof b.signature === 'string' && b.signature.startsWith(SIGNATURE_PREFIX);
|
|
256
|
-
const isPrompt = (m) => m.role === 'user' && !(Array.isArray(m.content) && m.content.some((b) => b.type === 'tool_result'));
|
|
257
|
-
function withoutCodexThinking(messages) {
|
|
258
|
-
const prompt = messages.findLastIndex(isPrompt);
|
|
259
|
-
let inTurn = false;
|
|
260
|
-
const out = messages.flatMap((m, i) => {
|
|
261
|
-
if (m.role !== 'assistant' || !Array.isArray(m.content) || !m.content.some(isCodexThinking)) return [m];
|
|
262
|
-
if (i > prompt) inTurn = true;
|
|
263
|
-
const content = m.content.filter((b) => !isCodexThinking(b));
|
|
264
|
-
return content.length ? [{ ...m, content }] : [];
|
|
265
|
-
});
|
|
266
|
-
return { messages: out, inTurn };
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// `cacheBucket` says which prompt cache TTL applies: "main" for the conversation itself (the default), "other"
|
|
270
|
-
// for sub-agents, compaction and side queries (see Config.cacheTtl). `cacheMessages: false` leaves the messages
|
|
271
|
-
// unmarked: for a request whose messages are never sent twice, writing them to the cache only costs more.
|
|
272
|
-
export function buildParams(config, { model, system, messages, tools, maxTokens, effort, cacheBucket = 'main', cacheMessages = true }, login = null) {
|
|
273
|
-
if (config.usesClaudeCode) login ||= readClaudeCodeLogin(config);
|
|
274
|
-
const wanted = maxTokens || config.maxOutputTokens;
|
|
275
|
-
const refused = cacheRefused.get(cacheKey(config, model.id, login));
|
|
276
|
-
const caching = !config.promptCachingDisabled(model.id);
|
|
277
|
-
const ttl = refused?.has('ttl') ? '5m' : config.cacheTtl(cacheBucket);
|
|
278
|
-
const mark = (place) => (caching && !refused?.has(place) ? { ...cacheControl(ttl) } : null);
|
|
279
|
-
const systemMark = mark('system');
|
|
280
|
-
// The codex provider, and the gateway for a GPT model, take the request to the Codex backend.
|
|
281
|
-
const toCodex = config.usesCodex || /^gpt-/i.test(model.id);
|
|
282
|
-
let turnLostThinking = false;
|
|
283
|
-
if (!toCodex) ({ messages, inTurn: turnLostThinking } = withoutCodexThinking(messages));
|
|
284
|
-
const params = {
|
|
285
|
-
model: model.id,
|
|
286
|
-
max_tokens: outputCaps.has(model.id) ? Math.min(wanted, outputCaps.get(model.id)) : wanted,
|
|
287
|
-
system: [{ type: 'text', text: system, ...(systemMark ? { cache_control: systemMark } : {}) }],
|
|
288
|
-
messages: withCacheControl(messages, cacheMessages ? mark('messages') : null),
|
|
289
|
-
stream: true,
|
|
290
|
-
};
|
|
291
|
-
if (login) {
|
|
292
|
-
params.system.unshift({ type: 'text', text: CLAUDE_CODE_SYSTEM });
|
|
293
|
-
params.metadata = { user_id: login.userId };
|
|
294
|
-
}
|
|
295
|
-
if (tools?.length) {
|
|
296
|
-
const toolMark = mark('tools');
|
|
297
|
-
params.tools = tools.map((t, i) => (i === tools.length - 1 && toolMark ? { ...t, cache_control: toolMark } : t));
|
|
298
|
-
}
|
|
299
|
-
// A GPT model with no effort set gets its default from the gateway's model metadata (or the local Codex cache
|
|
300
|
-
// when using the Codex provider), sent explicitly so that the footer and backend use the same level.
|
|
301
|
-
const eff = effortToSend(config, model.id, (effort === undefined ? config.effort : effort) || (/^gpt-/i.test(model.id) ? defaultEffort(model.id, config) : null));
|
|
302
|
-
if (eff && !noEffort.has(model.id)) params.output_config = { effort: eff };
|
|
303
|
-
const budget = config.thinkingBudget;
|
|
304
|
-
if (budget === 0 && !thinkingStaysOn.has(model.id)) params.thinking = { type: 'disabled' };
|
|
305
|
-
else if (budget > 0) params.thinking = { type: 'enabled', budget_tokens: Math.min(budget, params.max_tokens - 1) };
|
|
306
|
-
if (turnLostThinking) delete params.thinking;
|
|
307
|
-
return params;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
export class APIError extends Error {
|
|
311
|
-
constructor(message, { status, retryable, promptTooLong, retryAfterMs = null, rateLimit = null, overloaded = false } = {}) {
|
|
312
|
-
super(message);
|
|
313
|
-
this.status = status;
|
|
314
|
-
this.retryable = retryable;
|
|
315
|
-
this.promptTooLong = promptTooLong;
|
|
316
|
-
this.overloaded = overloaded; // 529 / overloaded_error: waited out with its own backoff (see planRetry)
|
|
317
|
-
this.retryAfterMs = retryAfterMs; // what the server's Retry-After header asked for
|
|
318
|
-
this.rateLimit = rateLimit; // what the rate limit headers say (see ratelimit.js)
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function describeError(err, config) {
|
|
323
|
-
if (err?.name === 'AbortError' || err instanceof Anthropic.APIUserAbortError) return err;
|
|
324
|
-
const status = err?.status;
|
|
325
|
-
let msg = err?.error?.error?.message || err?.error?.message || err?.message || String(err);
|
|
326
|
-
const promptTooLong = /prompt is too long|context length|maximum context|too many tokens|context_length_exceeded/i.test(msg);
|
|
327
|
-
if (err instanceof APIError) return err;
|
|
328
|
-
if (status === 401) msg = config?.usesClaudeCode
|
|
329
|
-
? `Claude Code OAuth authentication failed (401). Check the existing local subscription credentials in ${config.claudeCodeCredentialsFile}. ${msg}`
|
|
330
|
-
: config?.usesGateway ? `The gateway refused the login (401). Run /login to log in again. ${msg}`
|
|
331
|
-
: config?.usesCodex ? msg
|
|
332
|
-
: `Authentication failed (401). Check ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY. ${msg}`;
|
|
333
|
-
if (status === 402 && config?.usesGateway) msg = `The gateway account's balance is too low for this request. Top up at ${config.gatewayUrl}/user (or run /login to check the balance). ${msg}`;
|
|
334
|
-
const retryable = !status || status === 408 || status === 409 || status === 429 || status >= 500;
|
|
335
|
-
const header = typeof err?.headers?.get === 'function' ? err.headers.get('retry-after') : err?.headers?.['retry-after'];
|
|
336
|
-
const overloaded = status === 529 || (err?.error?.error?.type || err?.error?.type) === 'overloaded_error';
|
|
337
|
-
return new APIError(msg, { status, retryable, promptTooLong, retryAfterMs: parseRetryAfter(header), rateLimit: parseRateLimit(err?.headers, timing.now()), overloaded });
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Our own aborts are named AbortError and the SDK's is an APIUserAbortError. The message test only
|
|
341
|
-
// accepts the bare "aborted" wording: a server error that merely mentions "aborted" is not the user.
|
|
342
|
-
export function isAbort(err) {
|
|
343
|
-
return err?.name === 'AbortError' || err instanceof Anthropic.APIUserAbortError || /^(?:request was )?aborted\.?$/i.test(err?.message || '');
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// USD for a request at the list prices (see pricing.js), by the model that answered, else the one asked for;
|
|
347
|
-
// null when neither has a price. A cost goes to config.onCost.
|
|
348
|
-
function charge(config, requested, served, usage) {
|
|
349
|
-
const cost = costOf(served, usage) ?? costOf(requested, usage);
|
|
350
|
-
if (cost > 0) config.onCost?.(cost);
|
|
351
|
-
return cost;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
// Stream a message. `onEvent` receives:
|
|
355
|
-
// {type:'block_start', index, block}
|
|
356
|
-
// {type:'text', index, text} text delta
|
|
357
|
-
// {type:'thinking', index, text} thinking delta
|
|
358
|
-
// {type:'tool_input', index, partial} partial JSON of a tool call
|
|
359
|
-
// {type:'block_stop', index, block} finished block (tool_use input parsed)
|
|
360
|
-
// {type:'retry', attempt, error, delayMs, maxRetries} a quick retry is about to wait delayMs
|
|
361
|
-
// {type:'rate_limit_wait', error, delayMs, resumeAt, resetAt, label, polling, waitedMs, maxWaitMs, loginExpiresAt}
|
|
362
|
-
// a rate limit was hit: paused until resumeAt, then the request is repeated
|
|
363
|
-
// {type:'rate_limit_resume', waitedMs} the repeated request was accepted: the pause is over
|
|
364
|
-
// {type:'reset'} partial output from a failed attempt must be discarded
|
|
365
|
-
// `waitForRateLimit: false` reports a rate limit as an error instead of waiting for it (side queries
|
|
366
|
-
// where nobody wants to sit through a pause). Returns {message, usage, stopReason, model, cost, cache}.
|
|
367
|
-
// What each request costs (an interrupted one too) goes to config.onCost, the conversation's running total.
|
|
368
|
-
export async function streamMessage(config, request, { signal, onEvent = () => {}, waitForRateLimit = true } = {}) {
|
|
369
|
-
if (signal?.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError', partial: [] });
|
|
370
|
-
// A token that has expired (or is about to) is renewed first, as the official client does.
|
|
371
|
-
if (config.usesClaudeCode) await renewLogin(config, signal);
|
|
372
|
-
let login = config.usesClaudeCode ? readClaudeCodeLogin(config) : null;
|
|
373
|
-
// Load gateway model metadata before the synchronous parameter builder; the cache is keyed by gateway and key,
|
|
374
|
-
// so changing accounts or gateway URLs cannot leak a model's effort levels into another context.
|
|
375
|
-
if (config.usesGateway && /^gpt-/i.test(request.model?.id)) {
|
|
376
|
-
const gatewayLogin = readGatewayLogin(config.gatewayUrl);
|
|
377
|
-
if (gatewayLogin) {
|
|
378
|
-
// A gateway that does not answer costs at most a few seconds once (see fetchGatewayModels), not the request.
|
|
379
|
-
try { await fetchGatewayModels(gatewayLogin, { signal }); } catch { if (signal?.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError', partial: [] }); }
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
let client = getClient(config, login);
|
|
383
|
-
const params = buildParams(config, request, login);
|
|
384
|
-
// The gateway keeps a conversation on the upstream account that served it, where its prompt cache is; the
|
|
385
|
-
// session id names the conversation, in the header Claude Code sends it in.
|
|
386
|
-
const options = { signal, ...(config.usesGateway && config.sessionId ? { headers: { 'X-Claude-Code-Session-Id': config.sessionId } } : {}) };
|
|
387
|
-
let authReloaded = false;
|
|
388
|
-
let imagesFitted = false;
|
|
389
|
-
const refused = new Set(); // what the endpoint refused for this request: remembered once a request got through
|
|
390
|
-
const retryState = { attempt: 0, rateLimitHits: 0, waitedMs: 0, overloaded: 0, overloadedWaitMs: 0 };
|
|
391
|
-
let paused = null; // the plan of the pause in progress (see planRetry)
|
|
392
|
-
for (;;) {
|
|
393
|
-
let emitted = false;
|
|
394
|
-
const startedAt = Date.now(); // the cache TTL counts from the start of the request, not from the end of the reply
|
|
395
|
-
const blocks = [];
|
|
396
|
-
const jsonBufs = [];
|
|
397
|
-
let usage = {};
|
|
398
|
-
let model = params.model;
|
|
399
|
-
try {
|
|
400
|
-
const stream = await client.messages.create(params, options);
|
|
401
|
-
if (paused) {
|
|
402
|
-
const overloaded = paused.overloaded;
|
|
403
|
-
paused = null;
|
|
404
|
-
onEvent({ type: 'rate_limit_resume', waitedMs: overloaded ? retryState.overloadedWaitMs : retryState.waitedMs, ...(overloaded ? { overloaded } : {}) });
|
|
405
|
-
}
|
|
406
|
-
let stopReason = null;
|
|
407
|
-
let stopped = false;
|
|
408
|
-
for await (const ev of stream) {
|
|
409
|
-
switch (ev.type) {
|
|
410
|
-
case 'message_stop':
|
|
411
|
-
stopped = true;
|
|
412
|
-
break;
|
|
413
|
-
case 'message_start':
|
|
414
|
-
usage = { ...ev.message.usage };
|
|
415
|
-
model = ev.message.model || model;
|
|
416
|
-
break;
|
|
417
|
-
case 'content_block_start': {
|
|
418
|
-
const b = { ...ev.content_block };
|
|
419
|
-
if (b.type === 'tool_use') { b.input = {}; jsonBufs[ev.index] = ''; }
|
|
420
|
-
if (b.type === 'text') b.text = b.text || '';
|
|
421
|
-
if (b.type === 'thinking') { b.thinking = b.thinking || ''; b.signature = b.signature || ''; }
|
|
422
|
-
blocks[ev.index] = b;
|
|
423
|
-
emitted = true;
|
|
424
|
-
onEvent({ type: 'block_start', index: ev.index, block: b });
|
|
425
|
-
break;
|
|
426
|
-
}
|
|
427
|
-
case 'content_block_delta': {
|
|
428
|
-
const b = blocks[ev.index];
|
|
429
|
-
const d = ev.delta;
|
|
430
|
-
if (!b) break;
|
|
431
|
-
if (d.type === 'text_delta') { b.text += d.text; onEvent({ type: 'text', index: ev.index, text: d.text }); }
|
|
432
|
-
else if (d.type === 'thinking_delta') { b.thinking += d.thinking; onEvent({ type: 'thinking', index: ev.index, text: d.thinking }); }
|
|
433
|
-
else if (d.type === 'signature_delta') b.signature = (b.signature || '') + d.signature;
|
|
434
|
-
else if (d.type === 'input_json_delta') {
|
|
435
|
-
jsonBufs[ev.index] += d.partial_json;
|
|
436
|
-
onEvent({ type: 'tool_input', index: ev.index, partial: jsonBufs[ev.index], block: b });
|
|
437
|
-
}
|
|
438
|
-
break;
|
|
439
|
-
}
|
|
440
|
-
case 'content_block_stop': {
|
|
441
|
-
const b = blocks[ev.index];
|
|
442
|
-
if (b?.type === 'tool_use') {
|
|
443
|
-
const raw = jsonBufs[ev.index];
|
|
444
|
-
try { b.input = raw ? JSON.parse(raw) : {}; } catch { b.input = { __invalid_json: raw }; }
|
|
445
|
-
}
|
|
446
|
-
if (b) onEvent({ type: 'block_stop', index: ev.index, block: b });
|
|
447
|
-
break;
|
|
448
|
-
}
|
|
449
|
-
case 'message_delta':
|
|
450
|
-
if (ev.delta?.stop_reason) stopReason = ev.delta.stop_reason;
|
|
451
|
-
if (ev.usage) usage = { ...usage, ...Object.fromEntries(Object.entries(ev.usage).filter(([, v]) => v != null)) };
|
|
452
|
-
break;
|
|
453
|
-
case 'error':
|
|
454
|
-
throw Object.assign(new Error(ev.error?.message || 'stream error'), { status: { rate_limit_error: 429, overloaded_error: 529 }[ev.error?.type] || 500 });
|
|
455
|
-
default:
|
|
456
|
-
break;
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
// The SDK ends the iterator quietly when aborted; a stream without message_stop was cut off.
|
|
460
|
-
if (signal?.aborted) {
|
|
461
|
-
const partial = blocks.filter((b) => b?.type === 'text' && b.text.trim()).map((b) => ({ type: 'text', text: b.text }));
|
|
462
|
-
throw Object.assign(new Error('aborted'), { name: 'AbortError', partial });
|
|
463
|
-
}
|
|
464
|
-
if (!stopped && !stopReason) throw Object.assign(new Error('The response stream ended unexpectedly'), { status: 503 });
|
|
465
|
-
const content = blocks.filter(Boolean).filter((b) => !(b.type === 'text' && !b.text));
|
|
466
|
-
if (refused.size) { // it got through without what the endpoint refused: leave that off from now on
|
|
467
|
-
const key = cacheKey(config, params.model, login);
|
|
468
|
-
cacheRefused.set(key, new Set([...(cacheRefused.get(key) || []), ...refused]));
|
|
469
|
-
}
|
|
470
|
-
const cost = charge(config, params.model, model, usage);
|
|
471
|
-
return { message: { role: 'assistant', content }, usage, stopReason, model, cost, cache: { ...cachePlanOf(params), fingerprint: fingerprint(params), at: startedAt } };
|
|
472
|
-
} catch (raw) {
|
|
473
|
-
if (signal?.aborted || isAbort(raw)) {
|
|
474
|
-
// An interrupted reply is billed for what it had read and written: the output so far is estimated.
|
|
475
|
-
const written = blocks.reduce((n, b) => n + (b?.text?.length || 0) + (b?.thinking?.length || 0), 0) + jsonBufs.join('').length;
|
|
476
|
-
charge(config, params.model, model, { ...usage, output_tokens: Math.max(usage.output_tokens || 0, Math.ceil(written / 4)) });
|
|
477
|
-
throw Object.assign(new Error('aborted'), { name: 'AbortError', partial: raw?.partial || [] });
|
|
478
|
-
}
|
|
479
|
-
const err = describeError(raw, config);
|
|
480
|
-
// The token was refused. A running native Claude Code session may have renewed the shared token
|
|
481
|
-
// while this request was in flight: read it again. If it is the same one, it may have been revoked
|
|
482
|
-
// early or the local clock may be off: renew it once and try again.
|
|
483
|
-
if (login && err.status === 401 && !authReloaded) {
|
|
484
|
-
authReloaded = true;
|
|
485
|
-
const readValid = () => { try { return readClaudeCodeLogin(config); } catch { return null; } };
|
|
486
|
-
let updated = readValid();
|
|
487
|
-
if (!updated || updated.accessToken === login.accessToken) {
|
|
488
|
-
let failure = null;
|
|
489
|
-
if (await renewLogin(config, signal, { force: true, onError: (e) => { failure = e; } })) updated = readValid();
|
|
490
|
-
else if (!updated && failure) throw failure; // the token ran out meanwhile and cannot be renewed: say why, not just "401"
|
|
491
|
-
}
|
|
492
|
-
if (updated && updated.accessToken !== login.accessToken) {
|
|
493
|
-
login = updated;
|
|
494
|
-
client = getClient(config, login);
|
|
495
|
-
params.metadata = { user_id: login.userId };
|
|
496
|
-
if (emitted) onEvent({ type: 'reset' });
|
|
497
|
-
continue;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
// Not every model takes an effort level. Leave it out for this one, and for the rest of the session.
|
|
501
|
-
if (err.status === 400 && params.output_config && /does not support the effort parameter/i.test(err.message)) {
|
|
502
|
-
noEffort.add(params.model);
|
|
503
|
-
delete params.output_config;
|
|
504
|
-
continue;
|
|
505
|
-
}
|
|
506
|
-
// A model whose thinking cannot be turned off: carry on with its adaptive thinking.
|
|
507
|
-
if (err.status === 400 && params.thinking?.type === 'disabled' && /thinking\.type\.disabled|"disabled"/i.test(err.message) && /not supported/i.test(err.message)) {
|
|
508
|
-
thinkingStaysOn.add(params.model);
|
|
509
|
-
delete params.thinking;
|
|
510
|
-
continue;
|
|
511
|
-
}
|
|
512
|
-
// The endpoint does not take cache_control (or the 1-hour ttl) where it was sent: carry on without it
|
|
513
|
-
// instead of failing the request, and do not send it there again (see refuseCache).
|
|
514
|
-
if (err.status === 400 && /cache_control|\bttl\b/i.test(err.message)) {
|
|
515
|
-
const taken = refuseCache(params, err.message);
|
|
516
|
-
if (taken.size) {
|
|
517
|
-
for (const item of taken) refused.add(item);
|
|
518
|
-
continue;
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
// An image in the conversation is larger than the API takes (a screenshot read before images were scaled,
|
|
522
|
-
// or a session that only now has enough images for the stricter limit): scale the images in place, which
|
|
523
|
-
// fixes the conversation itself, and try again. Without this every later request of the session fails.
|
|
524
|
-
if (err.status === 400 && !imagesFitted && /image/i.test(err.message) && /dimension|pixels|exceed|too large|too big/i.test(err.message)) {
|
|
525
|
-
imagesFitted = true;
|
|
526
|
-
if (await fitImagesIn(params.messages)) {
|
|
527
|
-
if (emitted) onEvent({ type: 'reset' });
|
|
528
|
-
continue;
|
|
529
|
-
}
|
|
530
|
-
}
|
|
531
|
-
// The default max_tokens is generous: if this API allows less, learn its limit and retry at once.
|
|
532
|
-
const limit = err.status === 400 ? parseMaxTokensLimit(err.message) : null;
|
|
533
|
-
if (limit && limit < params.max_tokens) {
|
|
534
|
-
outputCaps.set(params.model, limit);
|
|
535
|
-
params.max_tokens = limit;
|
|
536
|
-
if (params.thinking?.budget_tokens) params.thinking.budget_tokens = Math.min(params.thinking.budget_tokens, limit - 1);
|
|
537
|
-
continue;
|
|
538
|
-
}
|
|
539
|
-
const plan = planRetry(err, retryState, config, { now: timing.now(), allowWait: waitForRateLimit });
|
|
540
|
-
if (plan.action === 'throw') {
|
|
541
|
-
if (plan.message) err.message = plan.message;
|
|
542
|
-
throw err;
|
|
543
|
-
}
|
|
544
|
-
if (emitted) onEvent({ type: 'reset' });
|
|
545
|
-
if (plan.kind === 'rate_limit') {
|
|
546
|
-
paused = plan;
|
|
547
|
-
onEvent({
|
|
548
|
-
type: 'rate_limit_wait', error: err, delayMs: plan.delayMs, resumeAt: plan.resumeAt, resetAt: plan.resetAt,
|
|
549
|
-
label: plan.label, polling: plan.polling,
|
|
550
|
-
...(plan.overloaded
|
|
551
|
-
? { overloaded: true, waitedMs: retryState.overloadedWaitMs, maxWaitMs: config.overloadedMaxWait }
|
|
552
|
-
: { waitedMs: retryState.waitedMs, maxWaitMs: config.rateLimitMaxWait }),
|
|
553
|
-
loginExpiresAt: loginExpiryToWarnAbout(config, login),
|
|
554
|
-
});
|
|
555
|
-
await sleepUntil(plan.resumeAt, signal);
|
|
556
|
-
// Hours may have passed: pick up the login again, the local Claude Code may have refreshed
|
|
557
|
-
// the token (and if it has expired, wait for that rather than fail with a 401).
|
|
558
|
-
if (config.usesClaudeCode) {
|
|
559
|
-
login = await loginAfterPause(config, retryState, { signal, onEvent });
|
|
560
|
-
client = getClient(config, login);
|
|
561
|
-
params.metadata = { user_id: login.userId };
|
|
562
|
-
}
|
|
563
|
-
retryState.attempt = 0; // the pause is a fresh start: quick retries made before it do not count after it
|
|
564
|
-
} else {
|
|
565
|
-
onEvent({ type: 'retry', attempt: plan.attempt, error: err, delayMs: plan.delayMs, maxRetries: plan.maxRetries });
|
|
566
|
-
await sleepUntil(plan.resumeAt, signal);
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
// One-shot, non-streaming helper for small side queries (summaries, titles, web page extraction).
|
|
573
|
-
// Nothing here can tell the user that it is paused, so a rate limit is reported as an error by default
|
|
574
|
-
// instead of being waited for out of sight; waitForRateLimit: true is for a caller that accepts that.
|
|
575
|
-
export async function quickQuery(config, { model, system, prompt, maxTokens = 4096, signal, waitForRateLimit = false }) {
|
|
576
|
-
const res = await streamMessage(config, {
|
|
577
|
-
model,
|
|
578
|
-
system,
|
|
579
|
-
messages: [{ role: 'user', content: prompt }],
|
|
580
|
-
maxTokens,
|
|
581
|
-
effort: null,
|
|
582
|
-
cacheBucket: 'other',
|
|
583
|
-
cacheMessages: false, // one-off content (a whole web page, say): caching it would only cost the write premium
|
|
584
|
-
}, { signal, waitForRateLimit });
|
|
585
|
-
return res.message.content.filter((b) => b.type === 'text').map((b) => b.text).join('').trim();
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
export function contextTokens(usage) {
|
|
589
|
-
if (!usage) return 0;
|
|
590
|
-
return (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0) + (usage.output_tokens || 0);
|
|
591
|
-
}
|
|
1
|
+
// Thin layer over the Anthropic SDK: builds request params from config and turns the
|
|
2
|
+
// raw SSE stream into a fully assembled assistant message while reporting progress.
|
|
3
|
+
|
|
4
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
5
|
+
import { readClaudeCodeLogin, claudeCodeHeaders, CLAUDE_CODE_API_URL, CLAUDE_CODE_SYSTEM } from './claude-code.js';
|
|
6
|
+
import { refreshClaudeCodeLogin, canRenewLogin } from './oauth.js';
|
|
7
|
+
import { humanDuration, parseRateLimit, parseRetryAfter, planRetry, sleepUntil, timing } from './ratelimit.js';
|
|
8
|
+
import { cacheControl, cachePlanOf, fingerprint } from './cache.js';
|
|
9
|
+
import { readGatewayLogin, fetchGatewayModels, cachedGatewayModel, gatewayEffort, gatewayDefaultEffort } from './gateway.js';
|
|
10
|
+
import { createCodexFetch, codexModels, codexEffort, nearestEffort } from './codex/client.js';
|
|
11
|
+
import { SIGNATURE_PREFIX } from './codex/translate-request.js';
|
|
12
|
+
import { fitImagesIn } from './utils/images.js';
|
|
13
|
+
import { costOf } from './pricing.js';
|
|
14
|
+
|
|
15
|
+
let cached = null;
|
|
16
|
+
|
|
17
|
+
const LOGIN_POLL_MS = 15_000; // how often to look for a refreshed login after a pause
|
|
18
|
+
const LOGIN_RENEW_RETRY_MS = 5 * 60_000; // how often to try renewing it while waiting for one
|
|
19
|
+
|
|
20
|
+
// Renew the subscription login if it needs it. Returns true when the file now holds a new login. A login
|
|
21
|
+
// that cannot be renewed is an error with its reason, in place of the bare "token has expired" -- unless
|
|
22
|
+
// the caller has its own way of dealing with that: `onError` is told and the call returns false (the
|
|
23
|
+
// wait for a login), and so does `force` (a 401: the request is reported as it is).
|
|
24
|
+
async function renewLogin(config, signal, { force = false, onError } = {}) {
|
|
25
|
+
try {
|
|
26
|
+
return (await refreshClaudeCodeLogin(config, { signal, force })).refreshed;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (err.name === 'AbortError' || signal?.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError', partial: [] });
|
|
29
|
+
if (onError) { onError(err); return false; }
|
|
30
|
+
if (force) return false;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The login expiry worth warning about before a long pause: none when this program renews it itself.
|
|
36
|
+
const loginExpiryToWarnAbout = (config, login) => (config.usesClaudeCode && !canRenewLogin(config) ? login?.expiresAt ?? null : null);
|
|
37
|
+
|
|
38
|
+
// The subscription login to use once a pause is over. Its access token lives 8 hours, so after hours of
|
|
39
|
+
// waiting it has most likely expired: it is renewed first, with the refresh token that is stored with it.
|
|
40
|
+
// If that is not possible (the refresh token was refused, the token server cannot be reached for the
|
|
41
|
+
// moment, renewing is switched off) the wait is not wasted by failing at its end: keep looking for a
|
|
42
|
+
// valid login for what is left of the waiting budget, renewing again every few minutes, and say so. A
|
|
43
|
+
// login done in the meantime (running `claude`) is picked up. The file may also be half written at this
|
|
44
|
+
// very moment by another process.
|
|
45
|
+
async function loginAfterPause(config, state, { signal, onEvent }) {
|
|
46
|
+
let spent = 0;
|
|
47
|
+
let renewedAt = 0;
|
|
48
|
+
let renewError = null;
|
|
49
|
+
for (;;) {
|
|
50
|
+
if (!renewedAt || timing.now() - renewedAt >= LOGIN_RENEW_RETRY_MS) {
|
|
51
|
+
renewedAt = timing.now();
|
|
52
|
+
renewError = null;
|
|
53
|
+
await renewLogin(config, signal, { onError: (err) => { renewError = err; } });
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return readClaudeCodeLogin(config);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
const left = config.rateLimitMaxWait - state.waitedMs;
|
|
59
|
+
if (left < LOGIN_POLL_MS) {
|
|
60
|
+
if (renewError && /access token has expired/.test(err.message)) err.message = renewError.message; // the reason, not just the fact
|
|
61
|
+
if (spent) err.message += ` (Waited ${humanDuration(spent)} for it after the limit reset.)`;
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
const now = timing.now();
|
|
65
|
+
onEvent({
|
|
66
|
+
type: 'rate_limit_wait', error: err, delayMs: LOGIN_POLL_MS, resumeAt: now + LOGIN_POLL_MS, resetAt: null, label: 'login',
|
|
67
|
+
polling: true, waitingForLogin: true, giveUpAt: now + left, waitedMs: state.waitedMs, maxWaitMs: config.rateLimitMaxWait,
|
|
68
|
+
renewError: renewError?.message || null,
|
|
69
|
+
});
|
|
70
|
+
state.waitedMs += LOGIN_POLL_MS;
|
|
71
|
+
spent += LOGIN_POLL_MS;
|
|
72
|
+
await sleepUntil(now + LOGIN_POLL_MS, signal);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let codexFetch = null;
|
|
78
|
+
|
|
79
|
+
// The gateway provider's key, from /login. Without one the request fails the way a refused key does.
|
|
80
|
+
function gatewayKey(config) {
|
|
81
|
+
const login = readGatewayLogin(config.gatewayUrl);
|
|
82
|
+
if (!login) throw new APIError(`Not logged in to the gateway ${config.gatewayUrl}. Run /login and choose the gateway.`, { status: 401, retryable: false });
|
|
83
|
+
return login.apiKey;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function getClient(config, login = null) {
|
|
87
|
+
if (config.usesGateway || config.usesCodex) {
|
|
88
|
+
const gateway = config.usesGateway;
|
|
89
|
+
const baseURL = gateway ? config.gatewayUrl : 'https://codex.invalid';
|
|
90
|
+
const authToken = gateway ? gatewayKey(config) : 'codex-login';
|
|
91
|
+
if (!gateway) codexFetch ||= createCodexFetch({ env: { ...process.env, ...config.env } });
|
|
92
|
+
const key = `${config.provider}|${baseURL}|${authToken}|${config.apiTimeout}`;
|
|
93
|
+
if (cached?.key === key && cached.fetch === globalThis.fetch) return cached.client;
|
|
94
|
+
const client = new Anthropic({
|
|
95
|
+
baseURL,
|
|
96
|
+
authToken,
|
|
97
|
+
apiKey: null,
|
|
98
|
+
timeout: config.apiTimeout,
|
|
99
|
+
maxRetries: 0,
|
|
100
|
+
...(gateway ? {} : { fetch: codexFetch }),
|
|
101
|
+
});
|
|
102
|
+
cached = { key, client, fetch: globalThis.fetch };
|
|
103
|
+
return client;
|
|
104
|
+
}
|
|
105
|
+
if (config.usesClaudeCode) login ||= readClaudeCodeLogin(config);
|
|
106
|
+
// Subscription credentials must only go to Anthropic, regardless of an API
|
|
107
|
+
// endpoint or custom auth headers left over from .env (e.g. DeepSeek).
|
|
108
|
+
const baseURL = login ? CLAUDE_CODE_API_URL : config.baseURL;
|
|
109
|
+
const authToken = login ? login.accessToken : config.authToken;
|
|
110
|
+
const apiKey = login || authToken ? null : (config.apiKey || null);
|
|
111
|
+
const headers = login ? claudeCodeHeaders(login) : config.customHeaders;
|
|
112
|
+
const key = `${baseURL}|${authToken}|${apiKey}|${JSON.stringify(headers)}|${config.apiTimeout}`;
|
|
113
|
+
if (cached?.key === key && cached.fetch === globalThis.fetch) return cached.client;
|
|
114
|
+
const client = new Anthropic({
|
|
115
|
+
baseURL,
|
|
116
|
+
// Prefer the bearer token; pass null explicitly so the SDK never falls back to env vars.
|
|
117
|
+
authToken,
|
|
118
|
+
apiKey,
|
|
119
|
+
defaultHeaders: headers,
|
|
120
|
+
defaultQuery: login ? { beta: 'true' } : undefined,
|
|
121
|
+
timeout: config.apiTimeout,
|
|
122
|
+
maxRetries: 0, // retries are handled in streamMessage so the UI can report them
|
|
123
|
+
|
|
124
|
+
dangerouslyAllowBrowser: false,
|
|
125
|
+
});
|
|
126
|
+
cached = { key, client, fetch: globalThis.fetch };
|
|
127
|
+
return client;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Mark the last block of the latest user message as cacheable (`mark` is the cache_control to put there; null
|
|
131
|
+
// leaves the messages alone). With the markers on the system prompt and the last tool this makes three of the
|
|
132
|
+
// four breakpoints a request may have. Endpoints that don't support prompt caching (e.g. DeepSeek, which caches
|
|
133
|
+
// automatically) ignore them.
|
|
134
|
+
function withCacheControl(messages, mark) {
|
|
135
|
+
if (!mark || !messages.length) return messages;
|
|
136
|
+
const out = messages.slice();
|
|
137
|
+
const idx = out.length - 1;
|
|
138
|
+
const last = out[idx];
|
|
139
|
+
if (last.role !== 'user') return out;
|
|
140
|
+
const content = typeof last.content === 'string' ? [{ type: 'text', text: last.content }] : last.content.slice();
|
|
141
|
+
if (!content.length) return out;
|
|
142
|
+
const tail = content.length - 1;
|
|
143
|
+
content[tail] = { ...content[tail], cache_control: mark };
|
|
144
|
+
out[idx] = { ...last, content };
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// What an endpoint refused (a gateway that does not know cache_control, say), by endpoint and model: the
|
|
149
|
+
// places (system, tools, messages) it was refused on, and "ttl" when the 1-hour TTL was. Learned from its 400
|
|
150
|
+
// errors. Claude Code carries on without the marker instead of failing the request, and so does this: the
|
|
151
|
+
// marker is left off from then on, so the endpoint is not asked the same thing again.
|
|
152
|
+
const cacheRefused = new Map();
|
|
153
|
+
const cacheKey = (config, modelId, login) => `${login ? 'claude-code' : config.baseURL}|${modelId}`;
|
|
154
|
+
|
|
155
|
+
const PLACES = ['system', 'tools', 'messages'];
|
|
156
|
+
const blocksAt = (params, place) => {
|
|
157
|
+
if (place === 'system') return params.system || [];
|
|
158
|
+
if (place === 'tools') return params.tools || [];
|
|
159
|
+
return (params.messages || []).flatMap((m) => (Array.isArray(m.content) ? m.content : []));
|
|
160
|
+
};
|
|
161
|
+
const hasMark = (params, place) => blocksAt(params, place).some((b) => b.cache_control);
|
|
162
|
+
|
|
163
|
+
// What an endpoint has refused so far, for /status: the places (system, tools, messages) and "ttl".
|
|
164
|
+
export function cacheRefusals(config, modelId) {
|
|
165
|
+
return [...(cacheRefused.get(cacheKey(config, modelId, config.usesClaudeCode)) || [])];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Take cache_control (or only its ttl) off a request after the endpoint refused it. Returns what was taken off
|
|
169
|
+
// (a set of places, or "ttl"), empty when there was nothing left to take off: then the caller must give up on
|
|
170
|
+
// the request. The refusal is only remembered once a request has got through without it (see streamMessage):
|
|
171
|
+
// an error that merely mentions cache_control must not switch caching off for good.
|
|
172
|
+
function refuseCache(params, message) {
|
|
173
|
+
const text = String(message || '').toLowerCase();
|
|
174
|
+
const taken = new Set();
|
|
175
|
+
if (/\bttl\b/.test(text) && cachePlanOf(params).ttl === '1h') {
|
|
176
|
+
// "a ttl='1h' cache_control block must occur before ...", "ttl: Extra inputs are not permitted": the 5-minute default
|
|
177
|
+
for (const place of PLACES) for (const b of blocksAt(params, place)) if (b.cache_control?.ttl) b.cache_control = { type: 'ephemeral' };
|
|
178
|
+
taken.add('ttl');
|
|
179
|
+
return taken;
|
|
180
|
+
}
|
|
181
|
+
const marked = PLACES.filter((p) => hasMark(params, p));
|
|
182
|
+
const named = marked.filter((p) => new RegExp(`\\b${p}\\b`).test(text));
|
|
183
|
+
// An error that says where is believed. One that does not ("a maximum of 4 blocks ... may be provided") loses
|
|
184
|
+
// one place at a time, the newest first, so that as much of the caching as possible is kept.
|
|
185
|
+
const places = named.length ? named : ['messages', 'tools', 'system'].filter((p) => marked.includes(p)).slice(0, 1);
|
|
186
|
+
for (const place of places) {
|
|
187
|
+
for (const b of blocksAt(params, place)) delete b.cache_control;
|
|
188
|
+
taken.add(place);
|
|
189
|
+
}
|
|
190
|
+
return taken;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Output ceilings the API has told us about, by model id (learned from "max_tokens too large" errors).
|
|
194
|
+
const outputCaps = new Map();
|
|
195
|
+
// Models that refused the effort parameter (Haiku does): it is left out for them from then on.
|
|
196
|
+
const noEffort = new Set();
|
|
197
|
+
export const effortSupported = (modelId) => !noEffort.has(modelId);
|
|
198
|
+
// Models that refused thinking "disabled" (MAX_THINKING_TOKENS=0): Opus 5.5, Fable, Sonnet 5.5, and Opus 5 at a
|
|
199
|
+
// high effort. Their requests leave `thinking` out from then on, which is adaptive thinking.
|
|
200
|
+
const thinkingStaysOn = new Set();
|
|
201
|
+
|
|
202
|
+
// The effort a model uses when none is sent (docs.claude.com, "Effort"): medium on Opus 5.5 and Haiku 5.5, high on
|
|
203
|
+
// the other models that take one; for a GPT model, the gateway's metadata or the local Codex models cache. null for
|
|
204
|
+
// a model that takes none or is not known.
|
|
205
|
+
// The environment that says where the Codex install is: CODEX_HOME may be set in the shell only.
|
|
206
|
+
const codexEnv = (config) => ({ ...process.env, ...(config?.env || {}) });
|
|
207
|
+
const gatewayModelInfo = (config, id) => (config?.usesGateway ? cachedGatewayModel(readGatewayLogin(config.gatewayUrl), id) : null);
|
|
208
|
+
|
|
209
|
+
export function defaultEffort(modelId, config = null) {
|
|
210
|
+
const id = String(modelId || '');
|
|
211
|
+
if (!effortSupported(id)) return null;
|
|
212
|
+
if (/^gpt-/i.test(id)) {
|
|
213
|
+
return gatewayDefaultEffort(gatewayModelInfo(config, id)) || codexModels(codexEnv(config)).models.find((m) => m.slug === id)?.defaultEffort || null;
|
|
214
|
+
}
|
|
215
|
+
if (/^claude-(?:opus|haiku)-5-5(?:-|$)/i.test(id)) return 'medium';
|
|
216
|
+
if (/^claude-(?:fable|mythos|opus-(?:5|4-[5-8])|sonnet-(?:5|4-6))(?:-|$)/i.test(id)) return 'high';
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// The effort levels Claude takes, as Claude Code's /effort offers them. OpenAI's none and minimal and the ultra of
|
|
221
|
+
// some GPT models are not among them; a level chosen on a GPT model stays chosen after a switch to Claude.
|
|
222
|
+
export const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
223
|
+
|
|
224
|
+
// The level a request for this model carries when `effort` is chosen: the nearest one the model takes. A GPT model
|
|
225
|
+
// lists its own (the gateway's /v1/models, else the local Codex models cache); Claude takes CLAUDE_EFFORTS.
|
|
226
|
+
export function effortToSend(config, modelId, effort) {
|
|
227
|
+
if (!effort) return effort;
|
|
228
|
+
if (/^claude-/i.test(modelId)) return nearestEffort(effort, CLAUDE_EFFORTS);
|
|
229
|
+
if (!/^gpt-/i.test(modelId)) return effort === 'ultra' ? 'max' : effort;
|
|
230
|
+
const info = gatewayModelInfo(config, modelId);
|
|
231
|
+
return info ? gatewayEffort(info, effort) : codexEffort(modelId, effort, codexEnv(config));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Pull the allowed maximum out of an error such as
|
|
235
|
+
// "max_tokens: 128000 > 64000, which is the maximum allowed number of output tokens for claude-..."
|
|
236
|
+
// "Invalid max_tokens value, the valid range of max_tokens is [1, 8192]"
|
|
237
|
+
export function parseMaxTokensLimit(message) {
|
|
238
|
+
const text = String(message || '');
|
|
239
|
+
if (!/max_tokens|output tokens/i.test(text)) return null;
|
|
240
|
+
const m = /max_tokens:\s*\d+\s*>\s*(\d+)/i.exec(text)
|
|
241
|
+
|| /range of max_tokens is \[\s*\d+\s*,\s*(\d+)\s*\]/i.exec(text)
|
|
242
|
+
|| /(?:maximum|at most|limit(?:ed)? (?:of|to)|<=|less than or equal to)[^\d]{0,40}(\d{3,7})/i.exec(text);
|
|
243
|
+
const n = m ? parseInt(m[1], 10) : 0;
|
|
244
|
+
return n >= 256 ? n : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Thinking blocks made from Codex reasoning (a GPT model's turns, signed "cgw1:...") replay that reasoning on the
|
|
248
|
+
// Codex backend only: Anthropic refuses their signatures ("Invalid `signature` in `thinking` block"), so a
|
|
249
|
+
// conversation that moves from a GPT model to Claude, or to another provider, sends them without those blocks.
|
|
250
|
+
// The session keeps them, for a later switch back. `inTurn`: blocks were taken out of the turn in progress (the
|
|
251
|
+
// tool loop since the last prompt), whose assistant messages Anthropic wants to start with thinking when a thinking
|
|
252
|
+
// budget is set; such a turn's requests leave `thinking` out until the next prompt. That is no thinking on older
|
|
253
|
+
// models and adaptive thinking on those that cannot turn it off (Opus 5.5, Fable, Sonnet 5.5 refuse "disabled"),
|
|
254
|
+
// which takes assistant messages without thinking.
|
|
255
|
+
const isCodexThinking = (b) => b?.type === 'thinking' && typeof b.signature === 'string' && b.signature.startsWith(SIGNATURE_PREFIX);
|
|
256
|
+
const isPrompt = (m) => m.role === 'user' && !(Array.isArray(m.content) && m.content.some((b) => b.type === 'tool_result'));
|
|
257
|
+
function withoutCodexThinking(messages) {
|
|
258
|
+
const prompt = messages.findLastIndex(isPrompt);
|
|
259
|
+
let inTurn = false;
|
|
260
|
+
const out = messages.flatMap((m, i) => {
|
|
261
|
+
if (m.role !== 'assistant' || !Array.isArray(m.content) || !m.content.some(isCodexThinking)) return [m];
|
|
262
|
+
if (i > prompt) inTurn = true;
|
|
263
|
+
const content = m.content.filter((b) => !isCodexThinking(b));
|
|
264
|
+
return content.length ? [{ ...m, content }] : [];
|
|
265
|
+
});
|
|
266
|
+
return { messages: out, inTurn };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// `cacheBucket` says which prompt cache TTL applies: "main" for the conversation itself (the default), "other"
|
|
270
|
+
// for sub-agents, compaction and side queries (see Config.cacheTtl). `cacheMessages: false` leaves the messages
|
|
271
|
+
// unmarked: for a request whose messages are never sent twice, writing them to the cache only costs more.
|
|
272
|
+
export function buildParams(config, { model, system, messages, tools, maxTokens, effort, cacheBucket = 'main', cacheMessages = true }, login = null) {
|
|
273
|
+
if (config.usesClaudeCode) login ||= readClaudeCodeLogin(config);
|
|
274
|
+
const wanted = maxTokens || config.maxOutputTokens;
|
|
275
|
+
const refused = cacheRefused.get(cacheKey(config, model.id, login));
|
|
276
|
+
const caching = !config.promptCachingDisabled(model.id);
|
|
277
|
+
const ttl = refused?.has('ttl') ? '5m' : config.cacheTtl(cacheBucket);
|
|
278
|
+
const mark = (place) => (caching && !refused?.has(place) ? { ...cacheControl(ttl) } : null);
|
|
279
|
+
const systemMark = mark('system');
|
|
280
|
+
// The codex provider, and the gateway for a GPT model, take the request to the Codex backend.
|
|
281
|
+
const toCodex = config.usesCodex || /^gpt-/i.test(model.id);
|
|
282
|
+
let turnLostThinking = false;
|
|
283
|
+
if (!toCodex) ({ messages, inTurn: turnLostThinking } = withoutCodexThinking(messages));
|
|
284
|
+
const params = {
|
|
285
|
+
model: model.id,
|
|
286
|
+
max_tokens: outputCaps.has(model.id) ? Math.min(wanted, outputCaps.get(model.id)) : wanted,
|
|
287
|
+
system: [{ type: 'text', text: system, ...(systemMark ? { cache_control: systemMark } : {}) }],
|
|
288
|
+
messages: withCacheControl(messages, cacheMessages ? mark('messages') : null),
|
|
289
|
+
stream: true,
|
|
290
|
+
};
|
|
291
|
+
if (login) {
|
|
292
|
+
params.system.unshift({ type: 'text', text: CLAUDE_CODE_SYSTEM });
|
|
293
|
+
params.metadata = { user_id: login.userId };
|
|
294
|
+
}
|
|
295
|
+
if (tools?.length) {
|
|
296
|
+
const toolMark = mark('tools');
|
|
297
|
+
params.tools = tools.map((t, i) => (i === tools.length - 1 && toolMark ? { ...t, cache_control: toolMark } : t));
|
|
298
|
+
}
|
|
299
|
+
// A GPT model with no effort set gets its default from the gateway's model metadata (or the local Codex cache
|
|
300
|
+
// when using the Codex provider), sent explicitly so that the footer and backend use the same level.
|
|
301
|
+
const eff = effortToSend(config, model.id, (effort === undefined ? config.effort : effort) || (/^gpt-/i.test(model.id) ? defaultEffort(model.id, config) : null));
|
|
302
|
+
if (eff && !noEffort.has(model.id)) params.output_config = { effort: eff };
|
|
303
|
+
const budget = config.thinkingBudget;
|
|
304
|
+
if (budget === 0 && !thinkingStaysOn.has(model.id)) params.thinking = { type: 'disabled' };
|
|
305
|
+
else if (budget > 0) params.thinking = { type: 'enabled', budget_tokens: Math.min(budget, params.max_tokens - 1) };
|
|
306
|
+
if (turnLostThinking) delete params.thinking;
|
|
307
|
+
return params;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export class APIError extends Error {
|
|
311
|
+
constructor(message, { status, retryable, promptTooLong, retryAfterMs = null, rateLimit = null, overloaded = false } = {}) {
|
|
312
|
+
super(message);
|
|
313
|
+
this.status = status;
|
|
314
|
+
this.retryable = retryable;
|
|
315
|
+
this.promptTooLong = promptTooLong;
|
|
316
|
+
this.overloaded = overloaded; // 529 / overloaded_error: waited out with its own backoff (see planRetry)
|
|
317
|
+
this.retryAfterMs = retryAfterMs; // what the server's Retry-After header asked for
|
|
318
|
+
this.rateLimit = rateLimit; // what the rate limit headers say (see ratelimit.js)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function describeError(err, config) {
|
|
323
|
+
if (err?.name === 'AbortError' || err instanceof Anthropic.APIUserAbortError) return err;
|
|
324
|
+
const status = err?.status;
|
|
325
|
+
let msg = err?.error?.error?.message || err?.error?.message || err?.message || String(err);
|
|
326
|
+
const promptTooLong = /prompt is too long|context length|maximum context|too many tokens|context_length_exceeded/i.test(msg);
|
|
327
|
+
if (err instanceof APIError) return err;
|
|
328
|
+
if (status === 401) msg = config?.usesClaudeCode
|
|
329
|
+
? `Claude Code OAuth authentication failed (401). Check the existing local subscription credentials in ${config.claudeCodeCredentialsFile}. ${msg}`
|
|
330
|
+
: config?.usesGateway ? `The gateway refused the login (401). Run /login to log in again. ${msg}`
|
|
331
|
+
: config?.usesCodex ? msg
|
|
332
|
+
: `Authentication failed (401). Check ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY. ${msg}`;
|
|
333
|
+
if (status === 402 && config?.usesGateway) msg = `The gateway account's balance is too low for this request. Top up at ${config.gatewayUrl}/user (or run /login to check the balance). ${msg}`;
|
|
334
|
+
const retryable = !status || status === 408 || status === 409 || status === 429 || status >= 500;
|
|
335
|
+
const header = typeof err?.headers?.get === 'function' ? err.headers.get('retry-after') : err?.headers?.['retry-after'];
|
|
336
|
+
const overloaded = status === 529 || (err?.error?.error?.type || err?.error?.type) === 'overloaded_error';
|
|
337
|
+
return new APIError(msg, { status, retryable, promptTooLong, retryAfterMs: parseRetryAfter(header), rateLimit: parseRateLimit(err?.headers, timing.now()), overloaded });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Our own aborts are named AbortError and the SDK's is an APIUserAbortError. The message test only
|
|
341
|
+
// accepts the bare "aborted" wording: a server error that merely mentions "aborted" is not the user.
|
|
342
|
+
export function isAbort(err) {
|
|
343
|
+
return err?.name === 'AbortError' || err instanceof Anthropic.APIUserAbortError || /^(?:request was )?aborted\.?$/i.test(err?.message || '');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// USD for a request at the list prices (see pricing.js), by the model that answered, else the one asked for;
|
|
347
|
+
// null when neither has a price. A cost goes to config.onCost.
|
|
348
|
+
function charge(config, requested, served, usage) {
|
|
349
|
+
const cost = costOf(served, usage) ?? costOf(requested, usage);
|
|
350
|
+
if (cost > 0) config.onCost?.(cost);
|
|
351
|
+
return cost;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Stream a message. `onEvent` receives:
|
|
355
|
+
// {type:'block_start', index, block}
|
|
356
|
+
// {type:'text', index, text} text delta
|
|
357
|
+
// {type:'thinking', index, text} thinking delta
|
|
358
|
+
// {type:'tool_input', index, partial} partial JSON of a tool call
|
|
359
|
+
// {type:'block_stop', index, block} finished block (tool_use input parsed)
|
|
360
|
+
// {type:'retry', attempt, error, delayMs, maxRetries} a quick retry is about to wait delayMs
|
|
361
|
+
// {type:'rate_limit_wait', error, delayMs, resumeAt, resetAt, label, polling, waitedMs, maxWaitMs, loginExpiresAt}
|
|
362
|
+
// a rate limit was hit: paused until resumeAt, then the request is repeated
|
|
363
|
+
// {type:'rate_limit_resume', waitedMs} the repeated request was accepted: the pause is over
|
|
364
|
+
// {type:'reset'} partial output from a failed attempt must be discarded
|
|
365
|
+
// `waitForRateLimit: false` reports a rate limit as an error instead of waiting for it (side queries
|
|
366
|
+
// where nobody wants to sit through a pause). Returns {message, usage, stopReason, model, cost, cache}.
|
|
367
|
+
// What each request costs (an interrupted one too) goes to config.onCost, the conversation's running total.
|
|
368
|
+
export async function streamMessage(config, request, { signal, onEvent = () => {}, waitForRateLimit = true } = {}) {
|
|
369
|
+
if (signal?.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError', partial: [] });
|
|
370
|
+
// A token that has expired (or is about to) is renewed first, as the official client does.
|
|
371
|
+
if (config.usesClaudeCode) await renewLogin(config, signal);
|
|
372
|
+
let login = config.usesClaudeCode ? readClaudeCodeLogin(config) : null;
|
|
373
|
+
// Load gateway model metadata before the synchronous parameter builder; the cache is keyed by gateway and key,
|
|
374
|
+
// so changing accounts or gateway URLs cannot leak a model's effort levels into another context.
|
|
375
|
+
if (config.usesGateway && /^gpt-/i.test(request.model?.id)) {
|
|
376
|
+
const gatewayLogin = readGatewayLogin(config.gatewayUrl);
|
|
377
|
+
if (gatewayLogin) {
|
|
378
|
+
// A gateway that does not answer costs at most a few seconds once (see fetchGatewayModels), not the request.
|
|
379
|
+
try { await fetchGatewayModels(gatewayLogin, { signal }); } catch { if (signal?.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError', partial: [] }); }
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
let client = getClient(config, login);
|
|
383
|
+
const params = buildParams(config, request, login);
|
|
384
|
+
// The gateway keeps a conversation on the upstream account that served it, where its prompt cache is; the
|
|
385
|
+
// session id names the conversation, in the header Claude Code sends it in.
|
|
386
|
+
const options = { signal, ...(config.usesGateway && config.sessionId ? { headers: { 'X-Claude-Code-Session-Id': config.sessionId } } : {}) };
|
|
387
|
+
let authReloaded = false;
|
|
388
|
+
let imagesFitted = false;
|
|
389
|
+
const refused = new Set(); // what the endpoint refused for this request: remembered once a request got through
|
|
390
|
+
const retryState = { attempt: 0, rateLimitHits: 0, waitedMs: 0, overloaded: 0, overloadedWaitMs: 0 };
|
|
391
|
+
let paused = null; // the plan of the pause in progress (see planRetry)
|
|
392
|
+
for (;;) {
|
|
393
|
+
let emitted = false;
|
|
394
|
+
const startedAt = Date.now(); // the cache TTL counts from the start of the request, not from the end of the reply
|
|
395
|
+
const blocks = [];
|
|
396
|
+
const jsonBufs = [];
|
|
397
|
+
let usage = {};
|
|
398
|
+
let model = params.model;
|
|
399
|
+
try {
|
|
400
|
+
const stream = await client.messages.create(params, options);
|
|
401
|
+
if (paused) {
|
|
402
|
+
const overloaded = paused.overloaded;
|
|
403
|
+
paused = null;
|
|
404
|
+
onEvent({ type: 'rate_limit_resume', waitedMs: overloaded ? retryState.overloadedWaitMs : retryState.waitedMs, ...(overloaded ? { overloaded } : {}) });
|
|
405
|
+
}
|
|
406
|
+
let stopReason = null;
|
|
407
|
+
let stopped = false;
|
|
408
|
+
for await (const ev of stream) {
|
|
409
|
+
switch (ev.type) {
|
|
410
|
+
case 'message_stop':
|
|
411
|
+
stopped = true;
|
|
412
|
+
break;
|
|
413
|
+
case 'message_start':
|
|
414
|
+
usage = { ...ev.message.usage };
|
|
415
|
+
model = ev.message.model || model;
|
|
416
|
+
break;
|
|
417
|
+
case 'content_block_start': {
|
|
418
|
+
const b = { ...ev.content_block };
|
|
419
|
+
if (b.type === 'tool_use') { b.input = {}; jsonBufs[ev.index] = ''; }
|
|
420
|
+
if (b.type === 'text') b.text = b.text || '';
|
|
421
|
+
if (b.type === 'thinking') { b.thinking = b.thinking || ''; b.signature = b.signature || ''; }
|
|
422
|
+
blocks[ev.index] = b;
|
|
423
|
+
emitted = true;
|
|
424
|
+
onEvent({ type: 'block_start', index: ev.index, block: b });
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
case 'content_block_delta': {
|
|
428
|
+
const b = blocks[ev.index];
|
|
429
|
+
const d = ev.delta;
|
|
430
|
+
if (!b) break;
|
|
431
|
+
if (d.type === 'text_delta') { b.text += d.text; onEvent({ type: 'text', index: ev.index, text: d.text }); }
|
|
432
|
+
else if (d.type === 'thinking_delta') { b.thinking += d.thinking; onEvent({ type: 'thinking', index: ev.index, text: d.thinking }); }
|
|
433
|
+
else if (d.type === 'signature_delta') b.signature = (b.signature || '') + d.signature;
|
|
434
|
+
else if (d.type === 'input_json_delta') {
|
|
435
|
+
jsonBufs[ev.index] += d.partial_json;
|
|
436
|
+
onEvent({ type: 'tool_input', index: ev.index, partial: jsonBufs[ev.index], block: b });
|
|
437
|
+
}
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
case 'content_block_stop': {
|
|
441
|
+
const b = blocks[ev.index];
|
|
442
|
+
if (b?.type === 'tool_use') {
|
|
443
|
+
const raw = jsonBufs[ev.index];
|
|
444
|
+
try { b.input = raw ? JSON.parse(raw) : {}; } catch { b.input = { __invalid_json: raw }; }
|
|
445
|
+
}
|
|
446
|
+
if (b) onEvent({ type: 'block_stop', index: ev.index, block: b });
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
case 'message_delta':
|
|
450
|
+
if (ev.delta?.stop_reason) stopReason = ev.delta.stop_reason;
|
|
451
|
+
if (ev.usage) usage = { ...usage, ...Object.fromEntries(Object.entries(ev.usage).filter(([, v]) => v != null)) };
|
|
452
|
+
break;
|
|
453
|
+
case 'error':
|
|
454
|
+
throw Object.assign(new Error(ev.error?.message || 'stream error'), { status: { rate_limit_error: 429, overloaded_error: 529 }[ev.error?.type] || 500 });
|
|
455
|
+
default:
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// The SDK ends the iterator quietly when aborted; a stream without message_stop was cut off.
|
|
460
|
+
if (signal?.aborted) {
|
|
461
|
+
const partial = blocks.filter((b) => b?.type === 'text' && b.text.trim()).map((b) => ({ type: 'text', text: b.text }));
|
|
462
|
+
throw Object.assign(new Error('aborted'), { name: 'AbortError', partial });
|
|
463
|
+
}
|
|
464
|
+
if (!stopped && !stopReason) throw Object.assign(new Error('The response stream ended unexpectedly'), { status: 503 });
|
|
465
|
+
const content = blocks.filter(Boolean).filter((b) => !(b.type === 'text' && !b.text));
|
|
466
|
+
if (refused.size) { // it got through without what the endpoint refused: leave that off from now on
|
|
467
|
+
const key = cacheKey(config, params.model, login);
|
|
468
|
+
cacheRefused.set(key, new Set([...(cacheRefused.get(key) || []), ...refused]));
|
|
469
|
+
}
|
|
470
|
+
const cost = charge(config, params.model, model, usage);
|
|
471
|
+
return { message: { role: 'assistant', content }, usage, stopReason, model, cost, cache: { ...cachePlanOf(params), fingerprint: fingerprint(params), at: startedAt } };
|
|
472
|
+
} catch (raw) {
|
|
473
|
+
if (signal?.aborted || isAbort(raw)) {
|
|
474
|
+
// An interrupted reply is billed for what it had read and written: the output so far is estimated.
|
|
475
|
+
const written = blocks.reduce((n, b) => n + (b?.text?.length || 0) + (b?.thinking?.length || 0), 0) + jsonBufs.join('').length;
|
|
476
|
+
charge(config, params.model, model, { ...usage, output_tokens: Math.max(usage.output_tokens || 0, Math.ceil(written / 4)) });
|
|
477
|
+
throw Object.assign(new Error('aborted'), { name: 'AbortError', partial: raw?.partial || [] });
|
|
478
|
+
}
|
|
479
|
+
const err = describeError(raw, config);
|
|
480
|
+
// The token was refused. A running native Claude Code session may have renewed the shared token
|
|
481
|
+
// while this request was in flight: read it again. If it is the same one, it may have been revoked
|
|
482
|
+
// early or the local clock may be off: renew it once and try again.
|
|
483
|
+
if (login && err.status === 401 && !authReloaded) {
|
|
484
|
+
authReloaded = true;
|
|
485
|
+
const readValid = () => { try { return readClaudeCodeLogin(config); } catch { return null; } };
|
|
486
|
+
let updated = readValid();
|
|
487
|
+
if (!updated || updated.accessToken === login.accessToken) {
|
|
488
|
+
let failure = null;
|
|
489
|
+
if (await renewLogin(config, signal, { force: true, onError: (e) => { failure = e; } })) updated = readValid();
|
|
490
|
+
else if (!updated && failure) throw failure; // the token ran out meanwhile and cannot be renewed: say why, not just "401"
|
|
491
|
+
}
|
|
492
|
+
if (updated && updated.accessToken !== login.accessToken) {
|
|
493
|
+
login = updated;
|
|
494
|
+
client = getClient(config, login);
|
|
495
|
+
params.metadata = { user_id: login.userId };
|
|
496
|
+
if (emitted) onEvent({ type: 'reset' });
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// Not every model takes an effort level. Leave it out for this one, and for the rest of the session.
|
|
501
|
+
if (err.status === 400 && params.output_config && /does not support the effort parameter/i.test(err.message)) {
|
|
502
|
+
noEffort.add(params.model);
|
|
503
|
+
delete params.output_config;
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
// A model whose thinking cannot be turned off: carry on with its adaptive thinking.
|
|
507
|
+
if (err.status === 400 && params.thinking?.type === 'disabled' && /thinking\.type\.disabled|"disabled"/i.test(err.message) && /not supported/i.test(err.message)) {
|
|
508
|
+
thinkingStaysOn.add(params.model);
|
|
509
|
+
delete params.thinking;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
// The endpoint does not take cache_control (or the 1-hour ttl) where it was sent: carry on without it
|
|
513
|
+
// instead of failing the request, and do not send it there again (see refuseCache).
|
|
514
|
+
if (err.status === 400 && /cache_control|\bttl\b/i.test(err.message)) {
|
|
515
|
+
const taken = refuseCache(params, err.message);
|
|
516
|
+
if (taken.size) {
|
|
517
|
+
for (const item of taken) refused.add(item);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// An image in the conversation is larger than the API takes (a screenshot read before images were scaled,
|
|
522
|
+
// or a session that only now has enough images for the stricter limit): scale the images in place, which
|
|
523
|
+
// fixes the conversation itself, and try again. Without this every later request of the session fails.
|
|
524
|
+
if (err.status === 400 && !imagesFitted && /image/i.test(err.message) && /dimension|pixels|exceed|too large|too big/i.test(err.message)) {
|
|
525
|
+
imagesFitted = true;
|
|
526
|
+
if (await fitImagesIn(params.messages)) {
|
|
527
|
+
if (emitted) onEvent({ type: 'reset' });
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
// The default max_tokens is generous: if this API allows less, learn its limit and retry at once.
|
|
532
|
+
const limit = err.status === 400 ? parseMaxTokensLimit(err.message) : null;
|
|
533
|
+
if (limit && limit < params.max_tokens) {
|
|
534
|
+
outputCaps.set(params.model, limit);
|
|
535
|
+
params.max_tokens = limit;
|
|
536
|
+
if (params.thinking?.budget_tokens) params.thinking.budget_tokens = Math.min(params.thinking.budget_tokens, limit - 1);
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
const plan = planRetry(err, retryState, config, { now: timing.now(), allowWait: waitForRateLimit });
|
|
540
|
+
if (plan.action === 'throw') {
|
|
541
|
+
if (plan.message) err.message = plan.message;
|
|
542
|
+
throw err;
|
|
543
|
+
}
|
|
544
|
+
if (emitted) onEvent({ type: 'reset' });
|
|
545
|
+
if (plan.kind === 'rate_limit') {
|
|
546
|
+
paused = plan;
|
|
547
|
+
onEvent({
|
|
548
|
+
type: 'rate_limit_wait', error: err, delayMs: plan.delayMs, resumeAt: plan.resumeAt, resetAt: plan.resetAt,
|
|
549
|
+
label: plan.label, polling: plan.polling,
|
|
550
|
+
...(plan.overloaded
|
|
551
|
+
? { overloaded: true, waitedMs: retryState.overloadedWaitMs, maxWaitMs: config.overloadedMaxWait }
|
|
552
|
+
: { waitedMs: retryState.waitedMs, maxWaitMs: config.rateLimitMaxWait }),
|
|
553
|
+
loginExpiresAt: loginExpiryToWarnAbout(config, login),
|
|
554
|
+
});
|
|
555
|
+
await sleepUntil(plan.resumeAt, signal);
|
|
556
|
+
// Hours may have passed: pick up the login again, the local Claude Code may have refreshed
|
|
557
|
+
// the token (and if it has expired, wait for that rather than fail with a 401).
|
|
558
|
+
if (config.usesClaudeCode) {
|
|
559
|
+
login = await loginAfterPause(config, retryState, { signal, onEvent });
|
|
560
|
+
client = getClient(config, login);
|
|
561
|
+
params.metadata = { user_id: login.userId };
|
|
562
|
+
}
|
|
563
|
+
retryState.attempt = 0; // the pause is a fresh start: quick retries made before it do not count after it
|
|
564
|
+
} else {
|
|
565
|
+
onEvent({ type: 'retry', attempt: plan.attempt, error: err, delayMs: plan.delayMs, maxRetries: plan.maxRetries });
|
|
566
|
+
await sleepUntil(plan.resumeAt, signal);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// One-shot, non-streaming helper for small side queries (summaries, titles, web page extraction).
|
|
573
|
+
// Nothing here can tell the user that it is paused, so a rate limit is reported as an error by default
|
|
574
|
+
// instead of being waited for out of sight; waitForRateLimit: true is for a caller that accepts that.
|
|
575
|
+
export async function quickQuery(config, { model, system, prompt, maxTokens = 4096, signal, waitForRateLimit = false }) {
|
|
576
|
+
const res = await streamMessage(config, {
|
|
577
|
+
model,
|
|
578
|
+
system,
|
|
579
|
+
messages: [{ role: 'user', content: prompt }],
|
|
580
|
+
maxTokens,
|
|
581
|
+
effort: null,
|
|
582
|
+
cacheBucket: 'other',
|
|
583
|
+
cacheMessages: false, // one-off content (a whole web page, say): caching it would only cost the write premium
|
|
584
|
+
}, { signal, waitForRateLimit });
|
|
585
|
+
return res.message.content.filter((b) => b.type === 'text').map((b) => b.text).join('').trim();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export function contextTokens(usage) {
|
|
589
|
+
if (!usage) return 0;
|
|
590
|
+
return (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0) + (usage.output_tokens || 0);
|
|
591
|
+
}
|