qwenproxy-cli 1.0.22 → 1.0.24
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/package.json +1 -1
- package/src/core/config.ts +1 -1
- package/src/routes/chat/validation.ts +5 -0
- package/src/services/chat-cleanup.ts +34 -11
- package/src/services/playwright.ts +24 -5
- package/src/services/qwen.ts +21 -4
- package/src/tools/instructions.ts +7 -1
- package/src/tools/parser.ts +89 -2
- package/src/utils/json.ts +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qwenproxy-cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.24",
|
|
4
4
|
"description": "High-performance OpenAI & Anthropic compatible API gateway for Qwen with multi-account rotation, interactive TUI, and resilient tool calling.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"bin": {
|
package/src/core/config.ts
CHANGED
|
@@ -101,7 +101,7 @@ const envSchema = z
|
|
|
101
101
|
RETRY_ON_UNKNOWN_UPSTREAM: z.string().default("true"),
|
|
102
102
|
RETRY_AUTO_MALFORMED_TOOLS: z.string().default("true"),
|
|
103
103
|
RETRY_AUTO_MALFORMED_TOOLS_MAX: z.string().default("2"),
|
|
104
|
-
MAX_TOOL_CALLS_PER_TURN: z.string().default("
|
|
104
|
+
MAX_TOOL_CALLS_PER_TURN: z.string().default("6"),
|
|
105
105
|
QWEN_REPEATED_TOOL_CALL_WARN: z.string().default("2"),
|
|
106
106
|
ACCOUNT_MAX_CONCURRENT_STREAMS: z.string().default("2"),
|
|
107
107
|
ACCOUNT_BUSY_WAIT_MS: z.string().default("30000"),
|
|
@@ -492,6 +492,11 @@ function injectToolInstructions(body: OpenAIRequest): string {
|
|
|
492
492
|
|
|
493
493
|
if (!shouldParseToolCalls) return "";
|
|
494
494
|
|
|
495
|
+
// If tool_choice is explicitly "none", suppress tool instructions so the model
|
|
496
|
+
// generates a regular conversational message per OpenAI / Anthropic spec.
|
|
497
|
+
if (bodyAny.tool_choice === "none" || bodyAny.tool_choice?.type === "none") {
|
|
498
|
+
return "";
|
|
499
|
+
}
|
|
495
500
|
if (isToolcallDebugEnabled()) {
|
|
496
501
|
logger.debug("[chat] tools provided in request", {
|
|
497
502
|
toolsCount: declaredTools.length,
|
|
@@ -10,32 +10,52 @@ import {
|
|
|
10
10
|
closeAllPlaywright,
|
|
11
11
|
} from "./playwright.ts";
|
|
12
12
|
import { isAuthMockEnabled } from "./auth-playwright.ts";
|
|
13
|
+
import { maskEmail } from "../core/logger.ts";
|
|
13
14
|
export interface DeleteChatsResult {
|
|
14
15
|
attempted: number;
|
|
15
16
|
succeeded: number;
|
|
16
17
|
mode: "accounts";
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
async function ensurePlaywrightSession(
|
|
20
|
+
async function ensurePlaywrightSession(
|
|
21
|
+
account: QwenAccount,
|
|
22
|
+
index = 1,
|
|
23
|
+
total = 1,
|
|
24
|
+
): Promise<void> {
|
|
20
25
|
if (isPlaywrightInitialized(account.id) || isAuthMockEnabled()) return;
|
|
21
26
|
|
|
22
27
|
const credentials = getAccountCredentials(account.id);
|
|
23
28
|
if (!credentials) {
|
|
24
|
-
throw new Error(`
|
|
29
|
+
throw new Error(`Credenciais da conta ${account.id} não encontradas.`);
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
console.log(
|
|
28
|
-
`[DeleteChats]
|
|
33
|
+
`[DeleteChats] [${index}/${total}] Abrindo navegador para ${maskEmail(account.email)}...`,
|
|
29
34
|
);
|
|
30
|
-
await initPlaywrightForAccount(credentials
|
|
35
|
+
await initPlaywrightForAccount(credentials, true, "chromium", {
|
|
36
|
+
skipHeaderCapture: true,
|
|
37
|
+
});
|
|
31
38
|
console.log(
|
|
32
|
-
`✅ [DeleteChats]
|
|
39
|
+
`✅ [DeleteChats] [${index}/${total}] Sessão pronta para ${maskEmail(account.email)}.`,
|
|
33
40
|
);
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
export async function deleteChatsForAccount(
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
export async function deleteChatsForAccount(
|
|
44
|
+
account: QwenAccount,
|
|
45
|
+
index = 1,
|
|
46
|
+
total = 1,
|
|
47
|
+
): Promise<boolean> {
|
|
48
|
+
await ensurePlaywrightSession(account, index, total);
|
|
49
|
+
console.log(
|
|
50
|
+
`🗑️ [DeleteChats] [${index}/${total}] Apagando conversas remotas de ${maskEmail(account.email)}...`,
|
|
51
|
+
);
|
|
52
|
+
const ok = await deleteAllQwenChats(account.id);
|
|
53
|
+
if (ok) {
|
|
54
|
+
console.log(
|
|
55
|
+
`✅ [DeleteChats] [${index}/${total}] Conversas apagadas com sucesso para ${maskEmail(account.email)}.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return ok;
|
|
39
59
|
}
|
|
40
60
|
|
|
41
61
|
export async function deleteChatsForAccountId(accountId: string): Promise<boolean> {
|
|
@@ -65,13 +85,16 @@ export async function deleteChatsForConfiguredAccounts(keepBrowserOpen = false):
|
|
|
65
85
|
let succeeded = 0;
|
|
66
86
|
|
|
67
87
|
try {
|
|
68
|
-
for (
|
|
88
|
+
for (let i = 0; i < accounts.length; i++) {
|
|
89
|
+
const account = accounts[i];
|
|
90
|
+
const currentIdx = i + 1;
|
|
91
|
+
const totalCount = accounts.length;
|
|
69
92
|
try {
|
|
70
|
-
const ok = await deleteChatsForAccount(account);
|
|
93
|
+
const ok = await deleteChatsForAccount(account, currentIdx, totalCount);
|
|
71
94
|
if (ok) succeeded++;
|
|
72
95
|
} catch (error) {
|
|
73
96
|
console.error(
|
|
74
|
-
|
|
97
|
+
`❌ [DeleteChats] [${currentIdx}/${totalCount}] Falha ao apagar conversas de ${maskEmail(account.email)}:`,
|
|
75
98
|
error instanceof Error ? error.message : String(error),
|
|
76
99
|
);
|
|
77
100
|
}
|
|
@@ -1290,6 +1290,7 @@ export async function initPlaywrightForAccount(
|
|
|
1290
1290
|
rawAccount: QwenAccount,
|
|
1291
1291
|
headless = true,
|
|
1292
1292
|
browserType: BrowserType = "chromium",
|
|
1293
|
+
options: { skipHeaderCapture?: boolean } = {},
|
|
1293
1294
|
): Promise<void> {
|
|
1294
1295
|
const account = await resolveAccountCredentials(rawAccount);
|
|
1295
1296
|
if (accountPages.has(account.id)) {
|
|
@@ -1346,11 +1347,19 @@ export async function initPlaywrightForAccount(
|
|
|
1346
1347
|
|
|
1347
1348
|
let acctContext: BrowserContext;
|
|
1348
1349
|
try {
|
|
1349
|
-
acctContext = await
|
|
1350
|
+
acctContext = await withTimeout(
|
|
1351
|
+
engine.launchPersistentContext(profilePath, launchOptions),
|
|
1352
|
+
30_000,
|
|
1353
|
+
`O navegador não iniciou em 30s para a conta ${maskEmail(account.email)}. Se o QwenProxy estiver rodando em outro terminal, feche-o antes de executar este comando.`,
|
|
1354
|
+
);
|
|
1350
1355
|
} catch (launchErr: any) {
|
|
1351
1356
|
if (launchErr?.message?.includes("Executable doesn't exist")) {
|
|
1352
1357
|
autoInstallPlaywrightChromium();
|
|
1353
|
-
acctContext = await
|
|
1358
|
+
acctContext = await withTimeout(
|
|
1359
|
+
engine.launchPersistentContext(profilePath, launchOptions),
|
|
1360
|
+
30_000,
|
|
1361
|
+
`O navegador não iniciou em 30s para a conta ${maskEmail(account.email)}.`,
|
|
1362
|
+
);
|
|
1354
1363
|
} else {
|
|
1355
1364
|
throw launchErr;
|
|
1356
1365
|
}
|
|
@@ -1472,7 +1481,9 @@ export async function initPlaywrightForAccount(
|
|
|
1472
1481
|
);
|
|
1473
1482
|
throw validationError;
|
|
1474
1483
|
}
|
|
1475
|
-
|
|
1484
|
+
if (!options.skipHeaderCapture) {
|
|
1485
|
+
await captureQwenHeaders(account.id);
|
|
1486
|
+
}
|
|
1476
1487
|
|
|
1477
1488
|
// Header capture may leave the UI on a generated chat page. Return the
|
|
1478
1489
|
// primary tab to the canonical chat home.
|
|
@@ -1559,11 +1570,19 @@ export async function validateAccountLogin(
|
|
|
1559
1570
|
|
|
1560
1571
|
let acctContext: BrowserContext;
|
|
1561
1572
|
try {
|
|
1562
|
-
acctContext = await
|
|
1573
|
+
acctContext = await withTimeout(
|
|
1574
|
+
engine.launchPersistentContext(profilePath, launchOptions),
|
|
1575
|
+
30_000,
|
|
1576
|
+
`O navegador não iniciou em 30s para a conta ${maskEmail(account.email)}. Se o QwenProxy estiver rodando em outro terminal, feche-o antes de executar este comando.`,
|
|
1577
|
+
);
|
|
1563
1578
|
} catch (launchErr: any) {
|
|
1564
1579
|
if (launchErr?.message?.includes("Executable doesn't exist")) {
|
|
1565
1580
|
autoInstallPlaywrightChromium();
|
|
1566
|
-
acctContext = await
|
|
1581
|
+
acctContext = await withTimeout(
|
|
1582
|
+
engine.launchPersistentContext(profilePath, launchOptions),
|
|
1583
|
+
30_000,
|
|
1584
|
+
`O navegador não iniciou em 30s para a conta ${maskEmail(account.email)}.`,
|
|
1585
|
+
);
|
|
1567
1586
|
} else {
|
|
1568
1587
|
throw launchErr;
|
|
1569
1588
|
}
|
package/src/services/qwen.ts
CHANGED
|
@@ -1916,14 +1916,31 @@ function formatPublicQwenModel(model: Record<string, unknown>): PublicQwenModel
|
|
|
1916
1916
|
}
|
|
1917
1917
|
|
|
1918
1918
|
export async function deleteAllQwenChats(accountId?: string): Promise<boolean> {
|
|
1919
|
-
|
|
1919
|
+
let requestHeaders: Record<string, string>;
|
|
1920
|
+
if (isAuthMockEnabled()) {
|
|
1921
|
+
const { headers } = await getQwenHeaders(false, accountId);
|
|
1922
|
+
requestHeaders = buildCapturedQwenHeaders(headers, {
|
|
1923
|
+
referer: qwenUrl("/settings/chats"),
|
|
1924
|
+
});
|
|
1925
|
+
} else {
|
|
1926
|
+
// In live mode, requestQwenTextInBrowser executes inside the authenticated
|
|
1927
|
+
// browser page where session cookies are attached automatically.
|
|
1928
|
+
// Bypassing getQwenHeaders avoids triggering captureQwenHeaders (which sends
|
|
1929
|
+
// a dummy chat completion to intercept anti-fraud tokens not needed for deletions).
|
|
1930
|
+
requestHeaders = {
|
|
1931
|
+
source: "web",
|
|
1932
|
+
version: "0.2.89",
|
|
1933
|
+
timezone: new Date().toString().split(" (")[0],
|
|
1934
|
+
"x-request-id": crypto.randomUUID(),
|
|
1935
|
+
Referer: qwenUrl("/settings/chats"),
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1920
1939
|
const response = await requestQwenTextInBrowser(
|
|
1921
1940
|
accountId,
|
|
1922
1941
|
"DELETE",
|
|
1923
1942
|
"/api/v2/chats/",
|
|
1924
|
-
|
|
1925
|
-
referer: qwenUrl("/settings/chats"),
|
|
1926
|
-
}),
|
|
1943
|
+
requestHeaders,
|
|
1927
1944
|
undefined,
|
|
1928
1945
|
{ referrer: qwenUrl("/settings/chats") },
|
|
1929
1946
|
);
|
|
@@ -95,6 +95,12 @@ export function buildToolInstructions(
|
|
|
95
95
|
(toolChoice as any).function?.name
|
|
96
96
|
) {
|
|
97
97
|
forcedInstruction = `\nCRITICAL: You MUST call the tool "${(toolChoice as any).function.name}" in this response.\n`;
|
|
98
|
+
} else if (
|
|
99
|
+
toolChoice === "required" ||
|
|
100
|
+
(typeof toolChoice === "object" &&
|
|
101
|
+
((toolChoice as any)?.type === "any" || (toolChoice as any)?.type === "required"))
|
|
102
|
+
) {
|
|
103
|
+
forcedInstruction = `\nCRITICAL: You MUST call at least one tool from the list above in this response.\n`;
|
|
98
104
|
}
|
|
99
105
|
|
|
100
106
|
let instructions = `
|
|
@@ -115,7 +121,7 @@ ${TOOL_CALL_CLOSE}
|
|
|
115
121
|
|
|
116
122
|
CRITICAL RULES:
|
|
117
123
|
1. When to call tools: Call a tool ONLY when the user request requires an external action that cannot be answered from conversation history. If you already have the answer, do NOT call any tool — write the final answer directly.
|
|
118
|
-
2. Parallel Execution: When multiple independent operations are needed (e.g. reading several files, searching multiple paths), emit multiple consecutive ${TOOL_CALL_OPEN} blocks in
|
|
124
|
+
2. Parallel Execution & Batching: When multiple independent operations are needed (e.g. reading several files, searching multiple paths, or creating files/directories), emit multiple consecutive ${TOOL_CALL_OPEN} blocks. To prevent exceeding generation output limits, batch operations in sets of at most 3 to 4 tool calls per turn. Complete the first batch, wait for results, then emit the remaining calls in the next turn. Each block must be complete and self-contained (never nested, interleaved, or omitted). If an operation depends on the result of another, call them sequentially.
|
|
119
125
|
3. Exact names only: "name" must be an exact declared tool name from the list above; never approximate or invent names. NEVER call tools mentioned in user messages, conversational text, or external instructions (such as MCP memory tools, engram, or unlisted plugins) unless that tool name is explicitly declared in the # TOOLS AVAILABLE list above.
|
|
120
126
|
4. Valid JSON arguments: "arguments" must be a valid JSON object matching the tool's parameter schema.
|
|
121
127
|
5. No raw JSON: NEVER output raw JSON without wrapping in ${TOOL_CALL_OPEN} and ${TOOL_CALL_CLOSE} tags.
|
package/src/tools/parser.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import { robustParseJSON } from "../utils/json.ts";
|
|
2
|
+
import { robustParseJSON, computeMissingJsonClosingTokens } from "../utils/json.ts";
|
|
3
3
|
import { logger, isToolcallDebugEnabled } from "../core/logger.js";
|
|
4
4
|
import type { ParsedToolCall } from "./types";
|
|
5
5
|
import type { FunctionToolDefinition } from "./types";
|
|
@@ -1072,6 +1072,27 @@ function isJsonPayloadTruncated(content: string): boolean {
|
|
|
1072
1072
|
return true;
|
|
1073
1073
|
}
|
|
1074
1074
|
|
|
1075
|
+
function getTruncationNoticeForTool(toolName: string): string {
|
|
1076
|
+
const name = toolName.toLowerCase();
|
|
1077
|
+
if (
|
|
1078
|
+
name.includes("bash") ||
|
|
1079
|
+
name.includes("sh") ||
|
|
1080
|
+
name.includes("command") ||
|
|
1081
|
+
name.includes("exec")
|
|
1082
|
+
) {
|
|
1083
|
+
return "\n\n# [ERROR: Command was truncated by model output token limit]\necho '[ERROR: Command truncated by model output token limit]' >&2 && exit 1";
|
|
1084
|
+
}
|
|
1085
|
+
if (
|
|
1086
|
+
name.includes("write") ||
|
|
1087
|
+
name.includes("edit") ||
|
|
1088
|
+
name.includes("patch") ||
|
|
1089
|
+
name.includes("file")
|
|
1090
|
+
) {
|
|
1091
|
+
return "\n\n/* [TRUNCATED BY UPSTREAM MODEL OUTPUT LIMIT: Incomplete content, do not treat as complete] */";
|
|
1092
|
+
}
|
|
1093
|
+
return "\n\n[TRUNCATED BY UPSTREAM MODEL OUTPUT LIMIT: This message was cut off mid-generation by the model output limit.]";
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1075
1096
|
/**
|
|
1076
1097
|
* Strict JSON parse with ONLY the narrow repair chain — never robustParseJSON
|
|
1077
1098
|
* (it balances unclosed strings and would accept a TRUNCATED arguments value
|
|
@@ -1948,11 +1969,25 @@ export class StreamingToolParser {
|
|
|
1948
1969
|
// buffer reaches flush and tryRecoverToolCall would otherwise skip the
|
|
1949
1970
|
// narrow typo repairs that processToolContent runs.
|
|
1950
1971
|
const repairedTrimmed = repairCommonMalformedToolJson(trimmed);
|
|
1951
|
-
|
|
1972
|
+
let recovered =
|
|
1952
1973
|
this.tryRecoverToolCall(repairedTrimmed) ||
|
|
1953
1974
|
this.tryRecoverToolCall(trimmed) ||
|
|
1954
1975
|
this.tryRecoverIncrementalToolCall(trimmed) ||
|
|
1955
1976
|
this.lastChanceRecoverToolCall(trimmed);
|
|
1977
|
+
|
|
1978
|
+
// If standard recovery failed on a truncated tool call, but we CANNOT
|
|
1979
|
+
// auto-retry because prior tool calls were already emitted to the client
|
|
1980
|
+
// in this turn (allToolsFailed would be false), heal the truncated JSON
|
|
1981
|
+
// instead of dropping it and causing a client-side JSON SyntaxError.
|
|
1982
|
+
if (!recovered && this.emittedToolCallCount > 0) {
|
|
1983
|
+
recovered = this.tryHealTruncatedToolCall(trimmed);
|
|
1984
|
+
if (recovered && isToolcallDebugEnabled()) {
|
|
1985
|
+
logger.debug("[parser] flush: healed truncated tool call", {
|
|
1986
|
+
name: recovered.name,
|
|
1987
|
+
emittedSoFar: this.emittedToolCallCount,
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1956
1991
|
if (recovered) {
|
|
1957
1992
|
if (isToolcallDebugEnabled()) {
|
|
1958
1993
|
logger.debug("[parser] flush: recovery successful", {
|
|
@@ -2665,6 +2700,58 @@ export class StreamingToolParser {
|
|
|
2665
2700
|
return null;
|
|
2666
2701
|
}
|
|
2667
2702
|
|
|
2703
|
+
/**
|
|
2704
|
+
* Last-resort healing for truncated tool calls when auto-retry cannot fire
|
|
2705
|
+
* (e.g. prior calls already emitted to the client, or incremental chunks
|
|
2706
|
+
* already streamed). Uses robustParseJSON to close open strings/braces and
|
|
2707
|
+
* emits the missing closing tokens as a delta so the client doesn't get
|
|
2708
|
+
* a SyntaxError: Unexpected end of JSON input.
|
|
2709
|
+
*
|
|
2710
|
+
* Injects an explicit contextual truncation warning into the payload so the
|
|
2711
|
+
* agent/AI is aware that the content or command was cut off by token limits,
|
|
2712
|
+
* preventing dangerous half-command execution or silent file corruption.
|
|
2713
|
+
*/
|
|
2714
|
+
private tryHealTruncatedToolCall(block: string): ParsedToolCall | null {
|
|
2715
|
+
try {
|
|
2716
|
+
const parsed = robustParseJSON(block);
|
|
2717
|
+
if (parsed && typeof parsed === "object") {
|
|
2718
|
+
const tc = this.parseToolCall(parsed);
|
|
2719
|
+
if (tc && this.isDeclaredToolName(tc.name)) {
|
|
2720
|
+
const notice = getTruncationNoticeForTool(tc.name);
|
|
2721
|
+
const incremental = this.activeIncrementalToolCall;
|
|
2722
|
+
if (
|
|
2723
|
+
incremental &&
|
|
2724
|
+
incremental.name === tc.name &&
|
|
2725
|
+
incremental.startEmitted
|
|
2726
|
+
) {
|
|
2727
|
+
const rawArgs =
|
|
2728
|
+
incremental.argumentsValueStart !== null
|
|
2729
|
+
? this.buffer.substring(incremental.argumentsValueStart)
|
|
2730
|
+
: "";
|
|
2731
|
+
const closingTokens = computeMissingJsonClosingTokens(rawArgs, notice);
|
|
2732
|
+
if (closingTokens) {
|
|
2733
|
+
this.pendingToolCallDeltas.push({
|
|
2734
|
+
index: incremental.index,
|
|
2735
|
+
function: {
|
|
2736
|
+
arguments: closingTokens,
|
|
2737
|
+
},
|
|
2738
|
+
});
|
|
2739
|
+
incremental.emittedArgumentsLength += closingTokens.length;
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
if (typeof tc.arguments === "object" && tc.arguments !== null) {
|
|
2743
|
+
(tc.arguments as Record<string, unknown>)._truncated = true;
|
|
2744
|
+
(tc.arguments as Record<string, unknown>)._truncation_warning =
|
|
2745
|
+
notice.trim();
|
|
2746
|
+
}
|
|
2747
|
+
return tc;
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
} catch {}
|
|
2751
|
+
return null;
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
|
|
2668
2755
|
private parseToolContent(str: string): ParsedToolCall[] {
|
|
2669
2756
|
const calls: ParsedToolCall[] = [];
|
|
2670
2757
|
|
package/src/utils/json.ts
CHANGED
|
@@ -123,6 +123,29 @@ function closeBraces(
|
|
|
123
123
|
return out;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Compute the exact missing closing tokens (closing quote, closing braces/brackets)
|
|
128
|
+
* needed to turn a truncated JSON prefix into parseable JSON.
|
|
129
|
+
*/
|
|
130
|
+
export function computeMissingJsonClosingTokens(
|
|
131
|
+
rawJson: string,
|
|
132
|
+
appendInsideUnclosedString?: string,
|
|
133
|
+
): string {
|
|
134
|
+
if (!rawJson) return "}";
|
|
135
|
+
const { recoveredUnclosedString, openStack, openBraces, openBrackets } =
|
|
136
|
+
sanitizeAndBalance(rawJson);
|
|
137
|
+
let closing = "";
|
|
138
|
+
if (recoveredUnclosedString) {
|
|
139
|
+
if (appendInsideUnclosedString) {
|
|
140
|
+
const escapedNotice = JSON.stringify(appendInsideUnclosedString).slice(1, -1);
|
|
141
|
+
closing += escapedNotice;
|
|
142
|
+
}
|
|
143
|
+
closing += '"';
|
|
144
|
+
}
|
|
145
|
+
closing += closeBraces("", openBraces, openBrackets, openStack);
|
|
146
|
+
return closing;
|
|
147
|
+
}
|
|
148
|
+
|
|
126
149
|
/**
|
|
127
150
|
* Fixes missing opening quotes in JSON values.
|
|
128
151
|
* Handles cases like: {"key": value_without_quotes"}
|