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.
- package/LICENSE +14 -0
- package/README.md +907 -0
- package/bin/qwenproxy.js +141 -0
- package/package.json +78 -0
- package/src/api/error-classifier.ts +159 -0
- package/src/api/error-helpers.ts +118 -0
- package/src/api/models.ts +261 -0
- package/src/api/server.ts +859 -0
- package/src/cache/memory-cache.ts +385 -0
- package/src/clean-cache.ts +204 -0
- package/src/core/account-concurrency.ts +671 -0
- package/src/core/account-manager.ts +297 -0
- package/src/core/account-priority.ts +163 -0
- package/src/core/accounts.ts +186 -0
- package/src/core/config.ts +383 -0
- package/src/core/crypto-utils.ts +79 -0
- package/src/core/database.ts +276 -0
- package/src/core/errors.ts +118 -0
- package/src/core/logger.ts +269 -0
- package/src/core/memory-usage.ts +84 -0
- package/src/core/metrics.ts +291 -0
- package/src/core/model-alias.ts +77 -0
- package/src/core/model-registry.ts +544 -0
- package/src/core/mutex.ts +119 -0
- package/src/core/paths.ts +199 -0
- package/src/core/prompt-limits.ts +214 -0
- package/src/core/reasoning-effort.ts +102 -0
- package/src/core/stream-registry.ts +96 -0
- package/src/core/waf-isolation.ts +117 -0
- package/src/core/watchdog.ts +195 -0
- package/src/delete-chats.ts +23 -0
- package/src/index.ts +64 -0
- package/src/login.ts +147 -0
- package/src/reset-cooldowns.ts +11 -0
- package/src/routes/anthropic/index.ts +355 -0
- package/src/routes/anthropic/translate.ts +522 -0
- package/src/routes/anthropic/types.ts +154 -0
- package/src/routes/anthropic/validation.ts +144 -0
- package/src/routes/chat/account.ts +1817 -0
- package/src/routes/chat/context.ts +241 -0
- package/src/routes/chat/errors.ts +85 -0
- package/src/routes/chat/helpers.ts +268 -0
- package/src/routes/chat/index.ts +618 -0
- package/src/routes/chat/media.ts +285 -0
- package/src/routes/chat/retry-policy.ts +754 -0
- package/src/routes/chat/stop.ts +98 -0
- package/src/routes/chat/streaming.ts +2710 -0
- package/src/routes/chat/validation.ts +526 -0
- package/src/routes/chat.ts +2 -0
- package/src/routes/completions.ts +290 -0
- package/src/routes/images.ts +139 -0
- package/src/routes/responses/adapter.ts +503 -0
- package/src/routes/responses/index.ts +405 -0
- package/src/routes/responses/state.ts +230 -0
- package/src/routes/responses/streaming.ts +528 -0
- package/src/routes/responses/types.ts +285 -0
- package/src/routes/responses/validation.ts +202 -0
- package/src/routes/upload.ts +731 -0
- package/src/routes/videos.ts +214 -0
- package/src/services/auth-playwright.ts +173 -0
- package/src/services/captcha-coordinator.ts +161 -0
- package/src/services/captcha-solver.ts +553 -0
- package/src/services/chat-cleanup.ts +80 -0
- package/src/services/context-meter.ts +317 -0
- package/src/services/fingerprint.ts +242 -0
- package/src/services/human-behavior.ts +173 -0
- package/src/services/media-generation.ts +1748 -0
- package/src/services/playwright.ts +2800 -0
- package/src/services/qwen-chat-pool.ts +345 -0
- package/src/services/qwen-errors.ts +133 -0
- package/src/services/qwen-headers.ts +79 -0
- package/src/services/qwen-thread-state.ts +393 -0
- package/src/services/qwen-url.ts +19 -0
- package/src/services/qwen.ts +3126 -0
- package/src/services/session-keeper.ts +88 -0
- package/src/services/token-estimation-metrics.ts +118 -0
- package/src/sync/claude-code.ts +75 -0
- package/src/sync/codex.ts +123 -0
- package/src/sync/index.ts +362 -0
- package/src/sync/omp.ts +105 -0
- package/src/sync/opencode.ts +214 -0
- package/src/sync/types.ts +53 -0
- package/src/sync/utils.ts +27 -0
- package/src/sync-clients.ts +189 -0
- package/src/tools/instructions.ts +137 -0
- package/src/tools/manifest.ts +81 -0
- package/src/tools/parser.ts +2989 -0
- package/src/tools/toolcall-tags.ts +142 -0
- package/src/tools/types.ts +53 -0
- package/src/tui/app.ts +264 -0
- package/src/tui/index.ts +61 -0
- package/src/tui/markdown.ts +258 -0
- package/src/tui/proxy-client.ts +326 -0
- package/src/tui/screen.ts +278 -0
- package/src/tui/server-manager.ts +270 -0
- package/src/tui/theme.ts +432 -0
- package/src/tui/types.ts +33 -0
- package/src/tui/views/accounts-view.ts +656 -0
- package/src/tui/views/chat-view.ts +823 -0
- package/src/tui/views/logs-view.ts +413 -0
- package/src/tui/views/status-view.ts +204 -0
- package/src/tui/views/storage-view.ts +291 -0
- package/src/tui/views/sync-view.ts +409 -0
- package/src/types/ali-oss.d.ts +32 -0
- package/src/utils/context-truncation.ts +84 -0
- package/src/utils/json.ts +380 -0
- package/src/utils/session-id.ts +37 -0
- package/src/utils/tool-call-guard.ts +85 -0
- package/src/utils/types.ts +109 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
declare module "ali-oss" {
|
|
2
|
+
interface OSSOptions {
|
|
3
|
+
region?: string;
|
|
4
|
+
accessKeyId: string;
|
|
5
|
+
accessKeySecret: string;
|
|
6
|
+
stsToken?: string;
|
|
7
|
+
bucket?: string;
|
|
8
|
+
endpoint?: string;
|
|
9
|
+
secure?: boolean;
|
|
10
|
+
refreshSTSToken?: () => Promise<{
|
|
11
|
+
accessKeyId: string;
|
|
12
|
+
accessKeySecret: string;
|
|
13
|
+
stsToken: string;
|
|
14
|
+
}>;
|
|
15
|
+
refreshSTSTokenInterval?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface PutOptions {
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
class OSS {
|
|
23
|
+
constructor(options: OSSOptions);
|
|
24
|
+
put(
|
|
25
|
+
name: string,
|
|
26
|
+
file: Buffer | string,
|
|
27
|
+
options?: PutOptions,
|
|
28
|
+
): Promise<any>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default OSS;
|
|
32
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic token estimation.
|
|
3
|
+
*
|
|
4
|
+
* Accepts one or more string parts so callers can avoid concatenating large
|
|
5
|
+
* strings just to estimate them (the parts are accumulated before a single
|
|
6
|
+
* final Math.ceil, keeping the result identical to the concatenated form).
|
|
7
|
+
*/
|
|
8
|
+
export function estimateTokenCount(...parts: string[]): number {
|
|
9
|
+
let tokens = 0;
|
|
10
|
+
for (const part of parts) {
|
|
11
|
+
if (part) tokens += estimatePart(part);
|
|
12
|
+
}
|
|
13
|
+
return Math.ceil(tokens);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function estimatePart(text: string): number {
|
|
17
|
+
let tokens = 0;
|
|
18
|
+
let i = 0;
|
|
19
|
+
const n = text.length;
|
|
20
|
+
|
|
21
|
+
while (i < n) {
|
|
22
|
+
const c = text.charCodeAt(i);
|
|
23
|
+
|
|
24
|
+
// Fast path: ASCII (the overwhelmingly common case in OpenAI payloads).
|
|
25
|
+
// charCodeAt avoids the per-char string allocation and codePointAt decode.
|
|
26
|
+
if (c < 0x80) {
|
|
27
|
+
if (c >= 0x20 && c <= 0x7e) {
|
|
28
|
+
// Printable ASCII: structural characters weigh more than prose.
|
|
29
|
+
tokens +=
|
|
30
|
+
c === 0x7b || // {
|
|
31
|
+
c === 0x7d || // }
|
|
32
|
+
c === 0x5b || // [
|
|
33
|
+
c === 0x5d || // ]
|
|
34
|
+
c === 0x22 || // "
|
|
35
|
+
c === 0x3a || // :
|
|
36
|
+
c === 0x2c || // ,
|
|
37
|
+
c === 0x3b || // ;
|
|
38
|
+
c === 0x28 || // (
|
|
39
|
+
c === 0x29 || // )
|
|
40
|
+
c === 0x2f || // /
|
|
41
|
+
c === 0x5c // \
|
|
42
|
+
? 0.4
|
|
43
|
+
: 0.25;
|
|
44
|
+
} else if (c === 0x0a || c === 0x0d || c === 0x09) {
|
|
45
|
+
tokens += 0.2; // \n \r \t
|
|
46
|
+
} else {
|
|
47
|
+
tokens += 1.0; // remaining control characters
|
|
48
|
+
}
|
|
49
|
+
i += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Non-ASCII path: code-point based heuristics (CJK/kana/hangul/other).
|
|
54
|
+
const codePoint = text.codePointAt(i) || 0;
|
|
55
|
+
|
|
56
|
+
// CJK Unified Ideographs (U+4E00-U+9FFF)
|
|
57
|
+
if (codePoint >= 0x4e00 && codePoint <= 0x9fff) {
|
|
58
|
+
tokens += 1.5;
|
|
59
|
+
i += 1;
|
|
60
|
+
}
|
|
61
|
+
// CJK Extension A/B (U+3400-U+2A6DF)
|
|
62
|
+
else if (codePoint >= 0x3400 && codePoint <= 0x2a6df) {
|
|
63
|
+
tokens += 1.5;
|
|
64
|
+
i += codePoint > 0xffff ? 2 : 1;
|
|
65
|
+
}
|
|
66
|
+
// Hiragana/Katakana (U+3040-U+30FF)
|
|
67
|
+
else if (codePoint >= 0x3040 && codePoint <= 0x30ff) {
|
|
68
|
+
tokens += 1.2;
|
|
69
|
+
i += 1;
|
|
70
|
+
}
|
|
71
|
+
// Hangul (U+AC00-U+D7AF)
|
|
72
|
+
else if (codePoint >= 0xac00 && codePoint <= 0xd7af) {
|
|
73
|
+
tokens += 1.3;
|
|
74
|
+
i += 1;
|
|
75
|
+
}
|
|
76
|
+
// Other Unicode (emoji, symbols, etc.)
|
|
77
|
+
else {
|
|
78
|
+
tokens += 1.0;
|
|
79
|
+
i += codePoint > 0xffff ? 2 : 1;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return tokens;
|
|
84
|
+
}
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { logger } from "../core/logger.js";
|
|
2
|
+
|
|
3
|
+
const isDebug = process.env.TOOLCALL_DEBUG === "1";
|
|
4
|
+
|
|
5
|
+
function sanitizeAndBalance(input: string): {
|
|
6
|
+
result: string;
|
|
7
|
+
openBraces: number;
|
|
8
|
+
openBrackets: number;
|
|
9
|
+
recoveredUnclosedString: boolean;
|
|
10
|
+
/** Opening tokens `{`/`[` in the order they were opened (LIFO close order). */
|
|
11
|
+
openStack: string[];
|
|
12
|
+
} {
|
|
13
|
+
let out = "";
|
|
14
|
+
let openBraces = 0;
|
|
15
|
+
let openBrackets = 0;
|
|
16
|
+
let openStack: string[] = [];
|
|
17
|
+
let inString = false;
|
|
18
|
+
let escaped = false;
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < input.length; i++) {
|
|
21
|
+
const char = input[i];
|
|
22
|
+
if (escaped) {
|
|
23
|
+
const validEscapes = ["n", "r", "t", "u", '"', "\\", "/"];
|
|
24
|
+
if (validEscapes.includes(char)) {
|
|
25
|
+
if (char === "u") {
|
|
26
|
+
const next4 = input.substring(i + 1, i + 5);
|
|
27
|
+
out += /^[0-9a-fA-F]{4}$/.test(next4) ? "\\" + char : "\\\\" + char;
|
|
28
|
+
} else if (["n", "r", "t"].includes(char)) {
|
|
29
|
+
// Local check: only double-escape if the 2 chars before this
|
|
30
|
+
// backslash form a Windows drive letter + colon (e.g. C:\, D:/).
|
|
31
|
+
// Scanning the whole input causes false positives with URLs
|
|
32
|
+
// like https:// or paths embedded elsewhere in the string.
|
|
33
|
+
const isWinPath = i >= 2 && /[a-zA-Z]:/.test(input.substring(i - 2, i));
|
|
34
|
+
const nextChar = input[i + 1] || "";
|
|
35
|
+
out +=
|
|
36
|
+
isWinPath && /^[a-zA-Z0-9]/.test(nextChar)
|
|
37
|
+
? "\\\\" + char
|
|
38
|
+
: "\\" + char;
|
|
39
|
+
} else {
|
|
40
|
+
out += "\\" + char;
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
out += "\\\\" + char;
|
|
44
|
+
}
|
|
45
|
+
escaped = false;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (char === "\\") {
|
|
49
|
+
escaped = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (char === '"') {
|
|
53
|
+
inString = !inString;
|
|
54
|
+
out += char;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (inString) {
|
|
58
|
+
if (char === "\n") out += "\\n";
|
|
59
|
+
else if (char === "\r") out += "\\r";
|
|
60
|
+
else if (char === "\t") out += "\\t";
|
|
61
|
+
else if (char.charCodeAt(0) < 32)
|
|
62
|
+
out += "\\u" + char.charCodeAt(0).toString(16).padStart(4, "0");
|
|
63
|
+
else out += char;
|
|
64
|
+
} else {
|
|
65
|
+
out += char;
|
|
66
|
+
if (char === "{") {
|
|
67
|
+
openBraces++;
|
|
68
|
+
openStack.push("{");
|
|
69
|
+
}
|
|
70
|
+
if (char === "}") {
|
|
71
|
+
openBraces--;
|
|
72
|
+
openStack.pop();
|
|
73
|
+
}
|
|
74
|
+
if (char === "[") {
|
|
75
|
+
openBrackets++;
|
|
76
|
+
openStack.push("[");
|
|
77
|
+
}
|
|
78
|
+
if (char === "]") {
|
|
79
|
+
openBrackets--;
|
|
80
|
+
openStack.pop();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let recoveredUnclosedString = false;
|
|
86
|
+
if (escaped) {
|
|
87
|
+
out += "\\";
|
|
88
|
+
}
|
|
89
|
+
if (inString) {
|
|
90
|
+
out += '"';
|
|
91
|
+
recoveredUnclosedString = true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { result: out, openBraces, openBrackets, recoveredUnclosedString, openStack };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Close unterminated containers in LIFO order (reverse of how they were
|
|
99
|
+
* opened). Appending all `]` then all `}` (the old counting-only approach)
|
|
100
|
+
* produces invalid JSON whenever an array is opened and then an object is
|
|
101
|
+
* opened inside it before truncation, e.g.
|
|
102
|
+
* `{"a":[{"old_text":"start` needs `}],}}` and not `]}}}`.
|
|
103
|
+
*/
|
|
104
|
+
function closeBraces(
|
|
105
|
+
input: string,
|
|
106
|
+
openBraces: number,
|
|
107
|
+
openBrackets: number,
|
|
108
|
+
openStack: string[],
|
|
109
|
+
): string {
|
|
110
|
+
let out = input;
|
|
111
|
+
for (let i = openStack.length - 1; i >= 0; i--) {
|
|
112
|
+
out += openStack[i] === "{" ? "}" : "]";
|
|
113
|
+
}
|
|
114
|
+
if (openBraces > 0 || openBrackets > 0) {
|
|
115
|
+
// Open tokens shorter than the counted opens (no stack entries for them)
|
|
116
|
+
// cannot happen since the stack mirrors the counts; keep the historical
|
|
117
|
+
// count-based close as a safety net for callers without a stack.
|
|
118
|
+
if (openStack.length === 0) {
|
|
119
|
+
if (openBrackets > 0) out += "]".repeat(openBrackets);
|
|
120
|
+
if (openBraces > 0) out += "}".repeat(openBraces);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Fixes missing opening quotes in JSON values.
|
|
128
|
+
* Handles cases like: {"key": value_without_quotes"}
|
|
129
|
+
* Upstream: a63f054, 9328bde
|
|
130
|
+
*/
|
|
131
|
+
function fixMissingOpeningQuotes(input: string): string {
|
|
132
|
+
let out = input;
|
|
133
|
+
let prev: string;
|
|
134
|
+
do {
|
|
135
|
+
prev = out;
|
|
136
|
+
// Pattern 1: {"key": value"} or {key: value"}
|
|
137
|
+
out = out.replace(
|
|
138
|
+
/([{,[]\s*"[a-zA-Z_][\w]*"\s*:\s*)([^"\s,}\]][^"\n]*?)"([\s,}\]])/g,
|
|
139
|
+
'$1"$2"$3',
|
|
140
|
+
);
|
|
141
|
+
out = out.replace(
|
|
142
|
+
/([{,[]\s*[a-zA-Z_][\w]*\s*:\s*)([^"\s,}\]][^"\n]*?)"([\s,}\]])/g,
|
|
143
|
+
'$1"$2"$3',
|
|
144
|
+
);
|
|
145
|
+
// Pattern 2: {: value"} - upstream 9328bde
|
|
146
|
+
out = out.replace(/(:\s*)([A-Za-z_][\w.-]*?)"([\s,}\]])/g, '$1"$2"$3');
|
|
147
|
+
} while (out !== prev);
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function robustParseJSON(str: string): any {
|
|
152
|
+
if (isDebug) {
|
|
153
|
+
logger.debug("[json] robustParseJSON: starting", {
|
|
154
|
+
inputLength: str.length,
|
|
155
|
+
inputPreview: str.substring(0, 200),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let sanitized = str.trim();
|
|
160
|
+
sanitized = sanitized
|
|
161
|
+
.replace(/^```json\s*/, "")
|
|
162
|
+
.replace(/```$/, "")
|
|
163
|
+
.trim();
|
|
164
|
+
|
|
165
|
+
const firstBrace = sanitized.indexOf("{");
|
|
166
|
+
if (firstBrace === -1) {
|
|
167
|
+
if (isDebug) {
|
|
168
|
+
logger.debug("[json] robustParseJSON: no opening brace found");
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let jsonPart = sanitized.substring(firstBrace);
|
|
174
|
+
try {
|
|
175
|
+
const result = JSON.parse(jsonPart);
|
|
176
|
+
if (isDebug) {
|
|
177
|
+
logger.debug("[json] robustParseJSON: direct parse succeeded", {
|
|
178
|
+
resultType: typeof result,
|
|
179
|
+
resultPreview: JSON.stringify(result).substring(0, 200),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
183
|
+
} catch (e) {
|
|
184
|
+
if (isDebug) {
|
|
185
|
+
logger.debug("[json] robustParseJSON: direct parse failed", {
|
|
186
|
+
error: e instanceof Error ? e.message : String(e),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// Fix double-escaped quotes (e.g., \\" -> ") only after direct parse
|
|
190
|
+
// fails, to avoid corrupting valid JSON with legitimate \\ sequences.
|
|
191
|
+
jsonPart = jsonPart.replace(/\\\\"/g, '\\"');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let currentJson = jsonPart.replace(
|
|
195
|
+
/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*:)/g,
|
|
196
|
+
'$1"$2"$3',
|
|
197
|
+
);
|
|
198
|
+
currentJson = currentJson.replace(
|
|
199
|
+
/([{,]\s*)"([a-zA-Z0-9_]+)"\s*:\s*"\2"\s*:/g,
|
|
200
|
+
'$1"$2":',
|
|
201
|
+
);
|
|
202
|
+
currentJson = currentJson.replace(
|
|
203
|
+
/([{,]\s*)([a-zA-Z0-9_]+)\s*:\s*\2\s*:/g,
|
|
204
|
+
"$1$2:",
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// Fix missing opening quotes in values (upstream: a63f054, 9328bde)
|
|
208
|
+
currentJson = fixMissingOpeningQuotes(currentJson);
|
|
209
|
+
|
|
210
|
+
// Handle unquoted string values after colon (e.g., "command":export CI=true)
|
|
211
|
+
// Matches: "key":value_without_quotes until comma, closing brace, or end
|
|
212
|
+
currentJson = currentJson.replace(
|
|
213
|
+
/"([a-zA-Z0-9_]+)":\s*([^"\s{\[\],}][^,}\]]*)/g,
|
|
214
|
+
(match, key, value) => {
|
|
215
|
+
// Don't quote if it's a number, boolean, or null
|
|
216
|
+
if (/^(true|false|null|\d+(\.\d+)?)$/.test(value.trim())) {
|
|
217
|
+
return match;
|
|
218
|
+
}
|
|
219
|
+
return `"${key}":"${value.trim()}"`;
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const result = JSON.parse(currentJson);
|
|
225
|
+
if (isDebug) {
|
|
226
|
+
logger.debug("[json] robustParseJSON: quote-fix parse succeeded", {
|
|
227
|
+
resultType: typeof result,
|
|
228
|
+
resultPreview: JSON.stringify(result).substring(0, 200),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return result;
|
|
232
|
+
} catch (e) {
|
|
233
|
+
if (isDebug) {
|
|
234
|
+
logger.debug("[json] robustParseJSON: quote-fix parse failed", {
|
|
235
|
+
error: e instanceof Error ? e.message : String(e),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
/* continue */
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let cleaned = currentJson.trim();
|
|
242
|
+
while (
|
|
243
|
+
cleaned.length > 0 &&
|
|
244
|
+
!/[}\]"0-9a-z]/i.test(cleaned[cleaned.length - 1])
|
|
245
|
+
) {
|
|
246
|
+
cleaned = cleaned.slice(0, -1).trim();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const {
|
|
250
|
+
result: fixedJson,
|
|
251
|
+
openBraces,
|
|
252
|
+
openBrackets,
|
|
253
|
+
recoveredUnclosedString,
|
|
254
|
+
openStack: fixedJsonOpenStack,
|
|
255
|
+
} = sanitizeAndBalance(cleaned);
|
|
256
|
+
|
|
257
|
+
if (isDebug && recoveredUnclosedString) {
|
|
258
|
+
logger.debug(
|
|
259
|
+
"[json] robustParseJSON: recovered unclosed string at end of input",
|
|
260
|
+
{
|
|
261
|
+
originalPreview: cleaned.substring(0, 150),
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let lastBalancedIndex = -1;
|
|
267
|
+
|
|
268
|
+
{
|
|
269
|
+
let ob = 0,
|
|
270
|
+
bk = 0,
|
|
271
|
+
ins = false,
|
|
272
|
+
esc = false;
|
|
273
|
+
for (let i = 0; i < fixedJson.length; i++) {
|
|
274
|
+
const c = fixedJson[i];
|
|
275
|
+
if (esc) {
|
|
276
|
+
esc = false;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (c === "\\") {
|
|
280
|
+
esc = true;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (c === '"') {
|
|
284
|
+
ins = !ins;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (!ins) {
|
|
288
|
+
if (c === "{") ob++;
|
|
289
|
+
if (c === "}") ob--;
|
|
290
|
+
if (c === "[") bk++;
|
|
291
|
+
if (c === "]") bk--;
|
|
292
|
+
if (ob === 0 && bk === 0) lastBalancedIndex = i;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let tempJson = fixedJson;
|
|
298
|
+
if (
|
|
299
|
+
lastBalancedIndex !== -1 &&
|
|
300
|
+
(openBraces !== 0 ||
|
|
301
|
+
openBrackets !== 0 ||
|
|
302
|
+
fixedJson.length > lastBalancedIndex + 1)
|
|
303
|
+
) {
|
|
304
|
+
tempJson = fixedJson.substring(0, lastBalancedIndex + 1);
|
|
305
|
+
if (isDebug) {
|
|
306
|
+
logger.debug("[json] robustParseJSON: truncated to balanced index", {
|
|
307
|
+
lastBalancedIndex,
|
|
308
|
+
originalLength: fixedJson.length,
|
|
309
|
+
truncatedLength: tempJson.length,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
} else if (openBraces > 0 || openBrackets > 0) {
|
|
313
|
+
tempJson = closeBraces(
|
|
314
|
+
fixedJson,
|
|
315
|
+
openBraces,
|
|
316
|
+
openBrackets,
|
|
317
|
+
fixedJsonOpenStack,
|
|
318
|
+
);
|
|
319
|
+
if (isDebug) {
|
|
320
|
+
logger.debug("[json] robustParseJSON: closed braces", {
|
|
321
|
+
openBraces,
|
|
322
|
+
openBrackets,
|
|
323
|
+
resultLength: tempJson.length,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const result = JSON.parse(tempJson);
|
|
330
|
+
if (isDebug) {
|
|
331
|
+
logger.debug("[json] robustParseJSON: balanced-parse succeeded", {
|
|
332
|
+
resultType: typeof result,
|
|
333
|
+
resultPreview: JSON.stringify(result).substring(0, 200),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
return result;
|
|
337
|
+
} catch (e) {
|
|
338
|
+
if (isDebug) {
|
|
339
|
+
logger.debug("[json] robustParseJSON: balanced-parse failed", {
|
|
340
|
+
error: e instanceof Error ? e.message : String(e),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
let aggressive = fixedJson.trim();
|
|
344
|
+
if (aggressive.endsWith(",")) aggressive = aggressive.slice(0, -1);
|
|
345
|
+
const {
|
|
346
|
+
result: aggFixed,
|
|
347
|
+
openBraces: ob,
|
|
348
|
+
openBrackets: bk,
|
|
349
|
+
recoveredUnclosedString: aggRecovered,
|
|
350
|
+
openStack: aggOpenStack,
|
|
351
|
+
} = sanitizeAndBalance(aggressive);
|
|
352
|
+
|
|
353
|
+
if (isDebug && aggRecovered) {
|
|
354
|
+
logger.debug(
|
|
355
|
+
"[json] robustParseJSON: aggressive recovery of unclosed string",
|
|
356
|
+
{
|
|
357
|
+
originalPreview: aggressive.substring(0, 150),
|
|
358
|
+
},
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
const result = JSON.parse(closeBraces(aggFixed, ob, bk, aggOpenStack));
|
|
364
|
+
if (isDebug) {
|
|
365
|
+
logger.debug("[json] robustParseJSON: aggressive-parse succeeded", {
|
|
366
|
+
resultType: typeof result,
|
|
367
|
+
resultPreview: JSON.stringify(result).substring(0, 200),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
return result;
|
|
371
|
+
} catch {
|
|
372
|
+
if (isDebug) {
|
|
373
|
+
logger.debug("[json] robustParseJSON: all parse attempts failed", {
|
|
374
|
+
originalError: e instanceof Error ? e.message : String(e),
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
throw e;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import type { Message } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Derive a deterministic session ID from a caller-provided conversation key
|
|
6
|
+
* plus the conversation's anchoring content.
|
|
7
|
+
*/
|
|
8
|
+
export function deriveSessionId(
|
|
9
|
+
messages: Message[],
|
|
10
|
+
systemPrompt: string = "",
|
|
11
|
+
conversationKey?: string,
|
|
12
|
+
): string {
|
|
13
|
+
const firstUser = messages.find((m) => m.role === "user");
|
|
14
|
+
const anchor = [
|
|
15
|
+
conversationKey?.trim(),
|
|
16
|
+
systemPrompt.trim(),
|
|
17
|
+
extractTextContent(firstUser),
|
|
18
|
+
]
|
|
19
|
+
.filter((part) => part && part.length > 0)
|
|
20
|
+
.join("|");
|
|
21
|
+
const hash = createHash("sha256").update(anchor).digest("hex").slice(0, 16);
|
|
22
|
+
return `sess_${hash}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function extractTextContent(msg: Message | undefined): string {
|
|
26
|
+
if (!msg || !msg.content) return "";
|
|
27
|
+
if (typeof msg.content === "string") return msg.content;
|
|
28
|
+
|
|
29
|
+
const content = msg.content as any;
|
|
30
|
+
if (Array.isArray(content)) {
|
|
31
|
+
return content
|
|
32
|
+
.filter((p: any) => p?.type === "text")
|
|
33
|
+
.map((p: any) => p.text || "")
|
|
34
|
+
.join(" ");
|
|
35
|
+
}
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Message } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detects tool calls that were already executed (identical name + arguments)
|
|
5
|
+
* earlier in the conversation and returns a short system reminder to be
|
|
6
|
+
* appended to the prompt. This breaks the re-read/re-inspect loops where the
|
|
7
|
+
* model keeps calling the same tool turn after turn instead of answering.
|
|
8
|
+
*
|
|
9
|
+
* The reminder is prompt-only and does not alter caching, session identity or
|
|
10
|
+
* the upstream thread.
|
|
11
|
+
*
|
|
12
|
+
* @param messages - Full message history (assistant tool_calls + tool/function results).
|
|
13
|
+
* @param threshold - Minimum number of identical executions before reminding.
|
|
14
|
+
* @returns Reminder text to append to the prompt, or null when the history is clean.
|
|
15
|
+
*/
|
|
16
|
+
export function buildRepeatedToolCallReminder(
|
|
17
|
+
messages: Message[] | null | undefined,
|
|
18
|
+
threshold = 2,
|
|
19
|
+
): string | null {
|
|
20
|
+
if (!messages || messages.length === 0 || threshold < 2) return null;
|
|
21
|
+
|
|
22
|
+
// Canonical key: tool name + normalized arguments (object key order ignored).
|
|
23
|
+
const occurrences = new Map<string, number>();
|
|
24
|
+
let lastToolCallTurnIndex = -1;
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < messages.length; i++) {
|
|
27
|
+
const calls = messages[i]?.tool_calls;
|
|
28
|
+
if (!calls || calls.length === 0) continue;
|
|
29
|
+
lastToolCallTurnIndex = i;
|
|
30
|
+
for (const call of calls) {
|
|
31
|
+
const key = toolCallKey(call.function?.name, call.function?.arguments);
|
|
32
|
+
occurrences.set(key, (occurrences.get(key) ?? 0) + 1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (lastToolCallTurnIndex < 0) return null;
|
|
37
|
+
|
|
38
|
+
const lastCalls = messages[lastToolCallTurnIndex].tool_calls ?? [];
|
|
39
|
+
const reminders: string[] = [];
|
|
40
|
+
|
|
41
|
+
for (const call of lastCalls) {
|
|
42
|
+
const key = toolCallKey(call.function?.name, call.function?.arguments);
|
|
43
|
+
const count = occurrences.get(key) ?? 0;
|
|
44
|
+
if (count >= threshold) {
|
|
45
|
+
reminders.push(
|
|
46
|
+
`[SYSTEM REMINDER] You have already executed the tool call "${call.function?.name}" ${count} time(s) in this conversation with exactly the same arguments, and its result is already in the history. Do NOT call it again: use the existing result and write the final answer now. If the earlier attempts were rejected, do not repeat them either; answer directly.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return reminders.length > 0
|
|
52
|
+
? reminders.join("\n\n")
|
|
53
|
+
: null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function toolCallKey(name: string | undefined, rawArgs: string | undefined): string {
|
|
57
|
+
const toolName = typeof name === "string" ? name : "";
|
|
58
|
+
const raw = typeof rawArgs === "string" ? rawArgs : "";
|
|
59
|
+
let parsed: unknown = raw;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(raw);
|
|
62
|
+
} catch {
|
|
63
|
+
// Unparseable payloads fall back to the raw string as the key.
|
|
64
|
+
}
|
|
65
|
+
return `${toolName}|${JSON.stringify(canonicalize(parsed))}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Deep-normalizes JSON values so that argument objects with different key
|
|
70
|
+
* orders or whitespace still produce the same canonical key.
|
|
71
|
+
*/
|
|
72
|
+
function canonicalize(value: unknown): unknown {
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
return value.map(canonicalize);
|
|
75
|
+
}
|
|
76
|
+
if (value && typeof value === "object") {
|
|
77
|
+
const record = value as Record<string, unknown>;
|
|
78
|
+
const result: Record<string, unknown> = {};
|
|
79
|
+
for (const key of Object.keys(record).sort()) {
|
|
80
|
+
result[key] = canonicalize(record[key]);
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|