qwenproxy-cli 1.0.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.
Files changed (109) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +907 -0
  3. package/bin/qwenproxy.js +141 -0
  4. package/package.json +78 -0
  5. package/src/api/error-classifier.ts +159 -0
  6. package/src/api/error-helpers.ts +118 -0
  7. package/src/api/models.ts +261 -0
  8. package/src/api/server.ts +859 -0
  9. package/src/cache/memory-cache.ts +385 -0
  10. package/src/clean-cache.ts +204 -0
  11. package/src/core/account-concurrency.ts +671 -0
  12. package/src/core/account-manager.ts +297 -0
  13. package/src/core/account-priority.ts +163 -0
  14. package/src/core/accounts.ts +186 -0
  15. package/src/core/config.ts +383 -0
  16. package/src/core/crypto-utils.ts +79 -0
  17. package/src/core/database.ts +276 -0
  18. package/src/core/errors.ts +118 -0
  19. package/src/core/logger.ts +269 -0
  20. package/src/core/memory-usage.ts +84 -0
  21. package/src/core/metrics.ts +291 -0
  22. package/src/core/model-alias.ts +77 -0
  23. package/src/core/model-registry.ts +544 -0
  24. package/src/core/mutex.ts +119 -0
  25. package/src/core/paths.ts +199 -0
  26. package/src/core/prompt-limits.ts +214 -0
  27. package/src/core/reasoning-effort.ts +102 -0
  28. package/src/core/stream-registry.ts +96 -0
  29. package/src/core/waf-isolation.ts +117 -0
  30. package/src/core/watchdog.ts +195 -0
  31. package/src/delete-chats.ts +23 -0
  32. package/src/index.ts +64 -0
  33. package/src/login.ts +147 -0
  34. package/src/reset-cooldowns.ts +11 -0
  35. package/src/routes/anthropic/index.ts +355 -0
  36. package/src/routes/anthropic/translate.ts +522 -0
  37. package/src/routes/anthropic/types.ts +154 -0
  38. package/src/routes/anthropic/validation.ts +144 -0
  39. package/src/routes/chat/account.ts +1817 -0
  40. package/src/routes/chat/context.ts +241 -0
  41. package/src/routes/chat/errors.ts +85 -0
  42. package/src/routes/chat/helpers.ts +268 -0
  43. package/src/routes/chat/index.ts +618 -0
  44. package/src/routes/chat/media.ts +285 -0
  45. package/src/routes/chat/retry-policy.ts +754 -0
  46. package/src/routes/chat/stop.ts +98 -0
  47. package/src/routes/chat/streaming.ts +2710 -0
  48. package/src/routes/chat/validation.ts +526 -0
  49. package/src/routes/chat.ts +2 -0
  50. package/src/routes/completions.ts +290 -0
  51. package/src/routes/images.ts +139 -0
  52. package/src/routes/responses/adapter.ts +503 -0
  53. package/src/routes/responses/index.ts +405 -0
  54. package/src/routes/responses/state.ts +230 -0
  55. package/src/routes/responses/streaming.ts +528 -0
  56. package/src/routes/responses/types.ts +285 -0
  57. package/src/routes/responses/validation.ts +202 -0
  58. package/src/routes/upload.ts +731 -0
  59. package/src/routes/videos.ts +214 -0
  60. package/src/services/auth-playwright.ts +173 -0
  61. package/src/services/captcha-coordinator.ts +161 -0
  62. package/src/services/captcha-solver.ts +553 -0
  63. package/src/services/chat-cleanup.ts +80 -0
  64. package/src/services/context-meter.ts +317 -0
  65. package/src/services/fingerprint.ts +242 -0
  66. package/src/services/human-behavior.ts +173 -0
  67. package/src/services/media-generation.ts +1748 -0
  68. package/src/services/playwright.ts +2800 -0
  69. package/src/services/qwen-chat-pool.ts +345 -0
  70. package/src/services/qwen-errors.ts +133 -0
  71. package/src/services/qwen-headers.ts +79 -0
  72. package/src/services/qwen-thread-state.ts +393 -0
  73. package/src/services/qwen-url.ts +19 -0
  74. package/src/services/qwen.ts +3126 -0
  75. package/src/services/session-keeper.ts +88 -0
  76. package/src/services/token-estimation-metrics.ts +118 -0
  77. package/src/sync/claude-code.ts +75 -0
  78. package/src/sync/codex.ts +123 -0
  79. package/src/sync/index.ts +362 -0
  80. package/src/sync/omp.ts +105 -0
  81. package/src/sync/opencode.ts +214 -0
  82. package/src/sync/types.ts +53 -0
  83. package/src/sync/utils.ts +27 -0
  84. package/src/sync-clients.ts +189 -0
  85. package/src/tools/instructions.ts +137 -0
  86. package/src/tools/manifest.ts +81 -0
  87. package/src/tools/parser.ts +2989 -0
  88. package/src/tools/toolcall-tags.ts +142 -0
  89. package/src/tools/types.ts +53 -0
  90. package/src/tui/app.ts +264 -0
  91. package/src/tui/index.ts +61 -0
  92. package/src/tui/markdown.ts +258 -0
  93. package/src/tui/proxy-client.ts +326 -0
  94. package/src/tui/screen.ts +278 -0
  95. package/src/tui/server-manager.ts +270 -0
  96. package/src/tui/theme.ts +432 -0
  97. package/src/tui/types.ts +33 -0
  98. package/src/tui/views/accounts-view.ts +656 -0
  99. package/src/tui/views/chat-view.ts +823 -0
  100. package/src/tui/views/logs-view.ts +413 -0
  101. package/src/tui/views/status-view.ts +204 -0
  102. package/src/tui/views/storage-view.ts +291 -0
  103. package/src/tui/views/sync-view.ts +409 -0
  104. package/src/types/ali-oss.d.ts +32 -0
  105. package/src/utils/context-truncation.ts +84 -0
  106. package/src/utils/json.ts +380 -0
  107. package/src/utils/session-id.ts +37 -0
  108. package/src/utils/tool-call-guard.ts +85 -0
  109. package/src/utils/types.ts +109 -0
@@ -0,0 +1,241 @@
1
+ import { config, type ChatMode } from "../../core/config.ts";
2
+ import { ContextLengthExceededError, ValidationError } from "../../core/errors.ts";
3
+ import { getModelContextWindow } from "../../core/model-registry.ts";
4
+ import {
5
+ assertPromptWithinLimits,
6
+ isRequestPersonalizationWithinLimit,
7
+ } from "../../core/prompt-limits.ts";
8
+ import type { Message } from "../../utils/types.ts";
9
+ import { estimateTokenCount } from "../../utils/context-truncation.ts";
10
+ import { deriveSessionId } from "../../utils/session-id.ts";
11
+ import { getLogicalThreadState, consumeToolCapNotice } from "../../services/qwen.ts";
12
+
13
+ export { estimateTokenCount, getModelContextWindow, deriveSessionId };
14
+
15
+ export interface FinalContext {
16
+ finalPrompt: string;
17
+ sessionId: string | null;
18
+ existingThread: boolean;
19
+ shouldResetUpstreamThread: boolean;
20
+ isNewSession: boolean;
21
+ useThreadNative: boolean;
22
+ updateLogicalThread: boolean;
23
+ chatMode: ChatMode;
24
+ isThinkingModel: boolean;
25
+ estimatedTokens: number;
26
+ modelContextWindow: number;
27
+ isTitleGenerationRequest: boolean;
28
+ requestPersonalizationInstruction: string | null;
29
+ hasExplicitConversationKey: boolean;
30
+ allowThreadReuse: boolean;
31
+ }
32
+
33
+ export interface BuildContextParams {
34
+ messages: Message[];
35
+ systemPrompt: string;
36
+ toolInstructions: string;
37
+ prompt: string;
38
+ currentPrompt: string;
39
+ modelId: string;
40
+ enableThinking: boolean;
41
+ conversationKey: string | null;
42
+ hasExplicitConversationKey: boolean;
43
+ chatMode?: ChatMode;
44
+ }
45
+
46
+ export async function buildFinalContext(
47
+ params: BuildContextParams,
48
+ ): Promise<FinalContext> {
49
+ const {
50
+ messages,
51
+ systemPrompt,
52
+ toolInstructions,
53
+ prompt,
54
+ currentPrompt,
55
+ modelId,
56
+ enableThinking,
57
+ conversationKey,
58
+ hasExplicitConversationKey,
59
+ chatMode = "thread",
60
+ } = params;
61
+
62
+ const modelContextWindow = getModelContextWindow(modelId);
63
+ const useThreadNative = true;
64
+ const isTempMode = chatMode === "temp";
65
+ // A continuation is ANY evidence of a prior turn, not just a plain
66
+ // role:"assistant" message. Tool-loop clients (Zed/Cline) can send history
67
+ // with tool/function responses or assistant tool_calls but WITHOUT a plain
68
+ // assistant entry; misclassifying those as a new session forced the FULL
69
+ // history to be re-sent on every request (and every chat_in_progress retry)
70
+ // instead of the thread-native delta.
71
+ // In temp mode EVERY request is a new (ephemeral) chat, so the whole history
72
+ // is always sent and no thread state is ever consulted.
73
+ const isNewSession = isTempMode
74
+ ? true
75
+ : !messages.some(isContinuationMessage);
76
+ const completeInstructions = [systemPrompt.trim(), toolInstructions.trim()]
77
+ .filter(Boolean)
78
+ .join("\n\n");
79
+
80
+ // Thread reuse is allowed when:
81
+ // 1. Thread-native mode is active
82
+ // 2. Either: explicit session_id/conversation_id was provided
83
+ // OR: this is a continuation (has assistant messages in history)
84
+ // This prevents new IDE chats from accidentally reusing old Qwen chats
85
+ // while still allowing continuations without explicit session_id
86
+ const allowThreadReuse = isTempMode
87
+ ? false
88
+ : useThreadNative && (hasExplicitConversationKey || !isNewSession); // has assistant messages = continuation of existing chat
89
+
90
+ // Compute sessionId: only generate a persistent session ID when we have
91
+ // an explicit conversation key. Otherwise, generate an ephemeral ID for
92
+ // logging/metrics only (not used for thread reuse). Temp mode never persists
93
+ // a thread, so it has no session id.
94
+ const sessionId = isTempMode
95
+ ? null
96
+ : (conversationKey || useThreadNative)
97
+ ? deriveSessionId(
98
+ messages,
99
+ conversationKey ? completeInstructions : "",
100
+ conversationKey ?? "implicit-thread",
101
+ )
102
+ : null;
103
+
104
+ // Only load existing thread when reuse is allowed
105
+ const existingThread = allowThreadReuse
106
+ ? getLogicalThreadState(sessionId)
107
+ : null;
108
+
109
+ const hasTrailingToolResult = detectTrailingToolResult(messages);
110
+ // Thread-native: send full history when Qwen has no context yet, but preserve
111
+ // tool-result deltas because the upstream parent chain already owns the call.
112
+ // Temp mode: always send the FULL history (OpenAI standard).
113
+ const baseActivePrompt = isTempMode
114
+ ? prompt
115
+ : (!existingThread && !hasTrailingToolResult ? prompt : currentPrompt) ||
116
+ prompt;
117
+
118
+ // If the previous turn of this session was closed early at the per-turn
119
+ // tool-call cap, tell the model so it knows calls beyond the cap were NOT
120
+ // executed and can re-issue them. The notice is consumed once (it clears
121
+ // itself) and rides this single turn only. This is a transient system notice,
122
+ // not the persistent personalization instruction, so it may live in the prompt.
123
+ const toolCapNotice = consumeToolCapNotice(sessionId)
124
+ ? `[SYSTEM NOTICE] Your previous response reached the maximum of ${config.retry.maxToolCallsPerTurn} tool calls per turn; any tool calls beyond that limit were NOT executed. Review the tool results below and continue; if you intended more operations, issue them now in smaller batches.\n\n`
125
+ : "";
126
+ const activePrompt = toolCapNotice
127
+ ? toolCapNotice + baseActivePrompt
128
+ : baseActivePrompt;
129
+ const isTitleGenerationRequest = detectTitleGenerationRequest(messages);
130
+ const requestedPersonalization =
131
+ config.qwen.personalizationFromRequest && !isTitleGenerationRequest;
132
+ const personalizationInstruction = completeInstructions;
133
+ const useRequestPersonalization =
134
+ requestedPersonalization &&
135
+ isRequestPersonalizationWithinLimit(personalizationInstruction);
136
+
137
+ // Agent instructions and tools ride ONLY the account-level personalization
138
+ // (confirmed before the completion request is sent — the real Qwen client
139
+ // also never sends a system prompt in the completions payload). When the
140
+ // channel cannot carry them, fail loud instead of degrading to inline.
141
+ if (completeInstructions && !useRequestPersonalization) {
142
+ if (requestedPersonalization) {
143
+ throw new ContextLengthExceededError(
144
+ `System instructions and tools (${Buffer.byteLength(personalizationInstruction, "utf8")} bytes) exceed the personalization payload limit (${config.qwen.maxPersonalizationBytes} bytes) and are no longer sent inline. Raise QWEN_MAX_PERSONALIZATION_BYTES or reduce the instruction size.`,
145
+ );
146
+ }
147
+ if (!isTitleGenerationRequest) {
148
+ throw new ValidationError(
149
+ "Agent instructions can only be delivered via Qwen account personalization, but QWEN_PERSONALIZATION_FROM_REQUEST is disabled. Re-enable it or remove the system instructions from the request.",
150
+ );
151
+ }
152
+ }
153
+ const estimatedTokens = estimateTokenCount(
154
+ completeInstructions,
155
+ activePrompt,
156
+ );
157
+ // Instructions are delivered exclusively via account-level personalization;
158
+ // the prompt carries only the conversation. Title generation does not sync
159
+ // personalization, so it keeps its (small) instructions inline.
160
+ const finalPrompt =
161
+ isTitleGenerationRequest && completeInstructions
162
+ ? `${completeInstructions}\n${activePrompt}`
163
+ : activePrompt;
164
+
165
+ // Truncation is deferred to tryCreateStreamWithRetry, which runs after the
166
+ // account is selected and the real model context window has been synced from
167
+ // Qwen's /api/models catalog. The early context build only performs the byte
168
+ // limit check; the authoritative token check happens downstream.
169
+ assertPromptWithinLimits(finalPrompt, modelId, { checkModelContext: false });
170
+
171
+ const isThinkingModel = enableThinking;
172
+ const shouldResetUpstreamThread = false;
173
+
174
+ return {
175
+ finalPrompt,
176
+ sessionId,
177
+ existingThread: !!existingThread,
178
+ shouldResetUpstreamThread,
179
+ isNewSession,
180
+ useThreadNative,
181
+ // Thread state is only persisted in thread mode (temp chats are ephemeral).
182
+ updateLogicalThread: isTempMode ? false : useThreadNative,
183
+ chatMode,
184
+ isThinkingModel,
185
+ estimatedTokens,
186
+ modelContextWindow,
187
+ isTitleGenerationRequest,
188
+ requestPersonalizationInstruction: useRequestPersonalization
189
+ ? personalizationInstruction
190
+ : null,
191
+ hasExplicitConversationKey,
192
+ allowThreadReuse,
193
+ };
194
+ }
195
+
196
+ function isContinuationMessage(message: Message): boolean {
197
+ return (
198
+ message.role === "assistant" ||
199
+ message.role === "tool" ||
200
+ message.role === "function" ||
201
+ (Array.isArray(message.tool_calls) && message.tool_calls.length > 0)
202
+ );
203
+ }
204
+
205
+ function extractMessageText(message: Message | undefined): string {
206
+ if (!message) return "";
207
+ const content: unknown = message.content;
208
+ if (typeof content === "string") return content;
209
+ if (Array.isArray(content)) {
210
+ return content
211
+ .map((part: any) => (part?.type === "text" ? part.text || "" : ""))
212
+ .join("\n");
213
+ }
214
+ if (content && typeof content === "object") return JSON.stringify(content);
215
+ return "";
216
+ }
217
+
218
+ function detectTrailingToolResult(messages: Message[]): boolean {
219
+ for (let i = messages.length - 1; i >= 0; i--) {
220
+ const role = messages[i].role;
221
+ if (role === "system") continue;
222
+ return role === "tool" || role === "function";
223
+ }
224
+ return false;
225
+ }
226
+
227
+ function detectTitleGenerationRequest(messages: Message[]): boolean {
228
+ if (messages.length < 2) return false;
229
+ const last = messages[messages.length - 1];
230
+ if (last?.role !== "user") return false;
231
+
232
+ const text = extractMessageText(last).toLowerCase();
233
+ if (!text) return false;
234
+
235
+ // The first pattern is strictly subsumed by the second (which does not
236
+ // require the leading verb), so it is redundant and was removed.
237
+ return (
238
+ /\btitle\b[\s\S]{0,80}\bconversation\b/.test(text) ||
239
+ /\bconversation\b[\s\S]{0,80}\btitle\b/.test(text)
240
+ );
241
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Parse a non-SSE upstream body that may contain a Qwen error payload.
3
+ * Returns null when the body is not a recognized error document.
4
+ */
5
+ export interface ParsedQwenErrorPayload {
6
+ code: string;
7
+ details: string;
8
+ message: string;
9
+ status: number;
10
+ }
11
+
12
+ function isWafChallenge(value: string): boolean {
13
+ const normalized = value.toLowerCase();
14
+ return (
15
+ normalized.includes("aliyun_waf") ||
16
+ normalized.includes("_____tmd_____") ||
17
+ normalized.includes("fail_sys_user_validate") ||
18
+ normalized.includes("rgv587_error") ||
19
+ normalized.includes("denyfromx5") ||
20
+ normalized.includes("captcha") ||
21
+ normalized.includes("security verification")
22
+ );
23
+ }
24
+
25
+ /**
26
+ * Parse an upstream response that arrived before any SSE event. The returned
27
+ * details are sanitized so an HTML WAF page is never sent back to API clients.
28
+ */
29
+ export function parseQwenErrorPayload(
30
+ raw: string,
31
+ ): ParsedQwenErrorPayload | null {
32
+ const text = raw.trim();
33
+ if (!text || text.startsWith("data:")) return null;
34
+
35
+ try {
36
+ const payload = JSON.parse(text);
37
+ if (payload && payload.success === false) {
38
+ const code = payload.data?.code || payload.code || "UpstreamError";
39
+ const details =
40
+ payload.data?.details || payload.message || "Qwen returned an error";
41
+ const wait =
42
+ payload.data?.num !== undefined
43
+ ? ` Wait about ${payload.data.num} hour(s) before trying again.`
44
+ : "";
45
+ const status =
46
+ code === "RateLimited" ? 429 : code === "Not_Found" ? 404 : 502;
47
+ return {
48
+ code,
49
+ details,
50
+ message: `Qwen upstream error: ${code}: ${details}.${wait}`,
51
+ status,
52
+ };
53
+ }
54
+ if (payload && payload.error) {
55
+ const error = payload.error;
56
+ const code =
57
+ typeof error === "object" && error?.code
58
+ ? error.code
59
+ : payload.code || "UpstreamError";
60
+ const details =
61
+ typeof error === "string"
62
+ ? error
63
+ : error.details || error.message || JSON.stringify(error);
64
+ return {
65
+ code,
66
+ details,
67
+ message: `Qwen upstream error: ${code}: ${details}`,
68
+ status: 502,
69
+ };
70
+ }
71
+ } catch {
72
+ const waf = isWafChallenge(text);
73
+ const details = waf
74
+ ? "Qwen returned an anti-bot challenge instead of an SSE response."
75
+ : "Qwen returned a non-SSE response before generation started.";
76
+ return {
77
+ code: waf ? "waf_challenge" : "non_sse_response",
78
+ details,
79
+ message: `Qwen upstream error: ${details}`,
80
+ status: 502,
81
+ };
82
+ }
83
+
84
+ return null;
85
+ }
@@ -0,0 +1,268 @@
1
+ import { Usage } from "../../utils/types.ts";
2
+
3
+ export interface DeltaResult {
4
+ delta: string;
5
+ matchedContent: string;
6
+ contentLength: number;
7
+ contentSuffix: string;
8
+ }
9
+
10
+ function buildDeltaResult(delta: string, matchedContent: string): DeltaResult {
11
+ return {
12
+ delta,
13
+ matchedContent,
14
+ contentLength: matchedContent.length,
15
+ contentSuffix: matchedContent.slice(-32),
16
+ };
17
+ }
18
+
19
+ export function getIncrementalDelta(
20
+ oldStr: string,
21
+ newStr: string,
22
+ previousLength = oldStr.length,
23
+ previousSuffix = oldStr.slice(-32),
24
+ ): DeltaResult {
25
+ if (!oldStr) {
26
+ return buildDeltaResult(newStr, newStr);
27
+ }
28
+ if (newStr === oldStr) {
29
+ return buildDeltaResult("", oldStr);
30
+ }
31
+
32
+ // Fast path for cumulative Qwen chunks: validate the old boundary using a
33
+ // short suffix instead of scanning the whole previous content with startsWith.
34
+ // Using 32-byte window (O(1)) since Qwen streams with incremental_output=true.
35
+ if (newStr.length > previousLength && previousLength > 0) {
36
+ const checkLen = Math.min(32, previousLength, previousSuffix.length);
37
+ const expectedSuffix = previousSuffix.slice(-checkLen);
38
+ const actualSuffix = newStr.slice(
39
+ previousLength - checkLen,
40
+ previousLength,
41
+ );
42
+
43
+ if (expectedSuffix === actualSuffix) {
44
+ return buildDeltaResult(newStr.slice(previousLength), newStr);
45
+ }
46
+ }
47
+
48
+ if (newStr.length >= oldStr.length && newStr.startsWith(oldStr)) {
49
+ return buildDeltaResult(newStr.substring(oldStr.length), newStr);
50
+ }
51
+
52
+ // Heuristic to detect if newStr is cumulative or incremental. Compare in
53
+ // small segments first to reduce per-character work on long responses.
54
+ const scanWindow = Math.min(2000, oldStr.length);
55
+ let commonPrefixLen = 0;
56
+ const maxLen = Math.min(scanWindow, newStr.length);
57
+ const segmentLen = 64;
58
+
59
+ while (commonPrefixLen + segmentLen <= maxLen) {
60
+ if (
61
+ oldStr.slice(commonPrefixLen, commonPrefixLen + segmentLen) !==
62
+ newStr.slice(commonPrefixLen, commonPrefixLen + segmentLen)
63
+ ) {
64
+ break;
65
+ }
66
+ commonPrefixLen += segmentLen;
67
+ }
68
+
69
+ while (
70
+ commonPrefixLen < maxLen &&
71
+ oldStr[commonPrefixLen] === newStr[commonPrefixLen]
72
+ ) {
73
+ commonPrefixLen++;
74
+ }
75
+
76
+ const threshold = Math.min(scanWindow, 4);
77
+ if (commonPrefixLen >= threshold) {
78
+ return buildDeltaResult(newStr.substring(commonPrefixLen), newStr);
79
+ }
80
+
81
+ // Treat as strictly incremental to avoid false-positive corruptions
82
+ return buildDeltaResult(newStr, oldStr + newStr);
83
+ }
84
+
85
+ export function formatThinkingSummaryContent(delta: any): string {
86
+ const titles = Array.isArray(delta?.extra?.summary_title?.content)
87
+ ? delta.extra.summary_title.content.filter(
88
+ (item: unknown): item is string => typeof item === "string",
89
+ )
90
+ : [];
91
+ const thoughts = Array.isArray(delta?.extra?.summary_thought?.content)
92
+ ? delta.extra.summary_thought.content.filter(
93
+ (item: unknown): item is string => typeof item === "string",
94
+ )
95
+ : [];
96
+
97
+ const sectionCount = Math.max(titles.length, thoughts.length);
98
+ const sections: string[] = [];
99
+
100
+ for (let i = 0; i < sectionCount; i++) {
101
+ const title = titles[i]?.trim() || "";
102
+ const thought = thoughts[i]?.trim() || "";
103
+
104
+ if (title && thought) {
105
+ sections.push(`**${title}**\n\n${thought}`);
106
+ } else if (title) {
107
+ sections.push(`**${title}**`);
108
+ } else if (thought) {
109
+ sections.push(thought);
110
+ }
111
+ }
112
+
113
+ return sections.join("\n\n");
114
+ }
115
+
116
+ export function isAbortError(err: unknown): boolean {
117
+ if (err instanceof DOMException) {
118
+ return err.name === "AbortError";
119
+ }
120
+
121
+ if (!err || typeof err !== "object") return false;
122
+
123
+ const maybeError = err as { name?: unknown; message?: unknown };
124
+ const name = maybeError.name;
125
+ const message = maybeError.message;
126
+
127
+ return (
128
+ name === "AbortError" ||
129
+ (typeof message === "string" && /abort(ed)?/i.test(message))
130
+ );
131
+ }
132
+
133
+ export function shouldSuppressStreamAbort(
134
+ err: unknown,
135
+ clientDisconnected: boolean,
136
+ requestAborted: boolean,
137
+ streamStillRegistered: boolean,
138
+ ): boolean {
139
+ return (
140
+ isAbortError(err) &&
141
+ (clientDisconnected || requestAborted || !streamStillRegistered)
142
+ );
143
+ }
144
+
145
+ export interface UsageAccumulator {
146
+ promptTokens: number;
147
+ completionTokens: number;
148
+ totalTokens: number;
149
+ hasRealPromptTokens: boolean;
150
+ hasRealCompletionTokens: boolean;
151
+ hasRealTotalTokens: boolean;
152
+ cachedPromptTokens: number;
153
+ promptTextTokens?: number;
154
+ reasoningTokens?: number;
155
+ completionTextTokens?: number;
156
+ }
157
+
158
+ function asFiniteNumber(value: unknown): number | null {
159
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
160
+ }
161
+
162
+ export function createUsageAccumulator(
163
+ estimatedPromptTokens: number,
164
+ ): UsageAccumulator {
165
+ return {
166
+ promptTokens: estimatedPromptTokens,
167
+ completionTokens: 0,
168
+ totalTokens: estimatedPromptTokens,
169
+ hasRealPromptTokens: false,
170
+ hasRealCompletionTokens: false,
171
+ hasRealTotalTokens: false,
172
+ cachedPromptTokens: 0,
173
+ };
174
+ }
175
+
176
+ export function applyUpstreamUsage(
177
+ accumulator: UsageAccumulator,
178
+ candidate: unknown,
179
+ ): void {
180
+ if (!candidate || typeof candidate !== "object") return;
181
+
182
+ const usage = candidate as Record<string, unknown>;
183
+ const promptTokens = asFiniteNumber(usage.input_tokens);
184
+ const completionTokens = asFiniteNumber(usage.output_tokens);
185
+ const totalTokens = asFiniteNumber(usage.total_tokens);
186
+
187
+ if (promptTokens !== null) {
188
+ accumulator.promptTokens = promptTokens;
189
+ accumulator.hasRealPromptTokens = true;
190
+ }
191
+
192
+ if (completionTokens !== null) {
193
+ accumulator.completionTokens = completionTokens;
194
+ accumulator.hasRealCompletionTokens = true;
195
+ }
196
+
197
+ if (totalTokens !== null) {
198
+ accumulator.totalTokens = totalTokens;
199
+ accumulator.hasRealTotalTokens = true;
200
+ }
201
+
202
+ const promptTokensDetails =
203
+ usage.prompt_tokens_details &&
204
+ typeof usage.prompt_tokens_details === "object"
205
+ ? (usage.prompt_tokens_details as Record<string, unknown>)
206
+ : null;
207
+ const inputTokensDetails =
208
+ usage.input_tokens_details && typeof usage.input_tokens_details === "object"
209
+ ? (usage.input_tokens_details as Record<string, unknown>)
210
+ : null;
211
+ const outputTokensDetails =
212
+ usage.output_tokens_details &&
213
+ typeof usage.output_tokens_details === "object"
214
+ ? (usage.output_tokens_details as Record<string, unknown>)
215
+ : null;
216
+
217
+ const cachedTokens = asFiniteNumber(promptTokensDetails?.cached_tokens);
218
+ if (cachedTokens !== null) {
219
+ accumulator.cachedPromptTokens = cachedTokens;
220
+ }
221
+
222
+ const promptTextTokens = asFiniteNumber(inputTokensDetails?.text_tokens);
223
+ if (promptTextTokens !== null) {
224
+ accumulator.promptTextTokens = promptTextTokens;
225
+ }
226
+
227
+ const reasoningTokens = asFiniteNumber(outputTokensDetails?.reasoning_tokens);
228
+ if (reasoningTokens !== null) {
229
+ accumulator.reasoningTokens = reasoningTokens;
230
+ }
231
+
232
+ const completionTextTokens = asFiniteNumber(outputTokensDetails?.text_tokens);
233
+ if (completionTextTokens !== null) {
234
+ accumulator.completionTextTokens = completionTextTokens;
235
+ }
236
+ }
237
+
238
+ export function buildUsage(accumulator: UsageAccumulator): Usage {
239
+ const usage: Usage = {
240
+ prompt_tokens: accumulator.promptTokens,
241
+ completion_tokens: accumulator.completionTokens,
242
+ total_tokens: accumulator.hasRealTotalTokens
243
+ ? accumulator.totalTokens
244
+ : accumulator.promptTokens + accumulator.completionTokens,
245
+ prompt_tokens_details: {
246
+ cached_tokens: accumulator.cachedPromptTokens,
247
+ ...(accumulator.promptTextTokens !== undefined
248
+ ? { text_tokens: accumulator.promptTextTokens }
249
+ : {}),
250
+ },
251
+ };
252
+
253
+ if (
254
+ accumulator.reasoningTokens !== undefined ||
255
+ accumulator.completionTextTokens !== undefined
256
+ ) {
257
+ usage.completion_tokens_details = {
258
+ ...(accumulator.reasoningTokens !== undefined
259
+ ? { reasoning_tokens: accumulator.reasoningTokens }
260
+ : {}),
261
+ ...(accumulator.completionTextTokens !== undefined
262
+ ? { text_tokens: accumulator.completionTextTokens }
263
+ : {}),
264
+ };
265
+ }
266
+
267
+ return usage;
268
+ }