@yanzc/claude-pro 0.0.0-stage → 0.4.1

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