@yanzc/claude-pro 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +41 -0
- package/LICENSE +21 -0
- package/README.md +314 -3
- package/bin/claude-pro.js +32 -0
- package/package.json +40 -6
- package/src/agent.js +752 -0
- package/src/agents.js +186 -0
- package/src/api.js +488 -0
- package/src/cache.js +150 -0
- package/src/checkpoints.js +184 -0
- package/src/claude-code.js +60 -0
- package/src/codex/client.js +300 -0
- package/src/codex/translate-request.js +239 -0
- package/src/codex/translate-stream.js +328 -0
- package/src/commands.js +803 -0
- package/src/config.js +453 -0
- package/src/context.js +109 -0
- package/src/filestate.js +57 -0
- package/src/gateway.js +110 -0
- package/src/hooks.js +81 -0
- package/src/main.js +193 -0
- package/src/mcp.js +290 -0
- package/src/modes.js +154 -0
- package/src/oauth.js +287 -0
- package/src/peers.js +156 -0
- package/src/print.js +106 -0
- package/src/prompts.js +179 -0
- package/src/ratelimit.js +244 -0
- package/src/restart.js +115 -0
- package/src/runtime.js +162 -0
- package/src/selfcheck.js +71 -0
- package/src/session.js +139 -0
- package/src/supervisor.js +110 -0
- package/src/tasks.js +76 -0
- package/src/tools/bash.js +227 -0
- package/src/tools/effort.js +41 -0
- package/src/tools/files.js +352 -0
- package/src/tools/index.js +52 -0
- package/src/tools/interaction.js +157 -0
- package/src/tools/notebook.js +91 -0
- package/src/tools/peers.js +45 -0
- package/src/tools/restart.js +50 -0
- package/src/tools/search.js +458 -0
- package/src/tools/skill.js +32 -0
- package/src/tools/task.js +139 -0
- package/src/tools/web.js +283 -0
- package/src/ui/ansi.js +228 -0
- package/src/ui/dialogs.js +122 -0
- package/src/ui/editor.js +389 -0
- package/src/ui/markdown.js +249 -0
- package/src/ui/render.js +155 -0
- package/src/ui/repl.js +1450 -0
- package/src/ui/screen.js +259 -0
- package/src/updater.js +93 -0
- package/src/utils/clipboard.js +197 -0
- package/src/utils/diff.js +95 -0
- package/src/utils/paths.js +37 -0
- package/src/utils/shell.js +251 -0
package/src/api.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
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 } from './gateway.js';
|
|
10
|
+
import { createCodexFetch, codexModels } from './codex/client.js';
|
|
11
|
+
|
|
12
|
+
let cached = null;
|
|
13
|
+
|
|
14
|
+
const LOGIN_POLL_MS = 15_000; // how often to look for a refreshed login after a pause
|
|
15
|
+
const LOGIN_RENEW_RETRY_MS = 5 * 60_000; // how often to try renewing it while waiting for one
|
|
16
|
+
|
|
17
|
+
// Renew the subscription login if it needs it. Returns true when the file now holds a new login. A login
|
|
18
|
+
// that cannot be renewed is an error with its reason, in place of the bare "token has expired" -- unless
|
|
19
|
+
// the caller has its own way of dealing with that: `onError` is told and the call returns false (the
|
|
20
|
+
// wait for a login), and so does `force` (a 401: the request is reported as it is).
|
|
21
|
+
async function renewLogin(config, signal, { force = false, onError } = {}) {
|
|
22
|
+
try {
|
|
23
|
+
return (await refreshClaudeCodeLogin(config, { signal, force })).refreshed;
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (err.name === 'AbortError' || signal?.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError', partial: [] });
|
|
26
|
+
if (onError) { onError(err); return false; }
|
|
27
|
+
if (force) return false;
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The login expiry worth warning about before a long pause: none when this program renews it itself.
|
|
33
|
+
const loginExpiryToWarnAbout = (config, login) => (config.usesClaudeCode && !canRenewLogin(config) ? login?.expiresAt ?? null : null);
|
|
34
|
+
|
|
35
|
+
// The subscription login to use once a pause is over. Its access token lives 8 hours, so after hours of
|
|
36
|
+
// waiting it has most likely expired: it is renewed first, with the refresh token that is stored with it.
|
|
37
|
+
// If that is not possible (the refresh token was refused, the token server cannot be reached for the
|
|
38
|
+
// moment, renewing is switched off) the wait is not wasted by failing at its end: keep looking for a
|
|
39
|
+
// valid login for what is left of the waiting budget, renewing again every few minutes, and say so. A
|
|
40
|
+
// login done in the meantime (running `claude`) is picked up. The file may also be half written at this
|
|
41
|
+
// very moment by another process.
|
|
42
|
+
async function loginAfterPause(config, state, { signal, onEvent }) {
|
|
43
|
+
let spent = 0;
|
|
44
|
+
let renewedAt = 0;
|
|
45
|
+
let renewError = null;
|
|
46
|
+
for (;;) {
|
|
47
|
+
if (!renewedAt || timing.now() - renewedAt >= LOGIN_RENEW_RETRY_MS) {
|
|
48
|
+
renewedAt = timing.now();
|
|
49
|
+
renewError = null;
|
|
50
|
+
await renewLogin(config, signal, { onError: (err) => { renewError = err; } });
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return readClaudeCodeLogin(config);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
const left = config.rateLimitMaxWait - state.waitedMs;
|
|
56
|
+
if (left < LOGIN_POLL_MS) {
|
|
57
|
+
if (renewError && /access token has expired/.test(err.message)) err.message = renewError.message; // the reason, not just the fact
|
|
58
|
+
if (spent) err.message += ` (Waited ${humanDuration(spent)} for it after the limit reset.)`;
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
const now = timing.now();
|
|
62
|
+
onEvent({
|
|
63
|
+
type: 'rate_limit_wait', error: err, delayMs: LOGIN_POLL_MS, resumeAt: now + LOGIN_POLL_MS, resetAt: null, label: 'login',
|
|
64
|
+
polling: true, waitingForLogin: true, giveUpAt: now + left, waitedMs: state.waitedMs, maxWaitMs: config.rateLimitMaxWait,
|
|
65
|
+
renewError: renewError?.message || null,
|
|
66
|
+
});
|
|
67
|
+
state.waitedMs += LOGIN_POLL_MS;
|
|
68
|
+
spent += LOGIN_POLL_MS;
|
|
69
|
+
await sleepUntil(now + LOGIN_POLL_MS, signal);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let codexFetch = null;
|
|
75
|
+
|
|
76
|
+
// The gateway provider's key, from /login. Without one the request fails the way a refused key does.
|
|
77
|
+
function gatewayKey(config) {
|
|
78
|
+
const login = readGatewayLogin(config.gatewayUrl);
|
|
79
|
+
if (!login) throw new APIError(`Not logged in to the gateway ${config.gatewayUrl}. Run /login and choose the gateway.`, { status: 401, retryable: false });
|
|
80
|
+
return login.apiKey;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getClient(config, login = null) {
|
|
84
|
+
if (config.usesGateway || config.usesCodex) {
|
|
85
|
+
const gateway = config.usesGateway;
|
|
86
|
+
const baseURL = gateway ? config.gatewayUrl : 'https://codex.invalid';
|
|
87
|
+
const authToken = gateway ? gatewayKey(config) : 'codex-login';
|
|
88
|
+
if (!gateway) codexFetch ||= createCodexFetch({ env: { ...process.env, ...config.env } });
|
|
89
|
+
const key = `${config.provider}|${baseURL}|${authToken}|${config.apiTimeout}`;
|
|
90
|
+
if (cached?.key === key && cached.fetch === globalThis.fetch) return cached.client;
|
|
91
|
+
const client = new Anthropic({
|
|
92
|
+
baseURL,
|
|
93
|
+
authToken,
|
|
94
|
+
apiKey: null,
|
|
95
|
+
timeout: config.apiTimeout,
|
|
96
|
+
maxRetries: 0,
|
|
97
|
+
...(gateway ? {} : { fetch: codexFetch }),
|
|
98
|
+
});
|
|
99
|
+
cached = { key, client, fetch: globalThis.fetch };
|
|
100
|
+
return client;
|
|
101
|
+
}
|
|
102
|
+
if (config.usesClaudeCode) login ||= readClaudeCodeLogin(config);
|
|
103
|
+
// Subscription credentials must only go to Anthropic, regardless of an API
|
|
104
|
+
// endpoint or custom auth headers left over from .env (e.g. DeepSeek).
|
|
105
|
+
const baseURL = login ? CLAUDE_CODE_API_URL : config.baseURL;
|
|
106
|
+
const authToken = login ? login.accessToken : config.authToken;
|
|
107
|
+
const apiKey = login || authToken ? null : (config.apiKey || null);
|
|
108
|
+
const headers = login ? claudeCodeHeaders(login) : config.customHeaders;
|
|
109
|
+
const key = `${baseURL}|${authToken}|${apiKey}|${JSON.stringify(headers)}|${config.apiTimeout}`;
|
|
110
|
+
if (cached?.key === key && cached.fetch === globalThis.fetch) return cached.client;
|
|
111
|
+
const client = new Anthropic({
|
|
112
|
+
baseURL,
|
|
113
|
+
// Prefer the bearer token; pass null explicitly so the SDK never falls back to env vars.
|
|
114
|
+
authToken,
|
|
115
|
+
apiKey,
|
|
116
|
+
defaultHeaders: headers,
|
|
117
|
+
defaultQuery: login ? { beta: 'true' } : undefined,
|
|
118
|
+
timeout: config.apiTimeout,
|
|
119
|
+
maxRetries: 0, // retries are handled in streamMessage so the UI can report them
|
|
120
|
+
|
|
121
|
+
dangerouslyAllowBrowser: false,
|
|
122
|
+
});
|
|
123
|
+
cached = { key, client, fetch: globalThis.fetch };
|
|
124
|
+
return client;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Mark the last block of the latest user message as cacheable (`mark` is the cache_control to put there; null
|
|
128
|
+
// leaves the messages alone). With the markers on the system prompt and the last tool this makes three of the
|
|
129
|
+
// four breakpoints a request may have. Endpoints that don't support prompt caching (e.g. DeepSeek, which caches
|
|
130
|
+
// automatically) ignore them.
|
|
131
|
+
function withCacheControl(messages, mark) {
|
|
132
|
+
if (!mark || !messages.length) return messages;
|
|
133
|
+
const out = messages.slice();
|
|
134
|
+
const idx = out.length - 1;
|
|
135
|
+
const last = out[idx];
|
|
136
|
+
if (last.role !== 'user') return out;
|
|
137
|
+
const content = typeof last.content === 'string' ? [{ type: 'text', text: last.content }] : last.content.slice();
|
|
138
|
+
if (!content.length) return out;
|
|
139
|
+
const tail = content.length - 1;
|
|
140
|
+
content[tail] = { ...content[tail], cache_control: mark };
|
|
141
|
+
out[idx] = { ...last, content };
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// What an endpoint refused (a gateway that does not know cache_control, say), by endpoint and model: the
|
|
146
|
+
// places (system, tools, messages) it was refused on, and "ttl" when the 1-hour TTL was. Learned from its 400
|
|
147
|
+
// errors. Claude Code carries on without the marker instead of failing the request, and so does this: the
|
|
148
|
+
// marker is left off from then on, so the endpoint is not asked the same thing again.
|
|
149
|
+
const cacheRefused = new Map();
|
|
150
|
+
const cacheKey = (config, modelId, login) => `${login ? 'claude-code' : config.baseURL}|${modelId}`;
|
|
151
|
+
|
|
152
|
+
const PLACES = ['system', 'tools', 'messages'];
|
|
153
|
+
const blocksAt = (params, place) => {
|
|
154
|
+
if (place === 'system') return params.system || [];
|
|
155
|
+
if (place === 'tools') return params.tools || [];
|
|
156
|
+
return (params.messages || []).flatMap((m) => (Array.isArray(m.content) ? m.content : []));
|
|
157
|
+
};
|
|
158
|
+
const hasMark = (params, place) => blocksAt(params, place).some((b) => b.cache_control);
|
|
159
|
+
|
|
160
|
+
// What an endpoint has refused so far, for /status: the places (system, tools, messages) and "ttl".
|
|
161
|
+
export function cacheRefusals(config, modelId) {
|
|
162
|
+
return [...(cacheRefused.get(cacheKey(config, modelId, config.usesClaudeCode)) || [])];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Take cache_control (or only its ttl) off a request after the endpoint refused it. Returns what was taken off
|
|
166
|
+
// (a set of places, or "ttl"), empty when there was nothing left to take off: then the caller must give up on
|
|
167
|
+
// the request. The refusal is only remembered once a request has got through without it (see streamMessage):
|
|
168
|
+
// an error that merely mentions cache_control must not switch caching off for good.
|
|
169
|
+
function refuseCache(params, message) {
|
|
170
|
+
const text = String(message || '').toLowerCase();
|
|
171
|
+
const taken = new Set();
|
|
172
|
+
if (/\bttl\b/.test(text) && cachePlanOf(params).ttl === '1h') {
|
|
173
|
+
// "a ttl='1h' cache_control block must occur before ...", "ttl: Extra inputs are not permitted": the 5-minute default
|
|
174
|
+
for (const place of PLACES) for (const b of blocksAt(params, place)) if (b.cache_control?.ttl) b.cache_control = { type: 'ephemeral' };
|
|
175
|
+
taken.add('ttl');
|
|
176
|
+
return taken;
|
|
177
|
+
}
|
|
178
|
+
const marked = PLACES.filter((p) => hasMark(params, p));
|
|
179
|
+
const named = marked.filter((p) => new RegExp(`\\b${p}\\b`).test(text));
|
|
180
|
+
// An error that says where is believed. One that does not ("a maximum of 4 blocks ... may be provided") loses
|
|
181
|
+
// one place at a time, the newest first, so that as much of the caching as possible is kept.
|
|
182
|
+
const places = named.length ? named : ['messages', 'tools', 'system'].filter((p) => marked.includes(p)).slice(0, 1);
|
|
183
|
+
for (const place of places) {
|
|
184
|
+
for (const b of blocksAt(params, place)) delete b.cache_control;
|
|
185
|
+
taken.add(place);
|
|
186
|
+
}
|
|
187
|
+
return taken;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Output ceilings the API has told us about, by model id (learned from "max_tokens too large" errors).
|
|
191
|
+
const outputCaps = new Map();
|
|
192
|
+
// Models that refused the effort parameter (Haiku does): it is left out for them from then on.
|
|
193
|
+
const noEffort = new Set();
|
|
194
|
+
export const effortSupported = (modelId) => !noEffort.has(modelId);
|
|
195
|
+
|
|
196
|
+
// The effort a model uses when none is sent (docs.claude.com, "Effort"): medium on Opus 5.5 and Haiku 5.5, high on
|
|
197
|
+
// the other models that take one; for a GPT model, its default in the local Codex install's models cache. null for
|
|
198
|
+
// a model that takes none or is not known.
|
|
199
|
+
export function defaultEffort(modelId) {
|
|
200
|
+
const id = String(modelId || '');
|
|
201
|
+
if (!effortSupported(id)) return null;
|
|
202
|
+
if (/^gpt-/i.test(id)) return codexModels().models.find((m) => m.slug === id)?.defaultEffort || null;
|
|
203
|
+
if (/^claude-(?:opus|haiku)-5-5(?:-|$)/i.test(id)) return 'medium';
|
|
204
|
+
if (/^claude-(?:fable|mythos|opus-(?:5|4-[5-8])|sonnet-(?:5|4-6))(?:-|$)/i.test(id)) return 'high';
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Pull the allowed maximum out of an error such as
|
|
209
|
+
// "max_tokens: 128000 > 64000, which is the maximum allowed number of output tokens for claude-..."
|
|
210
|
+
// "Invalid max_tokens value, the valid range of max_tokens is [1, 8192]"
|
|
211
|
+
export function parseMaxTokensLimit(message) {
|
|
212
|
+
const text = String(message || '');
|
|
213
|
+
if (!/max_tokens|output tokens/i.test(text)) return null;
|
|
214
|
+
const m = /max_tokens:\s*\d+\s*>\s*(\d+)/i.exec(text)
|
|
215
|
+
|| /range of max_tokens is \[\s*\d+\s*,\s*(\d+)\s*\]/i.exec(text)
|
|
216
|
+
|| /(?:maximum|at most|limit(?:ed)? (?:of|to)|<=|less than or equal to)[^\d]{0,40}(\d{3,7})/i.exec(text);
|
|
217
|
+
const n = m ? parseInt(m[1], 10) : 0;
|
|
218
|
+
return n >= 256 ? n : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// `cacheBucket` says which prompt cache TTL applies: "main" for the conversation itself (the default), "other"
|
|
222
|
+
// for sub-agents, compaction and side queries (see Config.cacheTtl). `cacheMessages: false` leaves the messages
|
|
223
|
+
// unmarked: for a request whose messages are never sent twice, writing them to the cache only costs more.
|
|
224
|
+
export function buildParams(config, { model, system, messages, tools, maxTokens, effort, cacheBucket = 'main', cacheMessages = true }, login = null) {
|
|
225
|
+
if (config.usesClaudeCode) login ||= readClaudeCodeLogin(config);
|
|
226
|
+
const wanted = maxTokens || config.maxOutputTokens;
|
|
227
|
+
const refused = cacheRefused.get(cacheKey(config, model.id, login));
|
|
228
|
+
const caching = !config.promptCachingDisabled(model.id);
|
|
229
|
+
const ttl = refused?.has('ttl') ? '5m' : config.cacheTtl(cacheBucket);
|
|
230
|
+
const mark = (place) => (caching && !refused?.has(place) ? { ...cacheControl(ttl) } : null);
|
|
231
|
+
const systemMark = mark('system');
|
|
232
|
+
const params = {
|
|
233
|
+
model: model.id,
|
|
234
|
+
max_tokens: outputCaps.has(model.id) ? Math.min(wanted, outputCaps.get(model.id)) : wanted,
|
|
235
|
+
system: [{ type: 'text', text: system, ...(systemMark ? { cache_control: systemMark } : {}) }],
|
|
236
|
+
messages: withCacheControl(messages, cacheMessages ? mark('messages') : null),
|
|
237
|
+
stream: true,
|
|
238
|
+
};
|
|
239
|
+
if (login) {
|
|
240
|
+
params.system.unshift({ type: 'text', text: CLAUDE_CODE_SYSTEM });
|
|
241
|
+
params.metadata = { user_id: login.userId };
|
|
242
|
+
}
|
|
243
|
+
if (tools?.length) {
|
|
244
|
+
const toolMark = mark('tools');
|
|
245
|
+
params.tools = tools.map((t, i) => (i === tools.length - 1 && toolMark ? { ...t, cache_control: toolMark } : t));
|
|
246
|
+
}
|
|
247
|
+
// A GPT model with no effort set gets its default from the local Codex models cache, sent explicitly, so that
|
|
248
|
+
// the gateway (whose own default is its Codex config) uses what the footer shows.
|
|
249
|
+
const eff = (effort === undefined ? config.effort : effort) || (/^gpt-/i.test(model.id) ? defaultEffort(model.id) : null);
|
|
250
|
+
if (eff && !noEffort.has(model.id)) params.output_config = { effort: eff };
|
|
251
|
+
const budget = config.thinkingBudget;
|
|
252
|
+
if (budget === 0) params.thinking = { type: 'disabled' };
|
|
253
|
+
else if (budget > 0) params.thinking = { type: 'enabled', budget_tokens: Math.min(budget, params.max_tokens - 1) };
|
|
254
|
+
return params;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export class APIError extends Error {
|
|
258
|
+
constructor(message, { status, retryable, promptTooLong, retryAfterMs = null, rateLimit = null } = {}) {
|
|
259
|
+
super(message);
|
|
260
|
+
this.status = status;
|
|
261
|
+
this.retryable = retryable;
|
|
262
|
+
this.promptTooLong = promptTooLong;
|
|
263
|
+
this.retryAfterMs = retryAfterMs; // what the server's Retry-After header asked for
|
|
264
|
+
this.rateLimit = rateLimit; // what the rate limit headers say (see ratelimit.js)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function describeError(err, config) {
|
|
269
|
+
if (err?.name === 'AbortError' || err instanceof Anthropic.APIUserAbortError) return err;
|
|
270
|
+
const status = err?.status;
|
|
271
|
+
let msg = err?.error?.error?.message || err?.error?.message || err?.message || String(err);
|
|
272
|
+
const promptTooLong = /prompt is too long|context length|maximum context|too many tokens|context_length_exceeded/i.test(msg);
|
|
273
|
+
if (err instanceof APIError) return err;
|
|
274
|
+
if (status === 401) msg = config?.usesClaudeCode
|
|
275
|
+
? `Claude Code OAuth authentication failed (401). Check the existing local subscription credentials in ${config.claudeCodeCredentialsFile}. ${msg}`
|
|
276
|
+
: config?.usesGateway ? `The gateway refused the login (401). Run /login to log in again. ${msg}`
|
|
277
|
+
: config?.usesCodex ? msg
|
|
278
|
+
: `Authentication failed (401). Check ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY. ${msg}`;
|
|
279
|
+
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}`;
|
|
280
|
+
const retryable = !status || status === 408 || status === 409 || status === 429 || status >= 500;
|
|
281
|
+
const header = typeof err?.headers?.get === 'function' ? err.headers.get('retry-after') : err?.headers?.['retry-after'];
|
|
282
|
+
return new APIError(msg, { status, retryable, promptTooLong, retryAfterMs: parseRetryAfter(header), rateLimit: parseRateLimit(err?.headers, timing.now()) });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Our own aborts are named AbortError and the SDK's is an APIUserAbortError. The message test only
|
|
286
|
+
// accepts the bare "aborted" wording: a server error that merely mentions "aborted" is not the user.
|
|
287
|
+
export function isAbort(err) {
|
|
288
|
+
return err?.name === 'AbortError' || err instanceof Anthropic.APIUserAbortError || /^(?:request was )?aborted\.?$/i.test(err?.message || '');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Stream a message. `onEvent` receives:
|
|
292
|
+
// {type:'block_start', index, block}
|
|
293
|
+
// {type:'text', index, text} text delta
|
|
294
|
+
// {type:'thinking', index, text} thinking delta
|
|
295
|
+
// {type:'tool_input', index, partial} partial JSON of a tool call
|
|
296
|
+
// {type:'block_stop', index, block} finished block (tool_use input parsed)
|
|
297
|
+
// {type:'retry', attempt, error, delayMs, maxRetries} a quick retry is about to wait delayMs
|
|
298
|
+
// {type:'rate_limit_wait', error, delayMs, resumeAt, resetAt, label, polling, waitedMs, maxWaitMs, loginExpiresAt}
|
|
299
|
+
// a rate limit was hit: paused until resumeAt, then the request is repeated
|
|
300
|
+
// {type:'rate_limit_resume', waitedMs} the repeated request was accepted: the pause is over
|
|
301
|
+
// {type:'reset'} partial output from a failed attempt must be discarded
|
|
302
|
+
// `waitForRateLimit: false` reports a rate limit as an error instead of waiting for it (side queries
|
|
303
|
+
// where nobody wants to sit through a pause). Returns {message, usage, stopReason}.
|
|
304
|
+
export async function streamMessage(config, request, { signal, onEvent = () => {}, waitForRateLimit = true } = {}) {
|
|
305
|
+
if (signal?.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError', partial: [] });
|
|
306
|
+
// A token that has expired (or is about to) is renewed first, as the official client does.
|
|
307
|
+
if (config.usesClaudeCode) await renewLogin(config, signal);
|
|
308
|
+
let login = config.usesClaudeCode ? readClaudeCodeLogin(config) : null;
|
|
309
|
+
let client = getClient(config, login);
|
|
310
|
+
const params = buildParams(config, request, login);
|
|
311
|
+
let authReloaded = false;
|
|
312
|
+
const refused = new Set(); // what the endpoint refused for this request: remembered once a request got through
|
|
313
|
+
const retryState = { attempt: 0, rateLimitHits: 0, waitedMs: 0 };
|
|
314
|
+
let paused = false;
|
|
315
|
+
for (;;) {
|
|
316
|
+
let emitted = false;
|
|
317
|
+
const startedAt = Date.now(); // the cache TTL counts from the start of the request, not from the end of the reply
|
|
318
|
+
try {
|
|
319
|
+
const stream = await client.messages.create(params, { signal });
|
|
320
|
+
if (paused) {
|
|
321
|
+
paused = false;
|
|
322
|
+
onEvent({ type: 'rate_limit_resume', waitedMs: retryState.waitedMs });
|
|
323
|
+
}
|
|
324
|
+
const blocks = [];
|
|
325
|
+
const jsonBufs = [];
|
|
326
|
+
let usage = {};
|
|
327
|
+
let stopReason = null;
|
|
328
|
+
let model = params.model;
|
|
329
|
+
let stopped = false;
|
|
330
|
+
for await (const ev of stream) {
|
|
331
|
+
switch (ev.type) {
|
|
332
|
+
case 'message_stop':
|
|
333
|
+
stopped = true;
|
|
334
|
+
break;
|
|
335
|
+
case 'message_start':
|
|
336
|
+
usage = { ...ev.message.usage };
|
|
337
|
+
model = ev.message.model || model;
|
|
338
|
+
break;
|
|
339
|
+
case 'content_block_start': {
|
|
340
|
+
const b = { ...ev.content_block };
|
|
341
|
+
if (b.type === 'tool_use') { b.input = {}; jsonBufs[ev.index] = ''; }
|
|
342
|
+
if (b.type === 'text') b.text = b.text || '';
|
|
343
|
+
if (b.type === 'thinking') { b.thinking = b.thinking || ''; b.signature = b.signature || ''; }
|
|
344
|
+
blocks[ev.index] = b;
|
|
345
|
+
emitted = true;
|
|
346
|
+
onEvent({ type: 'block_start', index: ev.index, block: b });
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
case 'content_block_delta': {
|
|
350
|
+
const b = blocks[ev.index];
|
|
351
|
+
const d = ev.delta;
|
|
352
|
+
if (!b) break;
|
|
353
|
+
if (d.type === 'text_delta') { b.text += d.text; onEvent({ type: 'text', index: ev.index, text: d.text }); }
|
|
354
|
+
else if (d.type === 'thinking_delta') { b.thinking += d.thinking; onEvent({ type: 'thinking', index: ev.index, text: d.thinking }); }
|
|
355
|
+
else if (d.type === 'signature_delta') b.signature = (b.signature || '') + d.signature;
|
|
356
|
+
else if (d.type === 'input_json_delta') {
|
|
357
|
+
jsonBufs[ev.index] += d.partial_json;
|
|
358
|
+
onEvent({ type: 'tool_input', index: ev.index, partial: jsonBufs[ev.index], block: b });
|
|
359
|
+
}
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
case 'content_block_stop': {
|
|
363
|
+
const b = blocks[ev.index];
|
|
364
|
+
if (b?.type === 'tool_use') {
|
|
365
|
+
const raw = jsonBufs[ev.index];
|
|
366
|
+
try { b.input = raw ? JSON.parse(raw) : {}; } catch { b.input = { __invalid_json: raw }; }
|
|
367
|
+
}
|
|
368
|
+
if (b) onEvent({ type: 'block_stop', index: ev.index, block: b });
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
case 'message_delta':
|
|
372
|
+
if (ev.delta?.stop_reason) stopReason = ev.delta.stop_reason;
|
|
373
|
+
if (ev.usage) usage = { ...usage, ...Object.fromEntries(Object.entries(ev.usage).filter(([, v]) => v != null)) };
|
|
374
|
+
break;
|
|
375
|
+
case 'error':
|
|
376
|
+
throw Object.assign(new Error(ev.error?.message || 'stream error'), { status: ev.error?.type === 'rate_limit_error' ? 429 : 500 });
|
|
377
|
+
default:
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// The SDK ends the iterator quietly when aborted; a stream without message_stop was cut off.
|
|
382
|
+
if (signal?.aborted) {
|
|
383
|
+
const partial = blocks.filter((b) => b?.type === 'text' && b.text.trim()).map((b) => ({ type: 'text', text: b.text }));
|
|
384
|
+
throw Object.assign(new Error('aborted'), { name: 'AbortError', partial });
|
|
385
|
+
}
|
|
386
|
+
if (!stopped && !stopReason) throw Object.assign(new Error('The response stream ended unexpectedly'), { status: 503 });
|
|
387
|
+
const content = blocks.filter(Boolean).filter((b) => !(b.type === 'text' && !b.text));
|
|
388
|
+
if (refused.size) { // it got through without what the endpoint refused: leave that off from now on
|
|
389
|
+
const key = cacheKey(config, params.model, login);
|
|
390
|
+
cacheRefused.set(key, new Set([...(cacheRefused.get(key) || []), ...refused]));
|
|
391
|
+
}
|
|
392
|
+
return { message: { role: 'assistant', content }, usage, stopReason, model, cache: { ...cachePlanOf(params), fingerprint: fingerprint(params), at: startedAt } };
|
|
393
|
+
} catch (raw) {
|
|
394
|
+
if (signal?.aborted || isAbort(raw)) throw Object.assign(new Error('aborted'), { name: 'AbortError', partial: raw?.partial || [] });
|
|
395
|
+
const err = describeError(raw, config);
|
|
396
|
+
// The token was refused. A running native Claude Code session may have renewed the shared token
|
|
397
|
+
// while this request was in flight: read it again. If it is the same one, it may have been revoked
|
|
398
|
+
// early or the local clock may be off: renew it once and try again.
|
|
399
|
+
if (login && err.status === 401 && !authReloaded) {
|
|
400
|
+
authReloaded = true;
|
|
401
|
+
const readValid = () => { try { return readClaudeCodeLogin(config); } catch { return null; } };
|
|
402
|
+
let updated = readValid();
|
|
403
|
+
if (!updated || updated.accessToken === login.accessToken) {
|
|
404
|
+
let failure = null;
|
|
405
|
+
if (await renewLogin(config, signal, { force: true, onError: (e) => { failure = e; } })) updated = readValid();
|
|
406
|
+
else if (!updated && failure) throw failure; // the token ran out meanwhile and cannot be renewed: say why, not just "401"
|
|
407
|
+
}
|
|
408
|
+
if (updated && updated.accessToken !== login.accessToken) {
|
|
409
|
+
login = updated;
|
|
410
|
+
client = getClient(config, login);
|
|
411
|
+
params.metadata = { user_id: login.userId };
|
|
412
|
+
if (emitted) onEvent({ type: 'reset' });
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Not every model takes an effort level. Leave it out for this one, and for the rest of the session.
|
|
417
|
+
if (err.status === 400 && params.output_config && /does not support the effort parameter/i.test(err.message)) {
|
|
418
|
+
noEffort.add(params.model);
|
|
419
|
+
delete params.output_config;
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
// The endpoint does not take cache_control (or the 1-hour ttl) where it was sent: carry on without it
|
|
423
|
+
// instead of failing the request, and do not send it there again (see refuseCache).
|
|
424
|
+
if (err.status === 400 && /cache_control|\bttl\b/i.test(err.message)) {
|
|
425
|
+
const taken = refuseCache(params, err.message);
|
|
426
|
+
if (taken.size) {
|
|
427
|
+
for (const item of taken) refused.add(item);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// The default max_tokens is generous: if this API allows less, learn its limit and retry at once.
|
|
432
|
+
const limit = err.status === 400 ? parseMaxTokensLimit(err.message) : null;
|
|
433
|
+
if (limit && limit < params.max_tokens) {
|
|
434
|
+
outputCaps.set(params.model, limit);
|
|
435
|
+
params.max_tokens = limit;
|
|
436
|
+
if (params.thinking?.budget_tokens) params.thinking.budget_tokens = Math.min(params.thinking.budget_tokens, limit - 1);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const plan = planRetry(err, retryState, config, { now: timing.now(), allowWait: waitForRateLimit });
|
|
440
|
+
if (plan.action === 'throw') {
|
|
441
|
+
if (plan.message) err.message = plan.message;
|
|
442
|
+
throw err;
|
|
443
|
+
}
|
|
444
|
+
if (emitted) onEvent({ type: 'reset' });
|
|
445
|
+
if (plan.kind === 'rate_limit') {
|
|
446
|
+
paused = true;
|
|
447
|
+
onEvent({
|
|
448
|
+
type: 'rate_limit_wait', error: err, delayMs: plan.delayMs, resumeAt: plan.resumeAt, resetAt: plan.resetAt,
|
|
449
|
+
label: plan.label, polling: plan.polling, waitedMs: retryState.waitedMs, maxWaitMs: config.rateLimitMaxWait,
|
|
450
|
+
loginExpiresAt: loginExpiryToWarnAbout(config, login),
|
|
451
|
+
});
|
|
452
|
+
await sleepUntil(plan.resumeAt, signal);
|
|
453
|
+
// Hours may have passed: pick up the login again, the local Claude Code may have refreshed
|
|
454
|
+
// the token (and if it has expired, wait for that rather than fail with a 401).
|
|
455
|
+
if (config.usesClaudeCode) {
|
|
456
|
+
login = await loginAfterPause(config, retryState, { signal, onEvent });
|
|
457
|
+
client = getClient(config, login);
|
|
458
|
+
params.metadata = { user_id: login.userId };
|
|
459
|
+
}
|
|
460
|
+
retryState.attempt = 0; // the pause is a fresh start: quick retries made before it do not count after it
|
|
461
|
+
} else {
|
|
462
|
+
onEvent({ type: 'retry', attempt: plan.attempt, error: err, delayMs: plan.delayMs, maxRetries: plan.maxRetries });
|
|
463
|
+
await sleepUntil(plan.resumeAt, signal);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// One-shot, non-streaming helper for small side queries (summaries, titles, web page extraction).
|
|
470
|
+
// Nothing here can tell the user that it is paused, so a rate limit is reported as an error by default
|
|
471
|
+
// instead of being waited for out of sight; waitForRateLimit: true is for a caller that accepts that.
|
|
472
|
+
export async function quickQuery(config, { model, system, prompt, maxTokens = 4096, signal, waitForRateLimit = false }) {
|
|
473
|
+
const res = await streamMessage(config, {
|
|
474
|
+
model,
|
|
475
|
+
system,
|
|
476
|
+
messages: [{ role: 'user', content: prompt }],
|
|
477
|
+
maxTokens,
|
|
478
|
+
effort: null,
|
|
479
|
+
cacheBucket: 'other',
|
|
480
|
+
cacheMessages: false, // one-off content (a whole web page, say): caching it would only cost the write premium
|
|
481
|
+
}, { signal, waitForRateLimit });
|
|
482
|
+
return res.message.content.filter((b) => b.type === 'text').map((b) => b.text).join('').trim();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function contextTokens(usage) {
|
|
486
|
+
if (!usage) return 0;
|
|
487
|
+
return (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0) + (usage.output_tokens || 0);
|
|
488
|
+
}
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Prompt caching, the way Claude Code does it (https://code.claude.com/docs/en/prompt-caching):
|
|
2
|
+
// - cache_control markers go on the tools, the system prompt and the newest message (see api.js);
|
|
3
|
+
// - there are two TTL buckets: "main" is the conversation itself, "other" is everything else
|
|
4
|
+
// (sub-agents, compaction, side queries). Each is 5 minutes by default, or 1 hour when asked for;
|
|
5
|
+
// - the whole thing can be switched off;
|
|
6
|
+
// - what the API reports (cache_read_input_tokens, cache_creation_input_tokens) is tracked here, so
|
|
7
|
+
// that /cost can say how much of the input came from the cache and why a request missed it.
|
|
8
|
+
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
export const TTL_MS = { '5m': 5 * 60_000, '1h': 60 * 60_000 };
|
|
12
|
+
|
|
13
|
+
// A TTL setting is "5m" or "1h"; Claude Code ignores any other value, and so does this.
|
|
14
|
+
export function normalizeTtl(value) {
|
|
15
|
+
const v = String(value ?? '').trim().toLowerCase();
|
|
16
|
+
return v === '5m' || v === '1h' ? v : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The 5-minute TTL is the API's default, so it is sent without a ttl field: the request is then the same as
|
|
20
|
+
// it always was, and endpoints that do not know the field are not bothered by it.
|
|
21
|
+
export function cacheControl(ttl) {
|
|
22
|
+
return ttl === '1h' ? { type: 'ephemeral', ttl: '1h' } : { type: 'ephemeral' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sha = (text) => crypto.createHash('sha1').update(text).digest('hex').slice(0, 16);
|
|
26
|
+
|
|
27
|
+
// What decides whether two requests can share a cache: the model, the system prompt, the tool definitions and
|
|
28
|
+
// the parameters that are part of the key. (The messages are compared by the caller's own notes: see noteRewrite.)
|
|
29
|
+
export function fingerprint(params) {
|
|
30
|
+
const system = (params.system || []).map((b) => b.text).join('\n');
|
|
31
|
+
const tools = JSON.stringify((params.tools || []).map(({ cache_control, ...rest }) => rest));
|
|
32
|
+
return {
|
|
33
|
+
model: params.model,
|
|
34
|
+
system: sha(system),
|
|
35
|
+
tools: sha(tools),
|
|
36
|
+
effort: params.output_config?.effort || null,
|
|
37
|
+
thinking: JSON.stringify(params.thinking || null),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The TTL a request ended up with, and whether it carries any marker at all.
|
|
42
|
+
export function cachePlanOf(params) {
|
|
43
|
+
const marks = [
|
|
44
|
+
...(params.system || []).map((b) => b.cache_control),
|
|
45
|
+
...(params.tools || []).map((t) => t.cache_control),
|
|
46
|
+
...(params.messages || []).flatMap((m) => (Array.isArray(m.content) ? m.content.map((b) => b.cache_control) : [])),
|
|
47
|
+
].filter(Boolean);
|
|
48
|
+
return { caching: marks.length > 0, ttl: marks.some((m) => m.ttl === '1h') ? '1h' : '5m' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Below this size the API does not cache anything (the smallest minimum of any model).
|
|
52
|
+
const MIN_CACHED = 1024;
|
|
53
|
+
// A request that read less than this share of what the previous one left in the cache missed.
|
|
54
|
+
const MISS_BELOW = 0.5;
|
|
55
|
+
|
|
56
|
+
const human = (ms) => {
|
|
57
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
58
|
+
if (s < 60) return `${s}s`;
|
|
59
|
+
const m = Math.floor(s / 60);
|
|
60
|
+
return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export class CacheStats {
|
|
64
|
+
constructor() {
|
|
65
|
+
this.requests = 0; // requests recorded (compaction included)
|
|
66
|
+
this.input = 0; // input tokens that were not cached
|
|
67
|
+
this.read = 0; // input tokens read from the cache
|
|
68
|
+
this.write = 0; // input tokens written to the cache
|
|
69
|
+
this.write1h = 0; // ... of which with the 1-hour TTL
|
|
70
|
+
this.misses = 0;
|
|
71
|
+
this.lastMiss = null; // {n, cause, expected, read}
|
|
72
|
+
this.newConversation();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The next request starts a conversation: there is nothing that it should find in the cache.
|
|
76
|
+
newConversation() {
|
|
77
|
+
this.expected = null; // tokens the previous request left cached; the next one should read about that many
|
|
78
|
+
this.prev = null; // fingerprint of the previous request
|
|
79
|
+
this.expiresAt = 0; // when what the previous request left in the cache runs out
|
|
80
|
+
this.ttl = '5m';
|
|
81
|
+
this.model = null; // the model that answered the last request
|
|
82
|
+
this.effort = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The history was cut or replaced (compaction, /rewind, /resume): the next request will not find the old
|
|
86
|
+
// one in the cache, and that is expected, not a miss.
|
|
87
|
+
noteRewrite() {
|
|
88
|
+
this.expected = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// One finished request. `at` is when it started: the TTL counts from there, not from the end of the reply.
|
|
92
|
+
record({ usage, fingerprint: fp = null, ttl = '5m', caching = true, at = Date.now() }) {
|
|
93
|
+
const u = usage || {};
|
|
94
|
+
const input = u.input_tokens || 0;
|
|
95
|
+
const read = u.cache_read_input_tokens || 0;
|
|
96
|
+
const write = u.cache_creation_input_tokens || 0;
|
|
97
|
+
this.requests++;
|
|
98
|
+
this.input += input;
|
|
99
|
+
this.read += read;
|
|
100
|
+
this.write += write;
|
|
101
|
+
this.write1h += u.cache_creation?.ephemeral_1h_input_tokens || 0;
|
|
102
|
+
if (caching && this.expected != null && this.expected >= MIN_CACHED && read < this.expected * MISS_BELOW) {
|
|
103
|
+
this.misses++;
|
|
104
|
+
this.lastMiss = { n: this.requests, cause: this.causeOf(fp, at), expected: this.expected, read };
|
|
105
|
+
}
|
|
106
|
+
// Whatever this request read or wrote is a prefix of the next one.
|
|
107
|
+
this.expected = caching ? read + write : null;
|
|
108
|
+
this.expiresAt = at + TTL_MS[ttl];
|
|
109
|
+
this.ttl = ttl;
|
|
110
|
+
if (fp) {
|
|
111
|
+
this.prev = fp;
|
|
112
|
+
this.model = fp.model;
|
|
113
|
+
this.effort = fp.effort;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
causeOf(fp, at) {
|
|
118
|
+
const p = this.prev;
|
|
119
|
+
if (p && fp) {
|
|
120
|
+
if (p.model !== fp.model) return `the model changed (${p.model} → ${fp.model})`;
|
|
121
|
+
if (p.system !== fp.system) return 'the system prompt changed';
|
|
122
|
+
if (p.tools !== fp.tools) return 'the tool definitions changed';
|
|
123
|
+
if (p.effort !== fp.effort) return `the effort changed (${p.effort || 'auto'} → ${fp.effort || 'auto'})`;
|
|
124
|
+
if (p.thinking !== fp.thinking) return 'the thinking settings changed';
|
|
125
|
+
}
|
|
126
|
+
if (at >= this.expiresAt) return `the cache had expired (${human(at - (this.expiresAt - TTL_MS[this.ttl]))} since the previous request, TTL ${this.ttl})`;
|
|
127
|
+
return 'earlier messages changed, or the endpoint does not cache';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Is there something worth keeping in the cache right now? Not when nothing was cached, or it has expired.
|
|
131
|
+
isWarm(now = Date.now()) {
|
|
132
|
+
return this.expected != null && this.expected >= MIN_CACHED && now < this.expiresAt;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get hitRatio() {
|
|
136
|
+
const total = this.input + this.read + this.write;
|
|
137
|
+
return total > 0 ? this.read / total : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The lines /cost shows for this conversation's cache.
|
|
141
|
+
describe(now = Date.now()) {
|
|
142
|
+
if (!this.requests) return [];
|
|
143
|
+
if (!this.read && !this.write) return ['Prompt cache: nothing reported (the endpoint does not report cache use, or does not cache)'];
|
|
144
|
+
const pct = this.hitRatio == null ? '–' : `${Math.round(this.hitRatio * 100)}%`;
|
|
145
|
+
const state = this.isWarm(now) ? `warm for ${human(this.expiresAt - now)} more` : 'cold';
|
|
146
|
+
const lines = [`Prompt cache: ${pct} of the input came from the cache · ${this.misses} miss${this.misses === 1 ? '' : 'es'} · ${state} (TTL ${this.ttl})`];
|
|
147
|
+
if (this.lastMiss) lines.push(` last miss: request ${this.lastMiss.n}, probably because ${this.lastMiss.cause}`);
|
|
148
|
+
return lines;
|
|
149
|
+
}
|
|
150
|
+
}
|