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,199 @@
1
+ /**
2
+ * QwenProxy - Unified Cross-Platform Storage & Path Resolution Engine
3
+ *
4
+ * Provides resilient, OS-standard persistent data paths for:
5
+ * - SQLite Database & WAL logs
6
+ * - AES-256-GCM Master Encryption Key
7
+ * - Chromium persistent browser profiles (cookies & session storage)
8
+ * - Account priority & AI client sync caches
9
+ *
10
+ * Guarantees zero data loss on package reinstall/update across npm, pnpm, bun, and yarn.
11
+ */
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import os from "node:os";
16
+
17
+ export interface ResolveDataDirOptions {
18
+ envDataDir?: string;
19
+ isNodeTest?: boolean;
20
+ localDataExists?: boolean;
21
+ platform?: NodeJS.Platform;
22
+ appData?: string;
23
+ homeDir?: string;
24
+ xdgDataHome?: string;
25
+ }
26
+
27
+ /**
28
+ * Checks if the current process is running as a unit/mock test.
29
+ */
30
+ export function isRunningUnderNodeTest(): boolean {
31
+ return process.argv.some(
32
+ (arg) =>
33
+ arg === "--test" ||
34
+ arg.includes("src/tests/") ||
35
+ arg.includes("src\\tests\\"),
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Computes the canonical global user data directory according to OS conventions:
41
+ * - Windows: %APPDATA%\qwenproxy
42
+ * - macOS: ~/Library/Application Support/qwenproxy
43
+ * - Linux: $XDG_DATA_HOME/qwenproxy or ~/.local/share/qwenproxy
44
+ */
45
+ export function getOsGlobalDataDir(options?: {
46
+ platform?: NodeJS.Platform;
47
+ appData?: string;
48
+ homeDir?: string;
49
+ xdgDataHome?: string;
50
+ }): string {
51
+ const currentPlatform = options?.platform || process.platform;
52
+ const home = options?.homeDir || os.homedir();
53
+
54
+ if (currentPlatform === "win32") {
55
+ const winPath = path.win32 || path;
56
+ const appData =
57
+ options?.appData ||
58
+ process.env.APPDATA ||
59
+ winPath.join(home, "AppData", "Roaming");
60
+ return winPath.join(appData, "qwenproxy");
61
+ }
62
+
63
+ if (currentPlatform === "darwin") {
64
+ return path.join(home, "Library", "Application Support", "qwenproxy");
65
+ }
66
+
67
+ // Linux / BSD / POSIX
68
+ const xdgDataHome =
69
+ options?.xdgDataHome ||
70
+ process.env.XDG_DATA_HOME ||
71
+ path.join(home, ".local", "share");
72
+ return path.join(xdgDataHome, "qwenproxy");
73
+ }
74
+
75
+ /**
76
+ * Resolves the active data directory based on environment, testing state,
77
+ * local checkout presence, or the persistent global OS user directory.
78
+ */
79
+ export function resolveDataDir(options?: ResolveDataDirOptions): string {
80
+ // 1. Explicit environment variable override takes top precedence
81
+ const envDir = options?.envDataDir ?? process.env.QWEN_DATA_DIR;
82
+ if (envDir && envDir.trim().length > 0) {
83
+ return path.resolve(envDir.trim());
84
+ }
85
+
86
+ // 2. Automated test isolation
87
+ const isTest = options?.isNodeTest ?? isRunningUnderNodeTest();
88
+ if (isTest) {
89
+ if (options?.localDataExists === false) {
90
+ return path.resolve("data-test");
91
+ }
92
+ if (fs.existsSync(path.resolve("data"))) {
93
+ return path.resolve("data");
94
+ }
95
+ return path.resolve("data-test");
96
+ }
97
+ // 3. Local repository checkout (development mode)
98
+ const localDir = path.resolve("data");
99
+ const localExists =
100
+ options?.localDataExists ??
101
+ (fs.existsSync(path.join(localDir, "db", "qwenproxy.db")) ||
102
+ fs.existsSync(localDir));
103
+
104
+ if (localExists) {
105
+ return localDir;
106
+ }
107
+
108
+ // 4. Global OS user directory (npm/pnpm/bun/yarn global mode)
109
+ return getOsGlobalDataDir({
110
+ platform: options?.platform,
111
+ appData: options?.appData,
112
+ homeDir: options?.homeDir,
113
+ xdgDataHome: options?.xdgDataHome,
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Returns the active root data directory.
119
+ */
120
+ export function getDataDir(): string {
121
+ return resolveDataDir();
122
+ }
123
+
124
+ /**
125
+ * Directory for SQLite database and encryption key.
126
+ */
127
+ export function getDbDir(customDataDir?: string): string {
128
+ return path.join(customDataDir || getDataDir(), "db");
129
+ }
130
+
131
+ /**
132
+ * Absolute path to the SQLite database file.
133
+ */
134
+ export function getDbPath(customDataDir?: string): string {
135
+ return path.join(getDbDir(customDataDir), "qwenproxy.db");
136
+ }
137
+
138
+ /**
139
+ * Absolute path to the master AES-256 encryption key.
140
+ */
141
+ export function getEncryptionKeyPath(customDataDir?: string): string {
142
+ return path.join(getDbDir(customDataDir), ".encryption_key");
143
+ }
144
+
145
+ /**
146
+ * Directory for persistent Chromium browser profiles.
147
+ */
148
+ export function getProfilesDir(customDataDir?: string): string {
149
+ return path.join(customDataDir || getDataDir(), "qwen_profiles");
150
+ }
151
+
152
+ /**
153
+ * Path to a specific account's Chromium profile directory.
154
+ */
155
+ export function getAccountProfilePath(accountId: string, customDataDir?: string): string {
156
+ return path.join(getProfilesDir(customDataDir), accountId);
157
+ }
158
+
159
+ /**
160
+ * Path to account priority persistence file.
161
+ */
162
+ export function getAccountPriorityPath(customDataDir?: string): string {
163
+ return path.join(customDataDir || getDataDir(), "account-priority.json");
164
+ }
165
+
166
+ /**
167
+ * Path to AI coding clients synchronization state file.
168
+ */
169
+ export function getSyncStatePath(customDataDir?: string): string {
170
+ return path.join(customDataDir || getDataDir(), "sync-state.json");
171
+ }
172
+
173
+ /**
174
+ * Path to user configuration .env file (either local or in global data dir).
175
+ */
176
+ export function getEnvFilePath(customDataDir?: string): string {
177
+ const localEnv = path.resolve(".env");
178
+ if (fs.existsSync(localEnv)) {
179
+ return localEnv;
180
+ }
181
+ return path.join(customDataDir || getDataDir(), ".env");
182
+ }
183
+
184
+ /**
185
+ * Ensures all standard directories exist on disk with proper recursive creation.
186
+ */
187
+ export function ensureDataDirs(targetDataDir?: string): void {
188
+ const root = targetDataDir || getDataDir();
189
+ const dbDir = getDbDir(root);
190
+ const profilesDir = getProfilesDir(root);
191
+
192
+ try {
193
+ if (!fs.existsSync(root)) fs.mkdirSync(root, { recursive: true });
194
+ if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
195
+ if (!fs.existsSync(profilesDir)) fs.mkdirSync(profilesDir, { recursive: true });
196
+ } catch (err: any) {
197
+ console.error(`[Paths] Error ensuring data directories at ${root}:`, err?.message || String(err));
198
+ }
199
+ }
@@ -0,0 +1,214 @@
1
+ import { config } from "./config.ts";
2
+ import { ContextLengthExceededError } from "./errors.ts";
3
+ import {
4
+ getModelCapabilities,
5
+ getModelContextWindow,
6
+ } from "./model-registry.ts";
7
+ import { estimateTokenCount } from "../utils/context-truncation.ts";
8
+
9
+ const INPUT_TOKEN_SAFETY_MARGIN = 4_096;
10
+
11
+ /**
12
+ * Maximum number of recent messages to keep when truncating.
13
+ * Older messages are dropped with a truncation notice.
14
+ */
15
+ const TRUNCATION_KEEP_RECENT_MESSAGES = 20;
16
+
17
+ /**
18
+ * Notice added when history is truncated.
19
+ */
20
+ const TRUNCATION_NOTICE =
21
+ "\n\n[Context truncated: older messages were removed to fit within model limits. Recent messages preserved.]\n\n";
22
+
23
+ export interface PromptLimitStats {
24
+ bytes: number;
25
+ estimatedTokens: number;
26
+ modelContextWindow: number;
27
+ usableInputTokens: number;
28
+ }
29
+
30
+ export interface PromptLimitOptions {
31
+ /** Skip the model-token check until live model metadata has been synced. */
32
+ checkModelContext?: boolean;
33
+ /** Use metadata synchronized from this account, never another account. */
34
+ accountId?: string;
35
+ }
36
+
37
+ export function getUtf8ByteLength(value: string): number {
38
+ return Buffer.byteLength(value, "utf8");
39
+ }
40
+
41
+ export function getPromptLimitStats(
42
+ prompt: string,
43
+ modelId: string,
44
+ accountId?: string,
45
+ ): PromptLimitStats {
46
+ const modelContextWindow = getModelContextWindow(modelId, accountId);
47
+ const capabilities = getModelCapabilities(modelId, accountId);
48
+ const reservedOutputTokens = Math.max(
49
+ INPUT_TOKEN_SAFETY_MARGIN,
50
+ capabilities.maxOutputTokens,
51
+ capabilities.maxThinkingTokens,
52
+ );
53
+
54
+ return {
55
+ bytes: getUtf8ByteLength(prompt),
56
+ estimatedTokens: estimateTokenCount(prompt),
57
+ modelContextWindow,
58
+ usableInputTokens: Math.max(1, modelContextWindow - reservedOutputTokens),
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Reject input that Qwen's web endpoint is unlikely to accept before allocating
64
+ * an upstream chat or retrying it on other accounts.
65
+ */
66
+ export function assertPromptWithinLimits(
67
+ prompt: string,
68
+ modelId: string,
69
+ options: PromptLimitOptions = {},
70
+ ): PromptLimitStats {
71
+ const maxPromptBytes = config.qwen.maxPromptBytes;
72
+ const checkModelContext = options.checkModelContext !== false;
73
+
74
+ // Nothing to enforce: skip the byte scan and the O(n) token estimation
75
+ // entirely (the default QWEN_MAX_PROMPT_BYTES=0 makes the byte check a
76
+ // no-op, and callers pass checkModelContext:false before the live model
77
+ // context window is known).
78
+ if (maxPromptBytes <= 0 && !checkModelContext) {
79
+ return {
80
+ bytes: 0,
81
+ estimatedTokens: 0,
82
+ modelContextWindow: getModelContextWindow(modelId, options.accountId),
83
+ usableInputTokens: 1,
84
+ };
85
+ }
86
+
87
+ const stats = getPromptLimitStats(prompt, modelId, options.accountId);
88
+
89
+ if (maxPromptBytes > 0 && stats.bytes > maxPromptBytes) {
90
+ throw new ContextLengthExceededError(
91
+ `Input is too large for QwenProxy (${stats.bytes} UTF-8 bytes; limit ${maxPromptBytes}). Reduce or summarize the conversation before retrying.`,
92
+ );
93
+ }
94
+
95
+ if (
96
+ options.checkModelContext !== false &&
97
+ stats.estimatedTokens > stats.usableInputTokens
98
+ ) {
99
+ throw new ContextLengthExceededError(
100
+ `Input exceeds the usable context for ${modelId} (${stats.estimatedTokens} estimated tokens; limit ${stats.usableInputTokens}). Reduce or summarize the conversation before retrying.`,
101
+ );
102
+ }
103
+
104
+ return stats;
105
+ }
106
+
107
+ /**
108
+ * Personalization is account-global Qwen state. Keep its settings payload below
109
+ * a separate cap so a large tool schema cannot trigger a WAF challenge there.
110
+ */
111
+ export function isRequestPersonalizationWithinLimit(
112
+ instruction: string,
113
+ ): boolean {
114
+ const maxPersonalizationBytes = config.qwen.maxPersonalizationBytes;
115
+ return (
116
+ maxPersonalizationBytes <= 0 ||
117
+ getUtf8ByteLength(instruction) <= maxPersonalizationBytes
118
+ );
119
+ }
120
+
121
+ export interface TruncationResult {
122
+ prompt: string;
123
+ wasTruncated: boolean;
124
+ originalTokens: number;
125
+ truncatedTokens: number;
126
+ messagesKept: number;
127
+ messagesDropped: number;
128
+ }
129
+
130
+ /**
131
+ * Intelligently truncate a prompt to fit within model context limits.
132
+ *
133
+ * Strategy:
134
+ * 1. Keep system prompt + tools (instructions) intact
135
+ * 2. Keep recent messages (up to TRUNCATION_KEEP_RECENT_MESSAGES)
136
+ * 3. Drop older messages with a truncation notice
137
+ * 4. If still too large, progressively drop more messages
138
+ */
139
+ export function truncatePromptToIntelligentLimit(
140
+ prompt: string,
141
+ modelId: string,
142
+ accountId?: string,
143
+ messages?: Array<{ role: string; content: string | null }>,
144
+ ): TruncationResult {
145
+ const stats = getPromptLimitStats(prompt, modelId, accountId);
146
+ const originalTokens = stats.estimatedTokens;
147
+
148
+ // Within limits, no truncation needed
149
+ if (stats.estimatedTokens <= stats.usableInputTokens) {
150
+ return {
151
+ prompt,
152
+ wasTruncated: false,
153
+ originalTokens,
154
+ truncatedTokens: originalTokens,
155
+ messagesKept: messages?.length ?? 0,
156
+ messagesDropped: 0,
157
+ };
158
+ }
159
+
160
+ // If no messages provided, do simple character-based truncation
161
+ if (!messages || messages.length === 0) {
162
+ const maxChars = Math.floor(stats.usableInputTokens * 4); // ~4 chars per token
163
+ const truncated = prompt.slice(0, maxChars);
164
+ return {
165
+ prompt: truncated + TRUNCATION_NOTICE,
166
+ wasTruncated: true,
167
+ originalTokens,
168
+ truncatedTokens: estimateTokenCount(truncated),
169
+ messagesKept: 0,
170
+ messagesDropped: 0,
171
+ };
172
+ }
173
+
174
+ // Intelligent truncation: keep recent messages, drop older ones
175
+ const totalMessages = messages.length;
176
+ let keepCount = Math.min(TRUNCATION_KEEP_RECENT_MESSAGES, totalMessages);
177
+ let droppedCount = totalMessages - keepCount;
178
+
179
+ // Build truncated prompt from recent messages
180
+ const buildTruncatedPrompt = (keep: number): string => {
181
+ const recentMessages = messages.slice(-keep);
182
+ const messageText = recentMessages
183
+ .map((m) => `${m.role}: ${m.content ?? ""}`)
184
+ .join("\n\n");
185
+ return messageText + TRUNCATION_NOTICE;
186
+ };
187
+
188
+ let truncatedPrompt = buildTruncatedPrompt(keepCount);
189
+ let truncatedTokens = estimateTokenCount(truncatedPrompt);
190
+
191
+ // Progressively drop more messages if still too large
192
+ while (truncatedTokens > stats.usableInputTokens && keepCount > 1) {
193
+ keepCount = Math.max(1, Math.floor(keepCount / 2));
194
+ droppedCount = totalMessages - keepCount;
195
+ truncatedPrompt = buildTruncatedPrompt(keepCount);
196
+ truncatedTokens = estimateTokenCount(truncatedPrompt);
197
+ }
198
+
199
+ // Final fallback: hard character limit
200
+ if (truncatedTokens > stats.usableInputTokens) {
201
+ const maxChars = Math.floor(stats.usableInputTokens * 4);
202
+ truncatedPrompt = truncatedPrompt.slice(0, maxChars) + TRUNCATION_NOTICE;
203
+ truncatedTokens = estimateTokenCount(truncatedPrompt);
204
+ }
205
+
206
+ return {
207
+ prompt: truncatedPrompt,
208
+ wasTruncated: true,
209
+ originalTokens,
210
+ truncatedTokens,
211
+ messagesKept: keepCount,
212
+ messagesDropped: droppedCount,
213
+ };
214
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Normalize OpenAI / Codex / provider reasoning.effort values.
3
+ *
4
+ * Provider-compatible names (common across OpenAI, Codex, Cursor, agents):
5
+ * - Max / high / xhigh → thinking ON (Qwen thinking_mode: "Thinking")
6
+ * - Fast / none / low → thinking OFF (Qwen thinking_mode: "Fast")
7
+ * - medium → thinking ON (default mid-tier agents)
8
+ *
9
+ * Qwen upstream only has a boolean + Thinking|Fast — no true medium gradient.
10
+ */
11
+
12
+ import { stripFastSuffix } from "./model-registry.ts";
13
+
14
+ export type NormalizedEffort = "low" | "medium" | "high";
15
+
16
+ /** Human-facing / provider aliases → normalized. */
17
+ const EFFORT_ALIASES: Record<string, NormalizedEffort> = {
18
+ // Fast path (thinking off)
19
+ none: "low",
20
+ off: "low",
21
+ disable: "low",
22
+ disabled: "low",
23
+ minimal: "low",
24
+ min: "low",
25
+ low: "low",
26
+ fast: "low",
27
+ quick: "low",
28
+ "thinking-off": "low",
29
+ thinking_off: "low",
30
+ "no-thinking": "low",
31
+ no_thinking: "low",
32
+
33
+ // Mid
34
+ medium: "medium",
35
+ med: "medium",
36
+ default: "medium",
37
+
38
+ // Max path (thinking on)
39
+ high: "high",
40
+ xhigh: "high",
41
+ "x-high": "high",
42
+ max: "high",
43
+ maximum: "high",
44
+ ultra: "high",
45
+ deep: "high",
46
+ thinking: "high",
47
+ "thinking-on": "high",
48
+ thinking_on: "high",
49
+ };
50
+
51
+ /**
52
+ * Map any client-provided effort string to low|medium|high.
53
+ * Unknown values fall back to "high" (prefer thinking for agentic clients like Codex).
54
+ */
55
+ export function normalizeReasoningEffort(
56
+ value: unknown,
57
+ ): NormalizedEffort | undefined {
58
+ if (value === null || value === undefined || value === "") return undefined;
59
+
60
+ const key = String(value).trim().toLowerCase();
61
+ if (!key) return undefined;
62
+
63
+ if (EFFORT_ALIASES[key]) return EFFORT_ALIASES[key];
64
+
65
+ // numeric 0-100 style (rare)
66
+ const n = Number(key);
67
+ if (Number.isFinite(n)) {
68
+ if (n <= 33) return "low";
69
+ if (n <= 66) return "medium";
70
+ return "high";
71
+ }
72
+
73
+ console.warn(
74
+ `[Effort] Unknown reasoning effort '${value}' — defaulting to high`,
75
+ );
76
+ return "high";
77
+ }
78
+
79
+ /**
80
+ * Returns the clean, canonical model id, stripping any legacy suffix.
81
+ */
82
+ export function applyEffortToModel(
83
+ model: string,
84
+ _effort: NormalizedEffort | undefined,
85
+ ): string {
86
+ return model.replace(/-(?:low|medium|high|fast|no-thinking|thinking)$/, "");
87
+ }
88
+
89
+ /**
90
+ * Chat-completions mapping: effort -> Qwen reasoning mode.
91
+ * - low → "fast" (thinking OFF / Fast mode)
92
+ * - medium → "auto" (thinking AUTO / Qwen decides dynamically)
93
+ * - high → "thinking" (thinking ON / forced reasoning mode)
94
+ */
95
+ export function effortToReasoningMode(
96
+ effort: NormalizedEffort | undefined,
97
+ ): "fast" | "auto" | "thinking" | undefined {
98
+ if (effort === "low") return "fast";
99
+ if (effort === "medium") return "auto";
100
+ if (effort === "high") return "thinking";
101
+ return undefined;
102
+ }
@@ -0,0 +1,96 @@
1
+ import { metrics } from "./metrics.js";
2
+
3
+ const activeStreams = new Map<
4
+ string,
5
+ {
6
+ abortController: AbortController;
7
+ accountId: string;
8
+ uiSessionId: string;
9
+ targetResponseId: string;
10
+ headers: Record<string, string>;
11
+ /** True once at least one model chunk reached the client. */
12
+ emittedChunk: boolean;
13
+ }
14
+ >();
15
+
16
+ export function registerStream(
17
+ key: string,
18
+ entry: {
19
+ abortController: AbortController;
20
+ accountId: string;
21
+ uiSessionId: string;
22
+ targetResponseId: string;
23
+ headers: Record<string, string>;
24
+ },
25
+ ): void {
26
+ const existing = activeStreams.get(key);
27
+ if (existing && existing.abortController !== entry.abortController) {
28
+ existing.abortController.abort();
29
+ }
30
+
31
+ activeStreams.set(key, { emittedChunk: false, ...entry });
32
+ metrics.gauge("streams.active", activeStreams.size);
33
+ }
34
+
35
+ export function getStream(key: string): ReturnType<typeof activeStreams.get> {
36
+ return activeStreams.get(key);
37
+ }
38
+
39
+ export function getStreamKeysBySessionId(sessionId: string): string[] {
40
+ const keys: string[] = [];
41
+ for (const [key, entry] of activeStreams.entries()) {
42
+ if (entry.uiSessionId === sessionId) {
43
+ keys.push(key);
44
+ }
45
+ }
46
+ return keys;
47
+ }
48
+
49
+ export function getStreamKeyBySessionAndResponse(
50
+ sessionId: string,
51
+ responseId: string,
52
+ ): string | undefined {
53
+ for (const [key, entry] of activeStreams.entries()) {
54
+ if (
55
+ entry.uiSessionId === sessionId &&
56
+ entry.targetResponseId === responseId
57
+ ) {
58
+ return key;
59
+ }
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ export function removeStream(key: string): void {
65
+ activeStreams.delete(key);
66
+ metrics.gauge("streams.active", activeStreams.size);
67
+ }
68
+
69
+ /**
70
+ * Mark a stream as having emitted at least one model chunk to the client.
71
+ * The emit-aware supersede uses this to avoid killing a generation the client
72
+ * has not consumed yet (e.g. a parallel title request racing the main stream).
73
+ */
74
+ export function markStreamEmitted(key: string): void {
75
+ const entry = activeStreams.get(key);
76
+ if (entry) {
77
+ entry.emittedChunk = true;
78
+ }
79
+ }
80
+
81
+ export function updateStreamTargetResponseId(
82
+ key: string,
83
+ targetResponseId: string,
84
+ ): void {
85
+ const entry = activeStreams.get(key);
86
+ if (entry) {
87
+ entry.targetResponseId = targetResponseId;
88
+ }
89
+ }
90
+
91
+ export function updateStreamSessionId(key: string, uiSessionId: string): void {
92
+ const entry = activeStreams.get(key);
93
+ if (entry) {
94
+ entry.uiSessionId = uiSessionId;
95
+ }
96
+ }