From a0abe83ae73793605de2323fedbde223a119383c Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 21 Aug 2026 02:42:26 +0300 Subject: [PATCH 01/13] feat(qwen): Baxia anti-bot punish detection, cooldown and local slider solver - request-pacing.mjs: detect punish responses (HTML _____tmd_____/punish, JSON RGV587 + data.url), streak-based cooldown, min-interval pacing between POST /completions, empty-stream backoff - baxia-solver.mjs: local solver for the AWSC nc drag slider (no external captcha service): scan frames for span.nc_1_n1z, human-like drag path (easeOut + jitter + overshoot), success detection via x5sec cookie; auto-clears cooldown on solve, falls back to 600s cooldown when unsolved - browser-proxy.mjs: run the solver on both punish branches (text + stream) before entering cooldown - client.mjs: pacing slot before completion, punish/cooldown error mapping Env: QWEN_BAXIA_AUTO_SOLVE=0 disables the solver (cooldown only), QWEN_PUNISH_COOLDOWN_MS overrides cooldown duration. --- .../src/providers/qwen/baxia-solver.mjs | 210 ++++++++++++++++ .../src/providers/qwen/browser-proxy.mjs | 84 +++++++ .../src/providers/qwen/client.mjs | 58 ++++- .../src/providers/qwen/request-pacing.mjs | 227 ++++++++++++++++++ src/providers/qwen/baxia-solver.mjs | 210 ++++++++++++++++ src/providers/qwen/browser-proxy.mjs | 84 +++++++ src/providers/qwen/client.mjs | 58 ++++- src/providers/qwen/request-pacing.mjs | 227 ++++++++++++++++++ test/qwen-baxia-solver.test.mjs | 85 +++++++ test/qwen-request-pacing.test.mjs | 173 +++++++++++++ 10 files changed, 1414 insertions(+), 2 deletions(-) create mode 100644 plugin-for-vscode/src/providers/qwen/baxia-solver.mjs create mode 100644 plugin-for-vscode/src/providers/qwen/request-pacing.mjs create mode 100644 src/providers/qwen/baxia-solver.mjs create mode 100644 src/providers/qwen/request-pacing.mjs create mode 100644 test/qwen-baxia-solver.test.mjs create mode 100644 test/qwen-request-pacing.test.mjs diff --git a/plugin-for-vscode/src/providers/qwen/baxia-solver.mjs b/plugin-for-vscode/src/providers/qwen/baxia-solver.mjs new file mode 100644 index 0000000..d8012e9 --- /dev/null +++ b/plugin-for-vscode/src/providers/qwen/baxia-solver.mjs @@ -0,0 +1,210 @@ +/** + * Локальный солвер Baxia punish-слайдера (AWSC nc, «проведите вправо»). + * + * Как это работает на chat.qwen.ai (наблюдения из HAR-дампа 2026-08-20): + * - Baxia перехватывает POST /api/v2/chat/completions и вместо SSE отдаёт + * punish-страницу /_____tmd_____/punish?x5step=2&action=captcha. + * - Qwen SPA рисует модалку (#baxia-dialog-content) с same-origin iframe + * Alibaba AWSC noCaptcha слайдера (nc_1_n1z — ручка, nc_1__scale_text — + * трек с текстом «Проведите вправо», сцена register, lang ru_RU). + * - Это НЕ слайдер-пазл с картинкой: достаточно довести ручку до правого + * края трека человеческим драгом. Внешний сервис (2Captcha/CapMonster) + * не нужен. + * - После успешного солва Baxia ставит cookie x5sec (report + * type=setCookieSuccess) и сам реплеит исходный fetch с тем же + * X-Request-Id — вручную повторять запрос не надо. + * + * Фоллбэк: если солвер не увидел слайдер или не добился x5sec за + * QWEN_BAXIA_SOLVE_TIMEOUT_MS — остаёмся на punish-кулдауне из + * request-pacing (дефолт 10 мин, потолок QWEN_PUNISH_COOLDOWN_MAX_MS). + */ + +export const BAXIA_SOLVER_VERSION = "baxia-nc-drag-v1"; + +/** Время между точками пути драга: человек не двигает мышь равномерно. */ +const HUMAN_STEP_MS = [8, 12, 16, 20, 24, 30]; + +/** + * Человекоподобный путь драга. Возвращает массив точек + * { x, y, dtMs } длиной steps+1: от (start,y=0) до (end,0). + * x монотонно растёт с лёгким джиттером, у конца — overshoot и + * возврат (типичная траектория «промахнулся и подтянул»). + * rng инъекцируется для детерминизма в тестах. + */ +export function buildDragPath({ start, end, steps, jitter = 2, rng = Math.random, overshootPx = 0 }) { + const s = Number(start) || 0; + const e = Number(end) || 0; + const n = Math.max(2, Math.min(60, Math.floor(Number(steps) || 20))); + const distance = e - s; + const path = []; + // easeOutQuad: быстрый разгон, замедление к концу — так тащат слайдеры люди. + const ease = (t) => t * (2 - t); + for (let i = 0; i <= n; i += 1) { + const t = i / n; + let x = s + distance * ease(t); + if (jitter > 0 && i > 0 && i < n) { + // Джиттер не должен разворачивать движение: масштабируем его + // текущим шагом (к концу ease-out шаг мал — джиттер гаснет). + const step = Math.abs(x - path[path.length - 1].x); + const j = Math.min(jitter, Math.max(0.3, step * 0.4)); + x += (rng() * 2 - 1) * j; + } + path.push({ + x: Math.round(x * 10) / 10, + y: i === 0 || i === n ? 0 : Math.round((rng() * 2 - 1) * jitter * 10) / 10, + dtMs: i === 0 ? 0 : HUMAN_STEP_MS[Math.floor(rng() * HUMAN_STEP_MS.length)], + }); + } + if (overshootPx > 0 && n >= 8) { + // Зайти за цель и плавно вернуть: 2 точки за end, финал точно в end. + const back1 = e + overshootPx * (0.5 + rng() * 0.5); + const back2 = e + overshootPx * 0.2; + path[n - 2] = { x: Math.round(back1 * 10) / 10, y: path[n - 2].y, dtMs: path[n - 2].dtMs }; + path[n - 1] = { x: Math.round(back2 * 10) / 10, y: path[n - 1].y, dtMs: path[n - 1].dtMs }; + path[n] = { x: e, y: 0, dtMs: HUMAN_STEP_MS[Math.floor(rng() * HUMAN_STEP_MS.length)] }; + } + path[n] = { x: e, y: 0, dtMs: path[n].dtMs }; + return path; +} + +/** Есть ли уже валидный (непустой) x5sec — признак недавнего успешного солва. */ +export function hasX5SecCookie(cookies) { + if (!Array.isArray(cookies)) return false; + const hit = cookies.find((c) => c && c.name === "x5sec" && typeof c.value === "string" && c.value.length > 0); + return Boolean(hit); +} + +/** + * Конфиг солвера. QWEN_BAXIA_AUTO_SOLVE=0 выключает полностью + * (остаётся только кулдаун-фоллбэк). + */ +export function resolveBaxiaSolverConfig(env = process.env) { + const num = (name, fallback) => { + const raw = env[name]; + if (raw === undefined || raw === null || raw === "") return fallback; + const v = Number(raw); + return Number.isFinite(v) ? v : fallback; + }; + const enabledRaw = env.QWEN_BAXIA_AUTO_SOLVE; + const enabled = enabledRaw === undefined || enabledRaw === "" ? true : !/^(0|false|no|off)$/i.test(String(enabledRaw)); + return { + enabled, + maxTries: Math.max(1, Math.min(6, num("QWEN_BAXIA_SOLVE_MAX_TRIES", 3))), + settleMs: Math.max(200, Math.min(5_000, num("QWEN_BAXIA_SOLVE_SETTLE_MS", 800))), + totalTimeoutMs: Math.max(5_000, Math.min(600_000, num("QWEN_BAXIA_SOLVE_TIMEOUT_MS", 120_000))), + }; +} + +/** + * Ждать появления iframe-слайдера в модалке Baxia на странице чата. + * Возвращает ElementHandle | null. + */ +export async function waitForBaxiaSlider(page, { timeoutMs = 15_000, pollMs = 400, log } = {}) { + const deadline = Date.now() + Math.max(1_000, timeoutMs); + while (Date.now() < deadline) { + try { + const handle = await findBaxiaSlider(page); + if (handle) return handle; + } catch (err) { + if (log) log(`slider probe failed: ${err?.message || err}`); + } + await page.waitForTimeout(pollMs); + } + return null; +} + +/** + * Найти ручку слайдера. Слайдер AWSC nc живёт в same-origin iframe + * (baxia-dialog / nc-container). Same-origin => доступ к содержимому + * фрейма разрешён. Пытаемся несколько селекторов и main-frame fallback. + */ +export async function findBaxiaSlider(page) { + const selectors = [ + "span.nc_1_n1z", // ручка nc-слайдера + "div.nc-lang-cnt span[role=button]", // fallback: ручка как role=button + "div#nc_1__scale_text", // сам трек + ]; + for (const frame of page.frames()) { + for (const sel of selectors) { + try { + const el = await frame.$(sel); + if (el) { + const visible = await el.evaluate((node) => { + const r = node.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }); + if (visible) return el; + } + } catch { + // cross-origin frame или фрейм умер — пропускаем + } + } + } + return null; +} + +/** + * Решить слайдер человеческим драгом: hover → mousedown на ручке → + * серия mouse.move по buildDragPath → mouse.up. Возвращает true, + * если драг дошёл до конца трека (валидацию делает сервер — итог + * подтверждается появлением x5sec). + */ +export async function dragBaxiaSlider(page, handle, { endX, rng = Math.random } = {}) { + const box = await handle.boundingBox(); + if (!box) return false; + const startX = box.x + box.width / 2; + const startY = box.y + box.height / 2; + // Трек шире ручки: тянем до правого края трека (+ запас на ширину ручки). + const track = endX ?? Math.min(startX + 300, page.viewportSize?.()?.width - 40 || startX + 300); + const steps = 20 + Math.floor(rng() * 15); + const path = buildDragPath({ start: startX, end: track, steps, jitter: 2.5, rng, overshootPx: 6 + rng() * 8 }); + + await page.mouse.move(startX, startY, { steps: 2 }); + await page.waitForTimeout(80 + rng() * 120); + await page.mouse.down(); + for (const p of path) { + if (p.dtMs) await page.waitForTimeout(p.dtMs); + await page.mouse.move(p.x, startY + p.y, { steps: 1 }); + } + await page.waitForTimeout(60 + rng() * 140); + await page.mouse.up(); + return true; +} + +/** + * Полный цикл: дождаться слайдера, продрагать, дождаться x5sec. + * Возвращает { solved: boolean, tries: number, error?: string }. + */ +export async function trySolveBaxiaOnPage(page, cfg = resolveBaxiaSolverConfig(), { log } = {}) { + const started = Date.now(); + for (let i = 1; i <= cfg.maxTries; i += 1) { + if (Date.now() - started > cfg.totalTimeoutMs) { + return { solved: false, tries: i - 1, error: "solver_timeout" }; + } + const handle = await waitForBaxiaSlider(page, { timeoutMs: 15_000, log }); + if (!handle) return { solved: false, tries: i, error: "slider_not_found" }; + log?.(`drag try ${i}/${cfg.maxTries}`); + const dragged = await dragBaxiaSlider(page, handle, {}); + if (!dragged) continue; + // Ждём setCookieSuccess: x5sec появляется в cookies страницы. + const ok = await waitForX5Sec(page, cfg.settleMs * 3 + 4_000); + if (ok) return { solved: true, tries: i }; + log?.(`try ${i}: x5sec not set after drag`); + } + return { solved: false, tries: cfg.maxTries, error: "x5sec_timeout" }; +} + +/** Ждать появления cookie x5sec в контексте страницы. */ +export async function waitForX5Sec(page, timeoutMs = 10_000, pollMs = 500) { + const deadline = Date.now() + Math.max(500, timeoutMs); + while (Date.now() < deadline) { + try { + const cookies = await page.context().cookies(); + if (hasX5SecCookie(cookies)) return true; + } catch { + // контекст мог закрыться + } + await page.waitForTimeout(pollMs); + } + return false; +} diff --git a/plugin-for-vscode/src/providers/qwen/browser-proxy.mjs b/plugin-for-vscode/src/providers/qwen/browser-proxy.mjs index dccaea2..2e4a677 100644 --- a/plugin-for-vscode/src/providers/qwen/browser-proxy.mjs +++ b/plugin-for-vscode/src/providers/qwen/browser-proxy.mjs @@ -20,6 +20,8 @@ import { QWEN_AUTH_FILE, QWEN_BASE_URL, QWEN_BROWSER_PROFILE } from "./config.mj import { applyQwenCookiesToContext, readQwenAuth } from "./auth-files.mjs"; import { randomUUID } from "node:crypto"; import { resolveQwenStreamTimeouts } from "./stream-timeouts.mjs"; +import { isQwenPunishResponse, startQwenPunishCooldown, clearQwenPunishCooldown } from "./request-pacing.mjs"; +import { resolveBaxiaSolverConfig, trySolveBaxiaOnPage } from "./baxia-solver.mjs"; let proxyPromise = null; const QWEN_NAV_TIMEOUT_MS = Number(process.env.QWEN_NAV_TIMEOUT_MS || 90_000); @@ -132,6 +134,30 @@ async function createProxy({ debug }) { attachPageDiagnostics(firstPage, "page0"); + /** + * Попытка решить Baxia punish-слайдер на странице воркера. + * Возвращает true при успехе (x5sec установлен, кулдаун снят). + * Любая ошибка проглатывается — фоллбэком остаётся punish-кулдаун. + */ + const trySolvePunishOnWorker = async (worker, pathLabel) => { + const cfg = resolveBaxiaSolverConfig(); + if (!cfg.enabled) return false; + try { + const res = await trySolveBaxiaOnPage(worker.page, cfg, { + log: (msg) => console.warn(`[qwen-proxy:${worker.label}] baxia-solver(${pathLabel}): ${msg}`), + }); + if (res.solved) { + clearQwenPunishCooldown(); + return true; + } + console.warn(`[qwen-proxy:${worker.label}] baxia-solver(${pathLabel}): not solved (${res.error} after ${res.tries} tries) — fallback to cooldown`); + return false; + } catch (err) { + console.warn(`[qwen-proxy:${worker.label}] baxia-solver(${pathLabel}) failed: ${err?.message || err}`); + return false; + } + }; + let rawStreamHandler = null; await context.exposeFunction("__qwenRawStreamChunk", async (chunk) => { return typeof rawStreamHandler === "function" && rawStreamHandler(chunk) === true; @@ -434,6 +460,18 @@ async function createProxy({ debug }) { result.text += `\nnetwork=${failure.errorText}\nnetworkMethod=${failure.method}`; } } + // Baxia punish (антибот-капча): детект по contentType/text и включение + // кулдауна, чтобы выше по стеку не долбить новыми запросами. + if (/\/api\/v2\/chat\/completions(?:$|\?)/.test(url) && isQwenPunishResponse(result)) { + const solved = await trySolvePunishOnWorker(worker, "text"); + if (solved) { + console.warn(`[qwen-proxy:${worker.label}] Baxia slider solved (text path) — cooldown cleared`); + } else { + const { backoffMs } = startQwenPunishCooldown(); + result = { ...result, ok: false, punish: true }; + console.warn(`[qwen-proxy:${worker.label}] Baxia punish detected — cooldown ${Math.round(backoffMs / 1000)}s (see browser window to solve captcha)`); + } + } return result; } @@ -611,6 +649,20 @@ async function createProxy({ debug }) { result.text += `\nnetwork=${failure.errorText}\nnetworkMethod=${failure.method}`; } } + // Baxia punish (антибот-капча). Пробуем решить слайдер локально; + // если не вышло — остаёмся на кулдауне из request-pacing. + if (/\/api\/v2\/chat\/completions(?:$|\?)/.test(url) && isQwenPunishResponse(result)) { + const solved = await trySolvePunishOnWorker(worker, "text"); + if (solved) { + // Baxia сам реплеит запрос после setCookieSuccess — просто отдаём + // результат как есть, клиент сделает новую попытку без кулдауна. + console.warn(`[qwen-proxy:${worker.label}] Baxia slider solved (stream path) — cooldown cleared`); + } else { + const { backoffMs } = startQwenPunishCooldown(); + result = { ...result, ok: false, punish: true }; + console.warn(`[qwen-proxy:${worker.label}] Baxia punish detected (stream) — cooldown ${Math.round(backoffMs / 1000)}s`); + } + } return result; } @@ -642,6 +694,38 @@ async function createProxy({ debug }) { maxAttempts, })); }, + // Same-origin POST к API chat.qwen.ai из контекста страницы — для файловых + // эндпоинтов (getstsToken / parse / parse/status). bx-ua подписывается + // JS-бандлом страницы автоматически, как у настоящего веб-интерфейса. + // ВАЖНО: page.evaluate сериализует результат (JSON) — функции не переносятся, + // поэтому json возвращаем как plain-поле, а не метод. + async proxyApiPost({ path, body, chatId, timeoutMs = 30_000 }) { + const worker = pickWorker(chatId || null); + return enqueue(worker, () => worker.page.evaluate( + async ({ path, body, timeoutMs }) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort("qwen_fetch_timeout"), timeoutMs); + try { + const res = await fetch(path, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/plain, */*", + source: "web", + }, + credentials: "include", + body: JSON.stringify(body), + signal: controller.signal, + }); + const json = await res.json().catch(() => null); + return { ok: res.ok, status: res.status, json }; + } finally { + clearTimeout(timeoutId); + } + }, + { path, body, timeoutMs }, + )); + }, async close() { await close(); }, }; } diff --git a/plugin-for-vscode/src/providers/qwen/client.mjs b/plugin-for-vscode/src/providers/qwen/client.mjs index 7bcf3a1..62091d8 100644 --- a/plugin-for-vscode/src/providers/qwen/client.mjs +++ b/plugin-for-vscode/src/providers/qwen/client.mjs @@ -29,6 +29,8 @@ import { } from "./session-errors.mjs"; import { getQwenBrowserProxy, resetQwenBrowserProxy } from "./browser-proxy.mjs"; import { buildQwenCompletionPayload } from "./completion-payload.mjs"; +import { resolveQwenContextFileConfig, splitPromptForFileUpload, uploadQwenContextFile } from "./context-file.mjs"; +import { waitForQwenCompletionSlot, createQwenPunishError, qwenAntibotCooldownRemainingMs } from "./request-pacing.mjs"; import { createFileLogger } from "../../logging/logger.mjs"; const providerLogger = createFileLogger({ component: "provider.qwen" }); @@ -420,13 +422,39 @@ export class QwenChatClient { onThinking, model, }) { + // Большой промпт → файл-вложение context.txt (аналог вставки большого + // текста в поле ввода веб-интерфейса). Антибот Qwen при промпте свыше + // ~118k символов молча возвращает punish-заглушку вместо ответа. + let promptToUse = String(prompt || ""); + let contextFiles = null; + const contextFileConfig = resolveQwenContextFileConfig(); + const split = splitPromptForFileUpload(promptToUse, contextFileConfig); + if (split) { + const proxy = await getQwenBrowserProxy({ debug: this.debug }); + providerLogger.info("provider.qwen.context_file", { + operation: "upload", + fileChars: split.fileChars, + inlineChars: split.inlineChars, + }); + const attachment = await uploadQwenContextFile({ + proxyApiPost: (path, apiBody) => proxy.proxyApiPost({ path, body: apiBody, chatId }), + content: split.fileText, + }); + promptToUse = split.inline; + contextFiles = [attachment]; + if (this.debug) { + console.log(`[qwen] context moved to file: ${split.fileChars} chars → ${attachment.name} (${attachment.id}); inline left: ${split.inlineChars}`); + } + } + const body = buildQwenCompletionPayload({ chatId, - prompt, + prompt: promptToUse, parentId, model, thinking, search, + files: contextFiles, }); const headers = { @@ -460,6 +488,13 @@ export class QwenChatClient { const streamParser = useLiveStream ? createQwenIncrementalParser({ onText, onThinking }) : null; + // Pacing: минимальный интервал между POST /completions, чтобы не + // разгонять риск-скоринг Baxia. Также кидает QWEN_ANTIBOT_PUNISH, + // если активен кулдаун после детектированной punish-страницы. + const pacingWaitMs = await waitForQwenCompletionSlot(); + if (pacingWaitMs > 0 && this.debug) { + console.log(`[qwen] pacing: waited ${pacingWaitMs}ms before completion`); + } const result = useLiveStream ? await proxy.proxyFetchStream({ url, @@ -471,6 +506,11 @@ export class QwenChatClient { : await proxy.proxyFetch({ url, body: bodyStr, chatId }); throwIfQwenFirstContentTimeout(result); + // Прокси пометил ответ как Baxia punish (антибот-капча) — + // кулдаун уже включён в прокси, наверх уходит понятная ошибка. + if (result.punish) { + throw createQwenPunishError(qwenAntibotCooldownRemainingMs()); + } providerLogger.info("provider.qwen.response", { operation: "completion", transport: "browser", @@ -533,6 +573,9 @@ export class QwenChatClient { transport: "browser", attempt: attempt + 1, }); + // Punish/кулдаун не ретраим на месте — ошибка уходит наверх, + // агентный слой получит понятный код и сообщение о капче. + if (error?.code === "QWEN_ANTIBOT_PUNISH") throw error; if (attempt >= 2 || !isQwenTransientBrowserTransportError(error)) { // Транспортная/авторизационная ошибка не считается «in progress». if (chatInProgressSeen) return { result: null, chatInProgressStuck: true }; @@ -603,6 +646,19 @@ function formatQwenInvalidParentUserMessage() { // SSE-событие с полем error (квота, rate limit и т.д.) — не содержит текста ответа. export function formatQwenStreamError(parsed) { if (!parsed || typeof parsed !== "object") return null; + // Punish-заглушка антибота Alibaba TMD: /completions при превышении лимита + // контекста (~118-120k символов промпта) молча отдаёт JSON с ret[] и + // data.url=.../_____tmd_____/punish вместо SSE-стрима (наблюдалось 2026-08). + const rets = Array.isArray(parsed.ret) ? parsed.ret : null; + if (rets && rets.some((r) => String(r).includes("RGV587") || String(r).includes("FAIL_SYS_USER_VALIDATE"))) { + const punishUrl = parsed?.data?.url || ""; + return ( + "Qwen отклонил запрос антибот-заглушкой (RGV587 punish). Обычно это значит, что промпт слишком большой " + + "(лимит ~118k символов на сообщение) — сократи контекст или он уйдёт файлом-вложением (context.txt), " + + "если включена автозагрузка больших промптов.\n\n" + + `ret: ${rets.join(", ")}\n${punishUrl ? `punish: ${punishUrl.slice(0, 160)}\n` : ""}` + ); + } const err = parsed.error; if (err && typeof err === "object") { const code = String(err.code || err.type || "error"); diff --git a/plugin-for-vscode/src/providers/qwen/request-pacing.mjs b/plugin-for-vscode/src/providers/qwen/request-pacing.mjs new file mode 100644 index 0000000..c128944 --- /dev/null +++ b/plugin-for-vscode/src/providers/qwen/request-pacing.mjs @@ -0,0 +1,227 @@ +/** + * Qwen request pacing & anti-bot cooldown (Baxia punish protection). + * + * Проблема: серия быстрых POST /api/v2/chat/completions (агентные tool-loop + * запросы) разгоняет риск-скоринг Baxia. Endpoint начинает отдавать + * punish-страницу (HTML 200 с `_____tmd_____/punish`) вместо SSE-потока. + * Клиент видит «пустой стрим», мгновенно создаёт новый чат и повторяет — + * чем ещё сильнее греет скоринг. + * + * Три механизма: + * 1. Pacing — минимальный интервал между POST /completions. + * 2. Empty-stream backoff — серия пустых стримов => короткий кулдаун. + * 3. Punish detection — punish-страница => экспоненциальный кулдаун, + * ошибка наружу с внятным сообщением. + */ + +/** Error code: Baxia punish page detected (antibot cooldown active). */ +export const QWEN_ANTIBOT_PUNISH = "QWEN_ANTIBOT_PUNISH"; + +const DEFAULT_MIN_INTERVAL_MS = 4_000; // 4 c между POST по умолчанию +const DEFAULT_EMPTY_BACKOFF_MS = 60_000; +const DEFAULT_PUNISH_COOLDOWN_MS = 600_000; // база: 10 минут +const DEFAULT_PUNISH_COOLDOWN_MAX_MS = 1_800_000; // потолок: 30 минут +const DEFAULT_EMPTY_STREAK_THRESHOLD = 3; + +/** Дефолты экспортированы для тестов и документации. */ +export function defaultMinIntervalMs() { return DEFAULT_MIN_INTERVAL_MS; } +export function defaultPunishCooldownMs() { return DEFAULT_PUNISH_COOLDOWN_MS; } + +function numEnv(name, fallback) { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +// --- punish page detection ------------------------------------------------ + +const PUNISH_MARKERS = [ + "_____tmd_____/punish", + "_____tmd_____%2Fpunish", + "/_____tmd_____/", + "x5secdata=", + "action=captcha", + "showBaxiaCaptchaModal", + "RGV587_ERROR", +]; + +/** + * Короткая проверка ответа: это punish/капча-страница Baxia? + * Принимает строку тела либо `{ contentType, text }`. + */ +export function isQwenPunishResponse(response) { + if (response == null) return false; + const body = + typeof response === "string" ? response : String(response.text ?? ""); + if (body.length === 0) return false; + // SSE-потоки начинаются с "data:"; punish всегда HTML/JSON. + if (body.startsWith("data:")) return false; + const head = body.length > 4096 ? body.slice(0, 4096) : body; + return PUNISH_MARKERS.some((marker) => head.includes(marker)); +} + +/** Создать ошибку punish с человеческим объяснением. */ +export function createQwenPunishError(cooldownMs) { + const seconds = Math.ceil((cooldownMs ?? 0) / 1000); + const err = new Error( + `Qwen Baxia antibot punish (captcha). Cooldown ~${seconds}s. ` + + `Решите капчу в окне браузера ai-free или подождите. ` + + `Снизить частоту: QWEN_COMPLETION_MIN_INTERVAL_MS (например 5000).` + ); + err.code = QWEN_ANTIBOT_PUNISH; + err.cooldownMs = cooldownMs; + return err; +} + +// --- cooldown state ------------------------------------------------------- + +let punishUntil = 0; +let punishStreak = 0; +let lastCompletionAt = 0; +let emptyStreak = 0; + +/** Сброс состояния (только для тестов). */ +export function resetQwenPacingStateForTests() { + punishUntil = 0; + punishStreak = 0; + lastCompletionAt = 0; + emptyStreak = 0; +} + +function resolvePunishCooldownMs() { + return numEnv("QWEN_PUNISH_COOLDOWN_MS", DEFAULT_PUNISH_COOLDOWN_MS); +} + +function resolvePunishCooldownMaxMs() { + return numEnv( + "QWEN_PUNISH_COOLDOWN_MAX_MS", + DEFAULT_PUNISH_COOLDOWN_MAX_MS + ); +} + +/** + * Активен ли антибот-кулдаун. + * @returns {number} остаток мс или 0. + */ +export function qwenAntibotCooldownRemainingMs(now = Date.now()) { + return Math.max(0, punishUntil - now); +} + +// Совместимые алиасы. +export const getQwenAntibotCooldownRemaining = qwenAntibotCooldownRemainingMs; +export const registerQwenPunish = startQwenPunishCooldown; + +/** + * Бросить, если антибот-кулдаун активен. + * @returns {boolean} false если кулдаун не активен. + * @throws {Error} с code=QWEN_ANTIBOT_PUNISH и cooldownRemainingMs. + */ +export function assertNoQwenAntibotCooldown(now = Date.now()) { + const remaining = qwenAntibotCooldownRemainingMs(now); + if (remaining <= 0) return false; + const err = createQwenPunishError(remaining); + err.cooldownRemainingMs = remaining; + throw err; +} + +// Зарегистрировать punish: экспоненциальный бэкофф с потолком. +export function startQwenPunishCooldown(now = Date.now()) { + const base = resolvePunishCooldownMs(); + const cap = Math.max(resolvePunishCooldownMaxMs(), base); + punishStreak += 1; + const backoffMs = Math.min(base * 2 ** (punishStreak - 1), cap); + punishUntil = Math.max(punishUntil, now + backoffMs); + emptyStreak = 0; + return { punishStreak, backoffMs }; +} + +/** Успешный ответ: сбросить streak-и и снять кулдаун. */ +export function registerQwenCompletionSuccess(now = Date.now()) { + punishStreak = 0; + emptyStreak = 0; + if (punishUntil > now) punishUntil = now; +} + +/** Солвер капчи решил punish: немедленно снять кулдаун. */ +export function clearQwenPunishCooldown() { + punishStreak = 0; + emptyStreak = 0; + punishUntil = 0; +} + +// --- empty-stream backoff --------------------------------------------------- + +/** + * Зафиксировать результат стрима. Пустые стримы подряд наращивают счётчик; + * при достижении порога включается короткий кулдаун, счётчик сбрасывается. + */ +export function recordQwenStreamOutcome(wasEmpty, now = Date.now()) { + if (wasEmpty) { + emptyStreak += 1; + if (emptyStreak >= emptyStreakThreshold()) { + const backoff = resolveEmptyBackoffMs(); + punishUntil = Math.max(punishUntil, now + backoff); + emptyStreak = 0; + } + return; + } + registerQwenCompletionSuccess(now); +} + +function emptyStreakThreshold() { + return numEnv("QWEN_EMPTY_STREAK_LIMIT", DEFAULT_EMPTY_STREAK_THRESHOLD); +} + +function resolveEmptyBackoffMs() { + return numEnv("QWEN_EMPTY_BACKOFF_MS", DEFAULT_EMPTY_BACKOFF_MS); +} + +/** Текущая длина серии пустых стримов (для тестов/логов). */ +export function getQwenEmptyStreak() { + return emptyStreak; +} + +// --- completion pacing ----------------------------------------------------- + +/** + * Дождаться слота для POST /completions: если предыдущий запрос был меньше + * чем QWEN_COMPLETION_MIN_INTERVAL_MS назад — спим остаток. Слот + * резервируется сразу (на момент освобождения), чтобы параллельные вызовы + * сериализовались. + * + * @param {{now?:()=>number, sleep?:(ms:number)=>Promise}} [inject] + * @returns {Promise} сколько фактически ждали (мс). + */ +export async function waitForQwenCompletionSlot(inject = {}) { + // now может быть функцией (динамическое время) или числом (фиксированный + // момент — удобно для тестов и детерминированных вызовов). + const nowFn = + typeof inject.now === "function" + ? inject.now + : typeof inject.now === "number" + ? () => inject.now + : () => Date.now(); + const sleep = inject.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))); + + await assertNoQwenAntibotCooldown(nowFn()); + + const minInterval = numEnv( + "QWEN_COMPLETION_MIN_INTERVAL_MS", + DEFAULT_MIN_INTERVAL_MS + ); + if (minInterval <= 0) { + lastCompletionAt = nowFn(); + return 0; + } + + const start = nowFn(); + // Момент фактической отправки: не раньше прихода и не раньше + // предыдущего резерва (prev send + interval). Конкурентные вызовы + // автоматически сериализуются за счёт сдвига резерва в будущее. + const sendAt = Math.max(start, lastCompletionAt); + const waitMs = sendAt - start; + lastCompletionAt = sendAt + minInterval; + if (waitMs > 0) await sleep(waitMs); + return waitMs; +} diff --git a/src/providers/qwen/baxia-solver.mjs b/src/providers/qwen/baxia-solver.mjs new file mode 100644 index 0000000..d8012e9 --- /dev/null +++ b/src/providers/qwen/baxia-solver.mjs @@ -0,0 +1,210 @@ +/** + * Локальный солвер Baxia punish-слайдера (AWSC nc, «проведите вправо»). + * + * Как это работает на chat.qwen.ai (наблюдения из HAR-дампа 2026-08-20): + * - Baxia перехватывает POST /api/v2/chat/completions и вместо SSE отдаёт + * punish-страницу /_____tmd_____/punish?x5step=2&action=captcha. + * - Qwen SPA рисует модалку (#baxia-dialog-content) с same-origin iframe + * Alibaba AWSC noCaptcha слайдера (nc_1_n1z — ручка, nc_1__scale_text — + * трек с текстом «Проведите вправо», сцена register, lang ru_RU). + * - Это НЕ слайдер-пазл с картинкой: достаточно довести ручку до правого + * края трека человеческим драгом. Внешний сервис (2Captcha/CapMonster) + * не нужен. + * - После успешного солва Baxia ставит cookie x5sec (report + * type=setCookieSuccess) и сам реплеит исходный fetch с тем же + * X-Request-Id — вручную повторять запрос не надо. + * + * Фоллбэк: если солвер не увидел слайдер или не добился x5sec за + * QWEN_BAXIA_SOLVE_TIMEOUT_MS — остаёмся на punish-кулдауне из + * request-pacing (дефолт 10 мин, потолок QWEN_PUNISH_COOLDOWN_MAX_MS). + */ + +export const BAXIA_SOLVER_VERSION = "baxia-nc-drag-v1"; + +/** Время между точками пути драга: человек не двигает мышь равномерно. */ +const HUMAN_STEP_MS = [8, 12, 16, 20, 24, 30]; + +/** + * Человекоподобный путь драга. Возвращает массив точек + * { x, y, dtMs } длиной steps+1: от (start,y=0) до (end,0). + * x монотонно растёт с лёгким джиттером, у конца — overshoot и + * возврат (типичная траектория «промахнулся и подтянул»). + * rng инъекцируется для детерминизма в тестах. + */ +export function buildDragPath({ start, end, steps, jitter = 2, rng = Math.random, overshootPx = 0 }) { + const s = Number(start) || 0; + const e = Number(end) || 0; + const n = Math.max(2, Math.min(60, Math.floor(Number(steps) || 20))); + const distance = e - s; + const path = []; + // easeOutQuad: быстрый разгон, замедление к концу — так тащат слайдеры люди. + const ease = (t) => t * (2 - t); + for (let i = 0; i <= n; i += 1) { + const t = i / n; + let x = s + distance * ease(t); + if (jitter > 0 && i > 0 && i < n) { + // Джиттер не должен разворачивать движение: масштабируем его + // текущим шагом (к концу ease-out шаг мал — джиттер гаснет). + const step = Math.abs(x - path[path.length - 1].x); + const j = Math.min(jitter, Math.max(0.3, step * 0.4)); + x += (rng() * 2 - 1) * j; + } + path.push({ + x: Math.round(x * 10) / 10, + y: i === 0 || i === n ? 0 : Math.round((rng() * 2 - 1) * jitter * 10) / 10, + dtMs: i === 0 ? 0 : HUMAN_STEP_MS[Math.floor(rng() * HUMAN_STEP_MS.length)], + }); + } + if (overshootPx > 0 && n >= 8) { + // Зайти за цель и плавно вернуть: 2 точки за end, финал точно в end. + const back1 = e + overshootPx * (0.5 + rng() * 0.5); + const back2 = e + overshootPx * 0.2; + path[n - 2] = { x: Math.round(back1 * 10) / 10, y: path[n - 2].y, dtMs: path[n - 2].dtMs }; + path[n - 1] = { x: Math.round(back2 * 10) / 10, y: path[n - 1].y, dtMs: path[n - 1].dtMs }; + path[n] = { x: e, y: 0, dtMs: HUMAN_STEP_MS[Math.floor(rng() * HUMAN_STEP_MS.length)] }; + } + path[n] = { x: e, y: 0, dtMs: path[n].dtMs }; + return path; +} + +/** Есть ли уже валидный (непустой) x5sec — признак недавнего успешного солва. */ +export function hasX5SecCookie(cookies) { + if (!Array.isArray(cookies)) return false; + const hit = cookies.find((c) => c && c.name === "x5sec" && typeof c.value === "string" && c.value.length > 0); + return Boolean(hit); +} + +/** + * Конфиг солвера. QWEN_BAXIA_AUTO_SOLVE=0 выключает полностью + * (остаётся только кулдаун-фоллбэк). + */ +export function resolveBaxiaSolverConfig(env = process.env) { + const num = (name, fallback) => { + const raw = env[name]; + if (raw === undefined || raw === null || raw === "") return fallback; + const v = Number(raw); + return Number.isFinite(v) ? v : fallback; + }; + const enabledRaw = env.QWEN_BAXIA_AUTO_SOLVE; + const enabled = enabledRaw === undefined || enabledRaw === "" ? true : !/^(0|false|no|off)$/i.test(String(enabledRaw)); + return { + enabled, + maxTries: Math.max(1, Math.min(6, num("QWEN_BAXIA_SOLVE_MAX_TRIES", 3))), + settleMs: Math.max(200, Math.min(5_000, num("QWEN_BAXIA_SOLVE_SETTLE_MS", 800))), + totalTimeoutMs: Math.max(5_000, Math.min(600_000, num("QWEN_BAXIA_SOLVE_TIMEOUT_MS", 120_000))), + }; +} + +/** + * Ждать появления iframe-слайдера в модалке Baxia на странице чата. + * Возвращает ElementHandle | null. + */ +export async function waitForBaxiaSlider(page, { timeoutMs = 15_000, pollMs = 400, log } = {}) { + const deadline = Date.now() + Math.max(1_000, timeoutMs); + while (Date.now() < deadline) { + try { + const handle = await findBaxiaSlider(page); + if (handle) return handle; + } catch (err) { + if (log) log(`slider probe failed: ${err?.message || err}`); + } + await page.waitForTimeout(pollMs); + } + return null; +} + +/** + * Найти ручку слайдера. Слайдер AWSC nc живёт в same-origin iframe + * (baxia-dialog / nc-container). Same-origin => доступ к содержимому + * фрейма разрешён. Пытаемся несколько селекторов и main-frame fallback. + */ +export async function findBaxiaSlider(page) { + const selectors = [ + "span.nc_1_n1z", // ручка nc-слайдера + "div.nc-lang-cnt span[role=button]", // fallback: ручка как role=button + "div#nc_1__scale_text", // сам трек + ]; + for (const frame of page.frames()) { + for (const sel of selectors) { + try { + const el = await frame.$(sel); + if (el) { + const visible = await el.evaluate((node) => { + const r = node.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }); + if (visible) return el; + } + } catch { + // cross-origin frame или фрейм умер — пропускаем + } + } + } + return null; +} + +/** + * Решить слайдер человеческим драгом: hover → mousedown на ручке → + * серия mouse.move по buildDragPath → mouse.up. Возвращает true, + * если драг дошёл до конца трека (валидацию делает сервер — итог + * подтверждается появлением x5sec). + */ +export async function dragBaxiaSlider(page, handle, { endX, rng = Math.random } = {}) { + const box = await handle.boundingBox(); + if (!box) return false; + const startX = box.x + box.width / 2; + const startY = box.y + box.height / 2; + // Трек шире ручки: тянем до правого края трека (+ запас на ширину ручки). + const track = endX ?? Math.min(startX + 300, page.viewportSize?.()?.width - 40 || startX + 300); + const steps = 20 + Math.floor(rng() * 15); + const path = buildDragPath({ start: startX, end: track, steps, jitter: 2.5, rng, overshootPx: 6 + rng() * 8 }); + + await page.mouse.move(startX, startY, { steps: 2 }); + await page.waitForTimeout(80 + rng() * 120); + await page.mouse.down(); + for (const p of path) { + if (p.dtMs) await page.waitForTimeout(p.dtMs); + await page.mouse.move(p.x, startY + p.y, { steps: 1 }); + } + await page.waitForTimeout(60 + rng() * 140); + await page.mouse.up(); + return true; +} + +/** + * Полный цикл: дождаться слайдера, продрагать, дождаться x5sec. + * Возвращает { solved: boolean, tries: number, error?: string }. + */ +export async function trySolveBaxiaOnPage(page, cfg = resolveBaxiaSolverConfig(), { log } = {}) { + const started = Date.now(); + for (let i = 1; i <= cfg.maxTries; i += 1) { + if (Date.now() - started > cfg.totalTimeoutMs) { + return { solved: false, tries: i - 1, error: "solver_timeout" }; + } + const handle = await waitForBaxiaSlider(page, { timeoutMs: 15_000, log }); + if (!handle) return { solved: false, tries: i, error: "slider_not_found" }; + log?.(`drag try ${i}/${cfg.maxTries}`); + const dragged = await dragBaxiaSlider(page, handle, {}); + if (!dragged) continue; + // Ждём setCookieSuccess: x5sec появляется в cookies страницы. + const ok = await waitForX5Sec(page, cfg.settleMs * 3 + 4_000); + if (ok) return { solved: true, tries: i }; + log?.(`try ${i}: x5sec not set after drag`); + } + return { solved: false, tries: cfg.maxTries, error: "x5sec_timeout" }; +} + +/** Ждать появления cookie x5sec в контексте страницы. */ +export async function waitForX5Sec(page, timeoutMs = 10_000, pollMs = 500) { + const deadline = Date.now() + Math.max(500, timeoutMs); + while (Date.now() < deadline) { + try { + const cookies = await page.context().cookies(); + if (hasX5SecCookie(cookies)) return true; + } catch { + // контекст мог закрыться + } + await page.waitForTimeout(pollMs); + } + return false; +} diff --git a/src/providers/qwen/browser-proxy.mjs b/src/providers/qwen/browser-proxy.mjs index dccaea2..2e4a677 100644 --- a/src/providers/qwen/browser-proxy.mjs +++ b/src/providers/qwen/browser-proxy.mjs @@ -20,6 +20,8 @@ import { QWEN_AUTH_FILE, QWEN_BASE_URL, QWEN_BROWSER_PROFILE } from "./config.mj import { applyQwenCookiesToContext, readQwenAuth } from "./auth-files.mjs"; import { randomUUID } from "node:crypto"; import { resolveQwenStreamTimeouts } from "./stream-timeouts.mjs"; +import { isQwenPunishResponse, startQwenPunishCooldown, clearQwenPunishCooldown } from "./request-pacing.mjs"; +import { resolveBaxiaSolverConfig, trySolveBaxiaOnPage } from "./baxia-solver.mjs"; let proxyPromise = null; const QWEN_NAV_TIMEOUT_MS = Number(process.env.QWEN_NAV_TIMEOUT_MS || 90_000); @@ -132,6 +134,30 @@ async function createProxy({ debug }) { attachPageDiagnostics(firstPage, "page0"); + /** + * Попытка решить Baxia punish-слайдер на странице воркера. + * Возвращает true при успехе (x5sec установлен, кулдаун снят). + * Любая ошибка проглатывается — фоллбэком остаётся punish-кулдаун. + */ + const trySolvePunishOnWorker = async (worker, pathLabel) => { + const cfg = resolveBaxiaSolverConfig(); + if (!cfg.enabled) return false; + try { + const res = await trySolveBaxiaOnPage(worker.page, cfg, { + log: (msg) => console.warn(`[qwen-proxy:${worker.label}] baxia-solver(${pathLabel}): ${msg}`), + }); + if (res.solved) { + clearQwenPunishCooldown(); + return true; + } + console.warn(`[qwen-proxy:${worker.label}] baxia-solver(${pathLabel}): not solved (${res.error} after ${res.tries} tries) — fallback to cooldown`); + return false; + } catch (err) { + console.warn(`[qwen-proxy:${worker.label}] baxia-solver(${pathLabel}) failed: ${err?.message || err}`); + return false; + } + }; + let rawStreamHandler = null; await context.exposeFunction("__qwenRawStreamChunk", async (chunk) => { return typeof rawStreamHandler === "function" && rawStreamHandler(chunk) === true; @@ -434,6 +460,18 @@ async function createProxy({ debug }) { result.text += `\nnetwork=${failure.errorText}\nnetworkMethod=${failure.method}`; } } + // Baxia punish (антибот-капча): детект по contentType/text и включение + // кулдауна, чтобы выше по стеку не долбить новыми запросами. + if (/\/api\/v2\/chat\/completions(?:$|\?)/.test(url) && isQwenPunishResponse(result)) { + const solved = await trySolvePunishOnWorker(worker, "text"); + if (solved) { + console.warn(`[qwen-proxy:${worker.label}] Baxia slider solved (text path) — cooldown cleared`); + } else { + const { backoffMs } = startQwenPunishCooldown(); + result = { ...result, ok: false, punish: true }; + console.warn(`[qwen-proxy:${worker.label}] Baxia punish detected — cooldown ${Math.round(backoffMs / 1000)}s (see browser window to solve captcha)`); + } + } return result; } @@ -611,6 +649,20 @@ async function createProxy({ debug }) { result.text += `\nnetwork=${failure.errorText}\nnetworkMethod=${failure.method}`; } } + // Baxia punish (антибот-капча). Пробуем решить слайдер локально; + // если не вышло — остаёмся на кулдауне из request-pacing. + if (/\/api\/v2\/chat\/completions(?:$|\?)/.test(url) && isQwenPunishResponse(result)) { + const solved = await trySolvePunishOnWorker(worker, "text"); + if (solved) { + // Baxia сам реплеит запрос после setCookieSuccess — просто отдаём + // результат как есть, клиент сделает новую попытку без кулдауна. + console.warn(`[qwen-proxy:${worker.label}] Baxia slider solved (stream path) — cooldown cleared`); + } else { + const { backoffMs } = startQwenPunishCooldown(); + result = { ...result, ok: false, punish: true }; + console.warn(`[qwen-proxy:${worker.label}] Baxia punish detected (stream) — cooldown ${Math.round(backoffMs / 1000)}s`); + } + } return result; } @@ -642,6 +694,38 @@ async function createProxy({ debug }) { maxAttempts, })); }, + // Same-origin POST к API chat.qwen.ai из контекста страницы — для файловых + // эндпоинтов (getstsToken / parse / parse/status). bx-ua подписывается + // JS-бандлом страницы автоматически, как у настоящего веб-интерфейса. + // ВАЖНО: page.evaluate сериализует результат (JSON) — функции не переносятся, + // поэтому json возвращаем как plain-поле, а не метод. + async proxyApiPost({ path, body, chatId, timeoutMs = 30_000 }) { + const worker = pickWorker(chatId || null); + return enqueue(worker, () => worker.page.evaluate( + async ({ path, body, timeoutMs }) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort("qwen_fetch_timeout"), timeoutMs); + try { + const res = await fetch(path, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/plain, */*", + source: "web", + }, + credentials: "include", + body: JSON.stringify(body), + signal: controller.signal, + }); + const json = await res.json().catch(() => null); + return { ok: res.ok, status: res.status, json }; + } finally { + clearTimeout(timeoutId); + } + }, + { path, body, timeoutMs }, + )); + }, async close() { await close(); }, }; } diff --git a/src/providers/qwen/client.mjs b/src/providers/qwen/client.mjs index 7bcf3a1..62091d8 100644 --- a/src/providers/qwen/client.mjs +++ b/src/providers/qwen/client.mjs @@ -29,6 +29,8 @@ import { } from "./session-errors.mjs"; import { getQwenBrowserProxy, resetQwenBrowserProxy } from "./browser-proxy.mjs"; import { buildQwenCompletionPayload } from "./completion-payload.mjs"; +import { resolveQwenContextFileConfig, splitPromptForFileUpload, uploadQwenContextFile } from "./context-file.mjs"; +import { waitForQwenCompletionSlot, createQwenPunishError, qwenAntibotCooldownRemainingMs } from "./request-pacing.mjs"; import { createFileLogger } from "../../logging/logger.mjs"; const providerLogger = createFileLogger({ component: "provider.qwen" }); @@ -420,13 +422,39 @@ export class QwenChatClient { onThinking, model, }) { + // Большой промпт → файл-вложение context.txt (аналог вставки большого + // текста в поле ввода веб-интерфейса). Антибот Qwen при промпте свыше + // ~118k символов молча возвращает punish-заглушку вместо ответа. + let promptToUse = String(prompt || ""); + let contextFiles = null; + const contextFileConfig = resolveQwenContextFileConfig(); + const split = splitPromptForFileUpload(promptToUse, contextFileConfig); + if (split) { + const proxy = await getQwenBrowserProxy({ debug: this.debug }); + providerLogger.info("provider.qwen.context_file", { + operation: "upload", + fileChars: split.fileChars, + inlineChars: split.inlineChars, + }); + const attachment = await uploadQwenContextFile({ + proxyApiPost: (path, apiBody) => proxy.proxyApiPost({ path, body: apiBody, chatId }), + content: split.fileText, + }); + promptToUse = split.inline; + contextFiles = [attachment]; + if (this.debug) { + console.log(`[qwen] context moved to file: ${split.fileChars} chars → ${attachment.name} (${attachment.id}); inline left: ${split.inlineChars}`); + } + } + const body = buildQwenCompletionPayload({ chatId, - prompt, + prompt: promptToUse, parentId, model, thinking, search, + files: contextFiles, }); const headers = { @@ -460,6 +488,13 @@ export class QwenChatClient { const streamParser = useLiveStream ? createQwenIncrementalParser({ onText, onThinking }) : null; + // Pacing: минимальный интервал между POST /completions, чтобы не + // разгонять риск-скоринг Baxia. Также кидает QWEN_ANTIBOT_PUNISH, + // если активен кулдаун после детектированной punish-страницы. + const pacingWaitMs = await waitForQwenCompletionSlot(); + if (pacingWaitMs > 0 && this.debug) { + console.log(`[qwen] pacing: waited ${pacingWaitMs}ms before completion`); + } const result = useLiveStream ? await proxy.proxyFetchStream({ url, @@ -471,6 +506,11 @@ export class QwenChatClient { : await proxy.proxyFetch({ url, body: bodyStr, chatId }); throwIfQwenFirstContentTimeout(result); + // Прокси пометил ответ как Baxia punish (антибот-капча) — + // кулдаун уже включён в прокси, наверх уходит понятная ошибка. + if (result.punish) { + throw createQwenPunishError(qwenAntibotCooldownRemainingMs()); + } providerLogger.info("provider.qwen.response", { operation: "completion", transport: "browser", @@ -533,6 +573,9 @@ export class QwenChatClient { transport: "browser", attempt: attempt + 1, }); + // Punish/кулдаун не ретраим на месте — ошибка уходит наверх, + // агентный слой получит понятный код и сообщение о капче. + if (error?.code === "QWEN_ANTIBOT_PUNISH") throw error; if (attempt >= 2 || !isQwenTransientBrowserTransportError(error)) { // Транспортная/авторизационная ошибка не считается «in progress». if (chatInProgressSeen) return { result: null, chatInProgressStuck: true }; @@ -603,6 +646,19 @@ function formatQwenInvalidParentUserMessage() { // SSE-событие с полем error (квота, rate limit и т.д.) — не содержит текста ответа. export function formatQwenStreamError(parsed) { if (!parsed || typeof parsed !== "object") return null; + // Punish-заглушка антибота Alibaba TMD: /completions при превышении лимита + // контекста (~118-120k символов промпта) молча отдаёт JSON с ret[] и + // data.url=.../_____tmd_____/punish вместо SSE-стрима (наблюдалось 2026-08). + const rets = Array.isArray(parsed.ret) ? parsed.ret : null; + if (rets && rets.some((r) => String(r).includes("RGV587") || String(r).includes("FAIL_SYS_USER_VALIDATE"))) { + const punishUrl = parsed?.data?.url || ""; + return ( + "Qwen отклонил запрос антибот-заглушкой (RGV587 punish). Обычно это значит, что промпт слишком большой " + + "(лимит ~118k символов на сообщение) — сократи контекст или он уйдёт файлом-вложением (context.txt), " + + "если включена автозагрузка больших промптов.\n\n" + + `ret: ${rets.join(", ")}\n${punishUrl ? `punish: ${punishUrl.slice(0, 160)}\n` : ""}` + ); + } const err = parsed.error; if (err && typeof err === "object") { const code = String(err.code || err.type || "error"); diff --git a/src/providers/qwen/request-pacing.mjs b/src/providers/qwen/request-pacing.mjs new file mode 100644 index 0000000..c128944 --- /dev/null +++ b/src/providers/qwen/request-pacing.mjs @@ -0,0 +1,227 @@ +/** + * Qwen request pacing & anti-bot cooldown (Baxia punish protection). + * + * Проблема: серия быстрых POST /api/v2/chat/completions (агентные tool-loop + * запросы) разгоняет риск-скоринг Baxia. Endpoint начинает отдавать + * punish-страницу (HTML 200 с `_____tmd_____/punish`) вместо SSE-потока. + * Клиент видит «пустой стрим», мгновенно создаёт новый чат и повторяет — + * чем ещё сильнее греет скоринг. + * + * Три механизма: + * 1. Pacing — минимальный интервал между POST /completions. + * 2. Empty-stream backoff — серия пустых стримов => короткий кулдаун. + * 3. Punish detection — punish-страница => экспоненциальный кулдаун, + * ошибка наружу с внятным сообщением. + */ + +/** Error code: Baxia punish page detected (antibot cooldown active). */ +export const QWEN_ANTIBOT_PUNISH = "QWEN_ANTIBOT_PUNISH"; + +const DEFAULT_MIN_INTERVAL_MS = 4_000; // 4 c между POST по умолчанию +const DEFAULT_EMPTY_BACKOFF_MS = 60_000; +const DEFAULT_PUNISH_COOLDOWN_MS = 600_000; // база: 10 минут +const DEFAULT_PUNISH_COOLDOWN_MAX_MS = 1_800_000; // потолок: 30 минут +const DEFAULT_EMPTY_STREAK_THRESHOLD = 3; + +/** Дефолты экспортированы для тестов и документации. */ +export function defaultMinIntervalMs() { return DEFAULT_MIN_INTERVAL_MS; } +export function defaultPunishCooldownMs() { return DEFAULT_PUNISH_COOLDOWN_MS; } + +function numEnv(name, fallback) { + const raw = process.env[name]; + if (raw === undefined || raw === "") return fallback; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +} + +// --- punish page detection ------------------------------------------------ + +const PUNISH_MARKERS = [ + "_____tmd_____/punish", + "_____tmd_____%2Fpunish", + "/_____tmd_____/", + "x5secdata=", + "action=captcha", + "showBaxiaCaptchaModal", + "RGV587_ERROR", +]; + +/** + * Короткая проверка ответа: это punish/капча-страница Baxia? + * Принимает строку тела либо `{ contentType, text }`. + */ +export function isQwenPunishResponse(response) { + if (response == null) return false; + const body = + typeof response === "string" ? response : String(response.text ?? ""); + if (body.length === 0) return false; + // SSE-потоки начинаются с "data:"; punish всегда HTML/JSON. + if (body.startsWith("data:")) return false; + const head = body.length > 4096 ? body.slice(0, 4096) : body; + return PUNISH_MARKERS.some((marker) => head.includes(marker)); +} + +/** Создать ошибку punish с человеческим объяснением. */ +export function createQwenPunishError(cooldownMs) { + const seconds = Math.ceil((cooldownMs ?? 0) / 1000); + const err = new Error( + `Qwen Baxia antibot punish (captcha). Cooldown ~${seconds}s. ` + + `Решите капчу в окне браузера ai-free или подождите. ` + + `Снизить частоту: QWEN_COMPLETION_MIN_INTERVAL_MS (например 5000).` + ); + err.code = QWEN_ANTIBOT_PUNISH; + err.cooldownMs = cooldownMs; + return err; +} + +// --- cooldown state ------------------------------------------------------- + +let punishUntil = 0; +let punishStreak = 0; +let lastCompletionAt = 0; +let emptyStreak = 0; + +/** Сброс состояния (только для тестов). */ +export function resetQwenPacingStateForTests() { + punishUntil = 0; + punishStreak = 0; + lastCompletionAt = 0; + emptyStreak = 0; +} + +function resolvePunishCooldownMs() { + return numEnv("QWEN_PUNISH_COOLDOWN_MS", DEFAULT_PUNISH_COOLDOWN_MS); +} + +function resolvePunishCooldownMaxMs() { + return numEnv( + "QWEN_PUNISH_COOLDOWN_MAX_MS", + DEFAULT_PUNISH_COOLDOWN_MAX_MS + ); +} + +/** + * Активен ли антибот-кулдаун. + * @returns {number} остаток мс или 0. + */ +export function qwenAntibotCooldownRemainingMs(now = Date.now()) { + return Math.max(0, punishUntil - now); +} + +// Совместимые алиасы. +export const getQwenAntibotCooldownRemaining = qwenAntibotCooldownRemainingMs; +export const registerQwenPunish = startQwenPunishCooldown; + +/** + * Бросить, если антибот-кулдаун активен. + * @returns {boolean} false если кулдаун не активен. + * @throws {Error} с code=QWEN_ANTIBOT_PUNISH и cooldownRemainingMs. + */ +export function assertNoQwenAntibotCooldown(now = Date.now()) { + const remaining = qwenAntibotCooldownRemainingMs(now); + if (remaining <= 0) return false; + const err = createQwenPunishError(remaining); + err.cooldownRemainingMs = remaining; + throw err; +} + +// Зарегистрировать punish: экспоненциальный бэкофф с потолком. +export function startQwenPunishCooldown(now = Date.now()) { + const base = resolvePunishCooldownMs(); + const cap = Math.max(resolvePunishCooldownMaxMs(), base); + punishStreak += 1; + const backoffMs = Math.min(base * 2 ** (punishStreak - 1), cap); + punishUntil = Math.max(punishUntil, now + backoffMs); + emptyStreak = 0; + return { punishStreak, backoffMs }; +} + +/** Успешный ответ: сбросить streak-и и снять кулдаун. */ +export function registerQwenCompletionSuccess(now = Date.now()) { + punishStreak = 0; + emptyStreak = 0; + if (punishUntil > now) punishUntil = now; +} + +/** Солвер капчи решил punish: немедленно снять кулдаун. */ +export function clearQwenPunishCooldown() { + punishStreak = 0; + emptyStreak = 0; + punishUntil = 0; +} + +// --- empty-stream backoff --------------------------------------------------- + +/** + * Зафиксировать результат стрима. Пустые стримы подряд наращивают счётчик; + * при достижении порога включается короткий кулдаун, счётчик сбрасывается. + */ +export function recordQwenStreamOutcome(wasEmpty, now = Date.now()) { + if (wasEmpty) { + emptyStreak += 1; + if (emptyStreak >= emptyStreakThreshold()) { + const backoff = resolveEmptyBackoffMs(); + punishUntil = Math.max(punishUntil, now + backoff); + emptyStreak = 0; + } + return; + } + registerQwenCompletionSuccess(now); +} + +function emptyStreakThreshold() { + return numEnv("QWEN_EMPTY_STREAK_LIMIT", DEFAULT_EMPTY_STREAK_THRESHOLD); +} + +function resolveEmptyBackoffMs() { + return numEnv("QWEN_EMPTY_BACKOFF_MS", DEFAULT_EMPTY_BACKOFF_MS); +} + +/** Текущая длина серии пустых стримов (для тестов/логов). */ +export function getQwenEmptyStreak() { + return emptyStreak; +} + +// --- completion pacing ----------------------------------------------------- + +/** + * Дождаться слота для POST /completions: если предыдущий запрос был меньше + * чем QWEN_COMPLETION_MIN_INTERVAL_MS назад — спим остаток. Слот + * резервируется сразу (на момент освобождения), чтобы параллельные вызовы + * сериализовались. + * + * @param {{now?:()=>number, sleep?:(ms:number)=>Promise}} [inject] + * @returns {Promise} сколько фактически ждали (мс). + */ +export async function waitForQwenCompletionSlot(inject = {}) { + // now может быть функцией (динамическое время) или числом (фиксированный + // момент — удобно для тестов и детерминированных вызовов). + const nowFn = + typeof inject.now === "function" + ? inject.now + : typeof inject.now === "number" + ? () => inject.now + : () => Date.now(); + const sleep = inject.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))); + + await assertNoQwenAntibotCooldown(nowFn()); + + const minInterval = numEnv( + "QWEN_COMPLETION_MIN_INTERVAL_MS", + DEFAULT_MIN_INTERVAL_MS + ); + if (minInterval <= 0) { + lastCompletionAt = nowFn(); + return 0; + } + + const start = nowFn(); + // Момент фактической отправки: не раньше прихода и не раньше + // предыдущего резерва (prev send + interval). Конкурентные вызовы + // автоматически сериализуются за счёт сдвига резерва в будущее. + const sendAt = Math.max(start, lastCompletionAt); + const waitMs = sendAt - start; + lastCompletionAt = sendAt + minInterval; + if (waitMs > 0) await sleep(waitMs); + return waitMs; +} diff --git a/test/qwen-baxia-solver.test.mjs b/test/qwen-baxia-solver.test.mjs new file mode 100644 index 0000000..693dff8 --- /dev/null +++ b/test/qwen-baxia-solver.test.mjs @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { describe, it, beforeEach } from "node:test"; +import { + buildDragPath, + hasX5SecCookie, + resolveBaxiaSolverConfig, +} from "../src/providers/qwen/baxia-solver.mjs"; +import { + startQwenPunishCooldown, + clearQwenPunishCooldown, + qwenAntibotCooldownRemainingMs, + assertNoQwenAntibotCooldown, + resetQwenPacingStateForTests, +} from "../src/providers/qwen/request-pacing.mjs"; + +// Детерминированный rng для тестов. +const seededRng = (seed) => () => { + seed = (seed * 9301 + 49297) % 233280; + return seed / 233280; +}; + +describe("baxia-solver", () => { + it("buildDragPath: старт/финиш точные, точек steps+1, dtMs в диапазоне", () => { + const path = buildDragPath({ start: 0, end: 260, steps: 20, jitter: 2, rng: seededRng(42) }); + assert.equal(path.length, 21); + assert.equal(path[0].x, 0); + assert.equal(path[path.length - 1].x, 260); + assert.equal(path[path.length - 1].y, 0); + // Первая точка — старт (dtMs=0, задержка до mousedown отдельно). + for (let i = 1; i < path.length; i += 1) { + const p = path[i]; + assert.ok(p.dtMs >= 8 && p.dtMs < 32, `dtMs out of range: ${p.dtMs}`); + } + // Монотонный прогресс (без учёта overshoot). + for (let i = 1; i < path.length - 1; i += 1) { + assert.ok(path[i].x >= path[i - 1].x - 0.5, `x regressed at ${i}`); + } + }); + + it("buildDragPath: overshoot заходит за end и возвращается", () => { + const path = buildDragPath({ start: 0, end: 200, steps: 24, jitter: 0, rng: seededRng(7), overshootPx: 8 }); + const maxX = Math.max(...path.map((p) => p.x)); + assert.ok(maxX > 200, `overshoot expected, max=${maxX}`); + assert.equal(path[path.length - 1].x, 200); + }); + + it("buildDragPath: детерминирован при одинаковом rng", () => { + const a = buildDragPath({ start: 0, end: 100, steps: 10, jitter: 3, rng: seededRng(5) }); + const b = buildDragPath({ start: 0, end: 100, steps: 10, jitter: 3, rng: seededRng(5) }); + assert.deepEqual(a, b); + }); + + it("hasX5SecCookie: true только для непустого x5sec", () => { + assert.equal(hasX5SecCookie([{ name: "tfstk", value: "x" }]), false); + assert.equal(hasX5SecCookie([{ name: "x5sec", value: "" }]), false); + assert.equal(hasX5SecCookie([]), false); + assert.equal( + hasX5SecCookie([{ name: "acw_tc", value: "1" }, { name: "x5sec", value: "7b22733b32..." }]), + true, + ); + }); + + it("конфиг: дефолты, выключение через env, границы", () => { + const cfg = resolveBaxiaSolverConfig({}); + assert.equal(cfg.enabled, true); + assert.equal(cfg.maxTries, 3); + assert.ok(cfg.totalTimeoutMs >= 5_000); + + const off = resolveBaxiaSolverConfig({ QWEN_BAXIA_AUTO_SOLVE: "0" }); + assert.equal(off.enabled, false); + + const clamped = resolveBaxiaSolverConfig({ QWEN_BAXIA_SOLVE_MAX_TRIES: "99" }); + assert.equal(clamped.maxTries, 6); + }); + + it("clearQwenPunishCooldown сбрасывает кулдаун после успешного солва", () => { + resetQwenPacingStateForTests(); + startQwenPunishCooldown(0); + assert.ok(qwenAntibotCooldownRemainingMs(1_000) > 0); + clearQwenPunishCooldown(); + assert.equal(qwenAntibotCooldownRemainingMs(1_000), 0); + // Не бросает — кулдауна нет. + assert.equal(assertNoQwenAntibotCooldown(200_000_000), false); + }); +}); diff --git a/test/qwen-request-pacing.test.mjs b/test/qwen-request-pacing.test.mjs new file mode 100644 index 0000000..9df5943 --- /dev/null +++ b/test/qwen-request-pacing.test.mjs @@ -0,0 +1,173 @@ +import assert from "node:assert/strict"; +import { describe, it, beforeEach } from "node:test"; +import { + QWEN_ANTIBOT_PUNISH, + assertNoQwenAntibotCooldown, + createQwenPunishError, + isQwenPunishResponse, + getQwenAntibotCooldownRemaining, + qwenAntibotCooldownRemainingMs, + recordQwenStreamOutcome, + startQwenPunishCooldown, + registerQwenPunish, + registerQwenCompletionSuccess, + resetQwenPacingStateForTests, + waitForQwenCompletionSlot, +} from "../src/providers/qwen/request-pacing.mjs"; + +// Тестовые хелперы для дефолтов (экспортируются только для тестов). +const { + defaultMinIntervalMs, + defaultPunishCooldownMs, +} = await import("../src/providers/qwen/request-pacing.mjs"); + +// Реальная punish-страница Baxia (Alibaba TMD), наблюдавшаяся 2026-08-20: +// POST /api/v2/chat/completions → 200 text/html с редиректом на капчу-слайдер. +const PUNISH_HTML = + "" + + "" + + "
Проведите ползунок вправо
"; + +const PUNISH_JSON = JSON.stringify({ + ret: ["RGV587_ERROR:: Heavy traffic / risk control"], + success: false, + data: { url: "https://chat.qwen.ai/api/v2/chat/completions/_____tmd_____/punish?x5secdata=..." }, +}); + +function withEnv(patch, fn) { + const saved = {}; + for (const [k, v] of Object.entries(patch)) { + saved[k] = process.env[k]; + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + const restore = () => { + for (const [k, v] of Object.entries(saved)) { + if (v === undefined) delete process.env[k]; + else process.env[k] = v; + } + }; + let result; + try { + result = fn(); + } catch (err) { + restore(); + throw err; + } + // async-колбэк: восстанавливаем env только после его завершения + if (result && typeof result.finally === "function") { + return result.finally(restore); + } + restore(); + return result; +} + +describe("isQwenPunishResponse", () => { + it("распознаёт HTML punish-страницу (text/html + _____tmd_____/punish)", async () => { + assert.equal(isQwenPunishResponse({ contentType: "text/html", text: PUNISH_HTML }), true); + }); + + it("распознаёт JSON-заглушку RGV587 с data.url punish", async () => { + assert.equal(isQwenPunishResponse({ contentType: "application/json", text: PUNISH_JSON }), true); + }); + + it("не считает punish обычный SSE-стрим или HTML без маркеров TMD", async () => { + assert.equal(isQwenPunishResponse({ contentType: "text/event-stream", text: "data: {\"choices\":[]}" }), false); + assert.equal(isQwenPunishResponse({ contentType: "text/html", text: "login" }), false); + assert.equal(isQwenPunishResponse({ contentType: "application/json", text: "{\"ret\":[],\"success\":true}" }), false); + assert.equal(isQwenPunishResponse(null), false); + }); +}); + +describe("кулдаун после punish", () => { + beforeEach(() => resetQwenPacingStateForTests()); + + it("registerQwenPunish включает кулдаун и растит его экспоненциально с потолком", async () => { + withEnv({ QWEN_PUNISH_COOLDOWN_MS: "60000", QWEN_PUNISH_COOLDOWN_MAX_MS: "300000" }, async () => { + const first = registerQwenPunish(1_000); + assert.equal(first.punishStreak, 1); + assert.equal(first.backoffMs, 60_000); + assert.equal(qwenAntibotCooldownRemainingMs(1_000), 60_000); + assert.equal(qwenAntibotCooldownRemainingMs(30_000), 31_000); + + const second = registerQwenPunish(2_000); + assert.equal(second.backoffMs, 120_000); // 60s * 2^1 + const third = registerQwenPunish(3_000); + assert.equal(third.backoffMs, 240_000); // 60s * 2^2 + const fourth = registerQwenPunish(4_000); + assert.equal(fourth.backoffMs, 300_000); // capped + const fifth = registerQwenPunish(5_000); + assert.equal(fifth.backoffMs, 300_000); // всё ещё потолок + }); + }); + + it("успешный ответ сбрасывает streak и кулдаун", async () => { + registerQwenPunish(1_000); + registerQwenCompletionSuccess(2_000); + assert.equal(qwenAntibotCooldownRemainingMs(2_000), 0); + // следующая punish начинает с базового интервала, а не с удвоенного + const again = registerQwenPunish(3_000); + assert.equal(again.punishStreak, 1); + }); + + it("assertNoQwenAntibotCooldown кидает читаемую ошибку с кодом и секундами", async () => { + withEnv({ QWEN_PUNISH_COOLDOWN_MS: "90000" }, async () => { + registerQwenPunish(0); + assert.throws( + () => assertNoQwenAntibotCooldown(1_000), + (err) => err.code === QWEN_ANTIBOT_PUNISH && /89s|89 s/i.test(err.message) && err.cooldownRemainingMs === 89_000, + ); + // после истечения — не кидает + assert.equal(assertNoQwenAntibotCooldown(600_000), false); + }); + }); + + it("createQwenPunishError формирует сообщение с советом охлаждения", async () => { + const err = createQwenPunishError(125_000); + assert.equal(err.code, QWEN_ANTIBOT_PUNISH); + assert.match(err.message, /Baxia|антибот/i); + assert.match(err.message, /QWEN_COMPLETION_MIN_INTERVAL_MS/); + }); +}); + +describe("pacing: минимальный интервал между POST /completions", () => { + beforeEach(() => resetQwenPacingStateForTests()); + + it("первый слот ждёт 0, второй в пределах интервала — ждёт остаток", async () => { + withEnv({ QWEN_COMPLETION_MIN_INTERVAL_MS: "2000" }, async () => { + const sleeps = []; + const sleep = async (ms) => { sleeps.push(ms); }; + const first = await waitForQwenCompletionSlot({ now: 10_000, sleep }); + assert.equal(first, 0); + + const second = await waitForQwenCompletionSlot({ now: 11_000, sleep }); + assert.equal(second, 1_000); // 10_000 + 2_000 - 11_000 + assert.deepEqual(sleeps, [1_000]); + + // после паузы слот снова свободен + const third = await waitForQwenCompletionSlot({ now: 15_000, sleep }); + assert.equal(third, 0); + }); + }); + + it("резервирует слот на момент releasing, чтобы параллельные запросы сериализовались", async () => { + await withEnv({ QWEN_COMPLETION_MIN_INTERVAL_MS: "3000" }, async () => { + const sleep = async () => {}; + const a = waitForQwenCompletionSlot({ now: 0, sleep }); + const b = waitForQwenCompletionSlot({ now: 1_000, sleep }); + // слот A занял [0..0+3000), B стартовал в 1_000 → ждёт до 3_000 + await a; + const waited = await b; + assert.equal(waited, 2_000); + }); + }); + + it("конфиг читается из env с безопасными дефолтами", async () => { + withEnv({ QWEN_COMPLETION_MIN_INTERVAL_MS: undefined, QWEN_PUNISH_COOLDOWN_MS: undefined }, async () => { + const minIntervalMs = defaultMinIntervalMs(); + const punishCooldownMs = defaultPunishCooldownMs(); + assert.ok(minIntervalMs >= 1_000 && minIntervalMs <= 5_000); + assert.ok(punishCooldownMs >= 30_000); + }); + }); +}); From 341bf092f06edd8741e91a3ae99725277e0e5f78 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 21 Aug 2026 02:42:36 +0300 Subject: [PATCH 02/13] feat(qwen): context.txt file upload pipeline for oversized prompts - context-file.mjs: upload prompts >32KB to chat.qwen.ai as context.txt via /api/v1/files/getstsToken + multipart upload, then reference the file_id in the completion payload - completion-payload.mjs: accept files[] and attach uploaded file ids - observed in the wild: 133K-char agent prompts pass reliably through the file pipeline where inline text gets silently truncated --- .../src/providers/qwen/completion-payload.mjs | 3 +- .../src/providers/qwen/context-file.mjs | 261 ++++++++++++++++++ src/providers/qwen/completion-payload.mjs | 3 +- src/providers/qwen/context-file.mjs | 261 ++++++++++++++++++ test/qwen-context-file.test.mjs | 235 ++++++++++++++++ 5 files changed, 761 insertions(+), 2 deletions(-) create mode 100644 plugin-for-vscode/src/providers/qwen/context-file.mjs create mode 100644 src/providers/qwen/context-file.mjs create mode 100644 test/qwen-context-file.test.mjs diff --git a/plugin-for-vscode/src/providers/qwen/completion-payload.mjs b/plugin-for-vscode/src/providers/qwen/completion-payload.mjs index 90c4b97..a1ec16d 100644 --- a/plugin-for-vscode/src/providers/qwen/completion-payload.mjs +++ b/plugin-for-vscode/src/providers/qwen/completion-payload.mjs @@ -9,6 +9,7 @@ export function buildQwenCompletionPayload({ model, thinking = false, search = false, + files = null, } = {}) { const fid = randomUUID(); const assistantFid = randomUUID(); @@ -31,7 +32,7 @@ export function buildQwenCompletionPayload({ role: "user", content: String(prompt || ""), user_action: "chat", - files: [], + files: Array.isArray(files) && files.length ? files : [], timestamp, models: [model], chat_type: "t2t", diff --git a/plugin-for-vscode/src/providers/qwen/context-file.mjs b/plugin-for-vscode/src/providers/qwen/context-file.mjs new file mode 100644 index 0000000..fda78fd --- /dev/null +++ b/plugin-for-vscode/src/providers/qwen/context-file.mjs @@ -0,0 +1,261 @@ +// Загрузка большого контекста как файла-вложения в Qwen web (аналог вставки +// большого текста в поле ввода chat.qwen.ai, где фронтенд сам превращает его +// в Pasted_Text_.txt). +// +// Зачем: /api/v2/chat/completions при промпте свыше ~118-120k символов молча +// возвращает 380-байтную JSON-заглушку антибота Alibaba TMD (ret: +// FAIL_SYS_USER_VALIDATE / RGV587_ERROR, data.url = punish/captcha) вместо +// SSE-стрима. Веб-интерфейс обходит это, прикрепляя текст файлом. +// +// Пайплайн (захвачен из HAR веб-интерфейса, 2026-08-20): +// 1. POST /api/v2/files/getstsToken { filename, filesize: "717775", filetype: "file" } +// -> data: { access_key_id, access_key_secret, security_token, bucketname, +// region, endpoint, file_id, file_path, file_url } +// 2. PUT https://./ (OSS, подпись HMAC-SHA1) +// 3. POST /api/v2/files/parse { file_id } +// 4. POST /api/v2/files/parse/status { file_id_list: [file_id] } -> status: success +// 5. messages[0].files = [attachment] (объект ниже, включая context: "full") +// +// Шаги 1/3/4 — same-origin, выполняются через page.evaluate внутри страницы +// chat.qwen.ai (bx-ua подписывается их JS-бандлом автоматически). Шаг 2 — PUT +// на другой домен (oss-accelerate.aliyuncs.com) из Node: браузерный fetch не +// может устанавливать заголовок Date, а OSS PUT не требует bx-ua. + +import { createHmac, randomUUID } from "node:crypto"; + +export const QWEN_CONTEXT_FILE_DEFAULTS = Object.freeze({ + thresholdChars: 100_000, + inlineChars: 50_000, +}); + +export function resolveQwenContextFileConfig(env = process.env) { + return { + thresholdChars: Number(env.QWEN_CONTEXT_FILE_THRESHOLD || QWEN_CONTEXT_FILE_DEFAULTS.thresholdChars), + inlineChars: Number(env.QWEN_CONTEXT_FILE_INLINE_CHARS || QWEN_CONTEXT_FILE_DEFAULTS.inlineChars), + }; +} + +const SEGMENT_SEPARATOR = /\n\n---\n\n/; + +// Разделяет промпт на инлайн-часть (инструкции инструментов + последние +// сообщения + заметка о файле) и файловую часть (старая история диалога). +// Возвращает null, если промпт ниже порога — файл не нужен. +export function splitPromptForFileUpload(prompt, config = resolveQwenContextFileConfig()) { + const text = String(prompt || ""); + if (text.length <= config.thresholdChars) return null; + + const parts = text.split(SEGMENT_SEPARATOR); + const head = parts[0] || ""; // [TOOL INSTRUCTIONS ...] — всегда инлайн + const segments = parts.slice(1); + + // Промпт без разделителей (один гигантский блоб, например вставленный + // документ): делим по символам — начало в файл, хвост инлайн. + if (segments.length === 0) { + const giant = head; + const keep = Math.max(0, Math.min(giant.length, config.inlineChars)); + const fileText = giant.slice(0, giant.length - keep); + if (!fileText.trim()) return null; + const note = + `\n\n---\n[CONTEXT FILE]: Начальная часть этого сообщения (${fileText.length} символов) ` + + `прикреплена как текстовый файл "context.txt" в списке вложений (files). ` + + `Используй его содержимое как основную часть запроса.`; + const inlineTail = giant.slice(giant.length - keep); + return { + inline: inlineTail + note, + fileText, + fileChars: fileText.length, + inlineChars: inlineTail.length + note.length, + }; + } + + // Собираем «хвост» (самые свежие сообщения), идя с конца, пока влезает. + let inlineLen = head.length; + let splitIdx = segments.length; + for (let i = segments.length - 1; i >= 0; i -= 1) { + const segLen = segments[i].length + SEGMENT_SEPARATOR.source.length; + if (inlineLen + segLen <= config.inlineChars) { + inlineLen += segLen; + splitIdx = i; + } else { + break; + } + } + + let fileSegments; + let inlineSegments; + if (splitIdx === 0) { + // Хвост съел всё: делим по символам внутри одного гигантского сегмента. + const giant = segments[0] || ""; + const keep = Math.max(0, Math.min(giant.length, config.inlineChars - head.length)); + fileSegments = [giant.slice(0, giant.length - keep)]; + inlineSegments = keep > 0 ? [giant.slice(giant.length - keep)] : []; + // Если после сплита файл пуст (порог меньше inline), файл не нужен. + if (!fileSegments[0].length) return null; + } else { + fileSegments = segments.slice(0, splitIdx); + inlineSegments = segments.slice(splitIdx); + } + + const fileText = fileSegments.join("\n\n---\n\n"); + if (!fileText.trim()) return null; + + const note = + `\n\n---\n[CONTEXT FILE]: Более ранняя часть этого разговора (${fileText.length} символов) ` + + `прикреплена к сообщению как текстовый файл "context.txt" в списке вложений (files). ` + + `Используй его содержимое как обычную историю диалога.`; + + const inline = [head, ...inlineSegments].join("\n\n---\n\n") + note; + + return { + inline, + fileText, + fileChars: fileText.length, + inlineChars: inline.length, + }; +} + +// Объект вложения — форма из HAR веб-интерфейса Qwen (files[] в completions). +export function buildQwenFileAttachment(sts, fileName, fileSize) { + const userId = String(sts.file_path || "").split("/")[0] || ""; + const now = Date.now(); + const meta = { + name: fileName, + size: fileSize, + content_type: "text/plain", + parse_meta: { parse_status: "success" }, + }; + const file = { + created_at: now, + data: {}, + filename: fileName, + hash: null, + id: sts.file_id, + user_id: userId, + meta, + update_at: now, + lastModified: now, + name: fileName, + webkitRelativePath: "", + size: fileSize, + type: "text/plain", + }; + return { + type: "file", + file, + id: sts.file_id, + url: sts.file_url, + name: fileName, + collection_name: "", + progress: 0, + status: "uploaded", + greenNet: "success", + size: fileSize, + error: "", + itemId: randomUUID(), + file_type: "text/plain", + showType: "file", + file_class: "default", + context: "full", + uploadTaskId: randomUUID(), + }; +} + +function hmacSha1Base64(key, message) { + return createHmac("sha1", key).update(message).digest("base64"); +} + +function buildOssCanonicalRequest(method, contentType, date, securityToken, bucket, objectKey) { + return [ + method, + "", + contentType, + date, + `x-oss-security-token:${securityToken}`, + `/${bucket}/${objectKey}`, + ].join("\n"); +} + +function buildOssUploadUrl(sts) { + let endpoint = String(sts.endpoint || "").replace(/\/+$/, ""); + if (!endpoint.includes(String(sts.bucketname))) { + endpoint = `https://${sts.bucketname}.${endpoint.replace(/^https?:\/\//, "")}`; + } + let objectKey = String(sts.file_path || ""); + const bucketPrefix = `${sts.bucketname}/`; + if (objectKey.startsWith(bucketPrefix)) objectKey = objectKey.slice(bucketPrefix.length); + return { uploadUrl: `${endpoint}/${objectKey}`, objectKey }; +} + +// Полный пайплайн загрузки. proxyApiPost выполняет same-origin POST из +// контекста страницы и возвращает { ok, status, json } с УЖЕ распарсенным телом; +// fetchImpl — обычный Node fetch для OSS PUT. +export async function uploadQwenContextFile({ + proxyApiPost, + fetchImpl = fetch, + content, + now = Date.now, + pollTimeoutMs = 15_000, + pollIntervalMs = 1_000, +}) { + const text = String(content || ""); + const buffer = Buffer.from(text, "utf8"); + const fileName = `Pasted_Text_${now()}.txt`; + + // 1. STS-токен (filesize строкой — так шлёт веб-интерфейс). + // proxyApiPost возвращает { ok, status, json } где json — УЖЕ распарсенное + // тело (page.evaluate не переносит функции через границу Playwright). + const stsRes = await proxyApiPost("/api/v2/files/getstsToken", { + filename: fileName, + filesize: String(buffer.length), + filetype: "file", + }); + if (!stsRes || !stsRes.ok) { + throw new Error(`qwen context-file: getstsToken failed (${stsRes ? stsRes.status : "no response"})`); + } + const sts = stsRes.json?.data; + if (!sts || !sts.file_id) throw new Error("qwen context-file: getstsToken returned no file_id"); + + // 2. OSS PUT. + const date = new Date(now()).toUTCString(); + const contentType = "text/plain"; + const { uploadUrl, objectKey } = buildOssUploadUrl(sts); + const canonical = buildOssCanonicalRequest("PUT", contentType, date, sts.security_token, sts.bucketname, objectKey); + const signature = hmacSha1Base64(sts.access_key_secret, canonical); + const putRes = await fetchImpl(uploadUrl, { + method: "PUT", + headers: { + "Content-Type": contentType, + Date: date, + Authorization: `OSS ${sts.access_key_id}:${signature}`, + "x-oss-security-token": sts.security_token, + }, + body: buffer, + }); + if (!putRes.ok) { + const errText = await putRes.text().catch(() => ""); + throw new Error(`qwen context-file: OSS PUT failed ${putRes.status} — ${errText.slice(0, 200)}`); + } + + // 3. Запуск серверного парсинга. + const parseRes = await proxyApiPost("/api/v2/files/parse", { file_id: sts.file_id }); + if (!parseRes || !parseRes.ok) { + throw new Error(`qwen context-file: parse failed (${parseRes ? parseRes.status : "no response"})`); + } + + // 4. Поллинг статуса. Таймаут НЕ фатален: сервер доделает парсинг асинхронно. + const deadline = now() + pollTimeoutMs; + for (;;) { + const statusRes = await proxyApiPost("/api/v2/files/parse/status", { file_id_list: [sts.file_id] }); + if (statusRes && statusRes.ok) { + const data = statusRes.json; + const status = data?.data?.[0]?.status || data?.status; + if (status === "success") break; + if (status === "failed") throw new Error(`qwen context-file: server-side parse failed for ${sts.file_id}`); + } + if (now() >= deadline) break; // proceed anyway — parsing finishes async + await new Promise((r) => setTimeout(r, pollIntervalMs)); + } + + // 5. Объект вложения. + return buildQwenFileAttachment(sts, fileName, buffer.length); +} diff --git a/src/providers/qwen/completion-payload.mjs b/src/providers/qwen/completion-payload.mjs index 90c4b97..a1ec16d 100644 --- a/src/providers/qwen/completion-payload.mjs +++ b/src/providers/qwen/completion-payload.mjs @@ -9,6 +9,7 @@ export function buildQwenCompletionPayload({ model, thinking = false, search = false, + files = null, } = {}) { const fid = randomUUID(); const assistantFid = randomUUID(); @@ -31,7 +32,7 @@ export function buildQwenCompletionPayload({ role: "user", content: String(prompt || ""), user_action: "chat", - files: [], + files: Array.isArray(files) && files.length ? files : [], timestamp, models: [model], chat_type: "t2t", diff --git a/src/providers/qwen/context-file.mjs b/src/providers/qwen/context-file.mjs new file mode 100644 index 0000000..fda78fd --- /dev/null +++ b/src/providers/qwen/context-file.mjs @@ -0,0 +1,261 @@ +// Загрузка большого контекста как файла-вложения в Qwen web (аналог вставки +// большого текста в поле ввода chat.qwen.ai, где фронтенд сам превращает его +// в Pasted_Text_.txt). +// +// Зачем: /api/v2/chat/completions при промпте свыше ~118-120k символов молча +// возвращает 380-байтную JSON-заглушку антибота Alibaba TMD (ret: +// FAIL_SYS_USER_VALIDATE / RGV587_ERROR, data.url = punish/captcha) вместо +// SSE-стрима. Веб-интерфейс обходит это, прикрепляя текст файлом. +// +// Пайплайн (захвачен из HAR веб-интерфейса, 2026-08-20): +// 1. POST /api/v2/files/getstsToken { filename, filesize: "717775", filetype: "file" } +// -> data: { access_key_id, access_key_secret, security_token, bucketname, +// region, endpoint, file_id, file_path, file_url } +// 2. PUT https://./ (OSS, подпись HMAC-SHA1) +// 3. POST /api/v2/files/parse { file_id } +// 4. POST /api/v2/files/parse/status { file_id_list: [file_id] } -> status: success +// 5. messages[0].files = [attachment] (объект ниже, включая context: "full") +// +// Шаги 1/3/4 — same-origin, выполняются через page.evaluate внутри страницы +// chat.qwen.ai (bx-ua подписывается их JS-бандлом автоматически). Шаг 2 — PUT +// на другой домен (oss-accelerate.aliyuncs.com) из Node: браузерный fetch не +// может устанавливать заголовок Date, а OSS PUT не требует bx-ua. + +import { createHmac, randomUUID } from "node:crypto"; + +export const QWEN_CONTEXT_FILE_DEFAULTS = Object.freeze({ + thresholdChars: 100_000, + inlineChars: 50_000, +}); + +export function resolveQwenContextFileConfig(env = process.env) { + return { + thresholdChars: Number(env.QWEN_CONTEXT_FILE_THRESHOLD || QWEN_CONTEXT_FILE_DEFAULTS.thresholdChars), + inlineChars: Number(env.QWEN_CONTEXT_FILE_INLINE_CHARS || QWEN_CONTEXT_FILE_DEFAULTS.inlineChars), + }; +} + +const SEGMENT_SEPARATOR = /\n\n---\n\n/; + +// Разделяет промпт на инлайн-часть (инструкции инструментов + последние +// сообщения + заметка о файле) и файловую часть (старая история диалога). +// Возвращает null, если промпт ниже порога — файл не нужен. +export function splitPromptForFileUpload(prompt, config = resolveQwenContextFileConfig()) { + const text = String(prompt || ""); + if (text.length <= config.thresholdChars) return null; + + const parts = text.split(SEGMENT_SEPARATOR); + const head = parts[0] || ""; // [TOOL INSTRUCTIONS ...] — всегда инлайн + const segments = parts.slice(1); + + // Промпт без разделителей (один гигантский блоб, например вставленный + // документ): делим по символам — начало в файл, хвост инлайн. + if (segments.length === 0) { + const giant = head; + const keep = Math.max(0, Math.min(giant.length, config.inlineChars)); + const fileText = giant.slice(0, giant.length - keep); + if (!fileText.trim()) return null; + const note = + `\n\n---\n[CONTEXT FILE]: Начальная часть этого сообщения (${fileText.length} символов) ` + + `прикреплена как текстовый файл "context.txt" в списке вложений (files). ` + + `Используй его содержимое как основную часть запроса.`; + const inlineTail = giant.slice(giant.length - keep); + return { + inline: inlineTail + note, + fileText, + fileChars: fileText.length, + inlineChars: inlineTail.length + note.length, + }; + } + + // Собираем «хвост» (самые свежие сообщения), идя с конца, пока влезает. + let inlineLen = head.length; + let splitIdx = segments.length; + for (let i = segments.length - 1; i >= 0; i -= 1) { + const segLen = segments[i].length + SEGMENT_SEPARATOR.source.length; + if (inlineLen + segLen <= config.inlineChars) { + inlineLen += segLen; + splitIdx = i; + } else { + break; + } + } + + let fileSegments; + let inlineSegments; + if (splitIdx === 0) { + // Хвост съел всё: делим по символам внутри одного гигантского сегмента. + const giant = segments[0] || ""; + const keep = Math.max(0, Math.min(giant.length, config.inlineChars - head.length)); + fileSegments = [giant.slice(0, giant.length - keep)]; + inlineSegments = keep > 0 ? [giant.slice(giant.length - keep)] : []; + // Если после сплита файл пуст (порог меньше inline), файл не нужен. + if (!fileSegments[0].length) return null; + } else { + fileSegments = segments.slice(0, splitIdx); + inlineSegments = segments.slice(splitIdx); + } + + const fileText = fileSegments.join("\n\n---\n\n"); + if (!fileText.trim()) return null; + + const note = + `\n\n---\n[CONTEXT FILE]: Более ранняя часть этого разговора (${fileText.length} символов) ` + + `прикреплена к сообщению как текстовый файл "context.txt" в списке вложений (files). ` + + `Используй его содержимое как обычную историю диалога.`; + + const inline = [head, ...inlineSegments].join("\n\n---\n\n") + note; + + return { + inline, + fileText, + fileChars: fileText.length, + inlineChars: inline.length, + }; +} + +// Объект вложения — форма из HAR веб-интерфейса Qwen (files[] в completions). +export function buildQwenFileAttachment(sts, fileName, fileSize) { + const userId = String(sts.file_path || "").split("/")[0] || ""; + const now = Date.now(); + const meta = { + name: fileName, + size: fileSize, + content_type: "text/plain", + parse_meta: { parse_status: "success" }, + }; + const file = { + created_at: now, + data: {}, + filename: fileName, + hash: null, + id: sts.file_id, + user_id: userId, + meta, + update_at: now, + lastModified: now, + name: fileName, + webkitRelativePath: "", + size: fileSize, + type: "text/plain", + }; + return { + type: "file", + file, + id: sts.file_id, + url: sts.file_url, + name: fileName, + collection_name: "", + progress: 0, + status: "uploaded", + greenNet: "success", + size: fileSize, + error: "", + itemId: randomUUID(), + file_type: "text/plain", + showType: "file", + file_class: "default", + context: "full", + uploadTaskId: randomUUID(), + }; +} + +function hmacSha1Base64(key, message) { + return createHmac("sha1", key).update(message).digest("base64"); +} + +function buildOssCanonicalRequest(method, contentType, date, securityToken, bucket, objectKey) { + return [ + method, + "", + contentType, + date, + `x-oss-security-token:${securityToken}`, + `/${bucket}/${objectKey}`, + ].join("\n"); +} + +function buildOssUploadUrl(sts) { + let endpoint = String(sts.endpoint || "").replace(/\/+$/, ""); + if (!endpoint.includes(String(sts.bucketname))) { + endpoint = `https://${sts.bucketname}.${endpoint.replace(/^https?:\/\//, "")}`; + } + let objectKey = String(sts.file_path || ""); + const bucketPrefix = `${sts.bucketname}/`; + if (objectKey.startsWith(bucketPrefix)) objectKey = objectKey.slice(bucketPrefix.length); + return { uploadUrl: `${endpoint}/${objectKey}`, objectKey }; +} + +// Полный пайплайн загрузки. proxyApiPost выполняет same-origin POST из +// контекста страницы и возвращает { ok, status, json } с УЖЕ распарсенным телом; +// fetchImpl — обычный Node fetch для OSS PUT. +export async function uploadQwenContextFile({ + proxyApiPost, + fetchImpl = fetch, + content, + now = Date.now, + pollTimeoutMs = 15_000, + pollIntervalMs = 1_000, +}) { + const text = String(content || ""); + const buffer = Buffer.from(text, "utf8"); + const fileName = `Pasted_Text_${now()}.txt`; + + // 1. STS-токен (filesize строкой — так шлёт веб-интерфейс). + // proxyApiPost возвращает { ok, status, json } где json — УЖЕ распарсенное + // тело (page.evaluate не переносит функции через границу Playwright). + const stsRes = await proxyApiPost("/api/v2/files/getstsToken", { + filename: fileName, + filesize: String(buffer.length), + filetype: "file", + }); + if (!stsRes || !stsRes.ok) { + throw new Error(`qwen context-file: getstsToken failed (${stsRes ? stsRes.status : "no response"})`); + } + const sts = stsRes.json?.data; + if (!sts || !sts.file_id) throw new Error("qwen context-file: getstsToken returned no file_id"); + + // 2. OSS PUT. + const date = new Date(now()).toUTCString(); + const contentType = "text/plain"; + const { uploadUrl, objectKey } = buildOssUploadUrl(sts); + const canonical = buildOssCanonicalRequest("PUT", contentType, date, sts.security_token, sts.bucketname, objectKey); + const signature = hmacSha1Base64(sts.access_key_secret, canonical); + const putRes = await fetchImpl(uploadUrl, { + method: "PUT", + headers: { + "Content-Type": contentType, + Date: date, + Authorization: `OSS ${sts.access_key_id}:${signature}`, + "x-oss-security-token": sts.security_token, + }, + body: buffer, + }); + if (!putRes.ok) { + const errText = await putRes.text().catch(() => ""); + throw new Error(`qwen context-file: OSS PUT failed ${putRes.status} — ${errText.slice(0, 200)}`); + } + + // 3. Запуск серверного парсинга. + const parseRes = await proxyApiPost("/api/v2/files/parse", { file_id: sts.file_id }); + if (!parseRes || !parseRes.ok) { + throw new Error(`qwen context-file: parse failed (${parseRes ? parseRes.status : "no response"})`); + } + + // 4. Поллинг статуса. Таймаут НЕ фатален: сервер доделает парсинг асинхронно. + const deadline = now() + pollTimeoutMs; + for (;;) { + const statusRes = await proxyApiPost("/api/v2/files/parse/status", { file_id_list: [sts.file_id] }); + if (statusRes && statusRes.ok) { + const data = statusRes.json; + const status = data?.data?.[0]?.status || data?.status; + if (status === "success") break; + if (status === "failed") throw new Error(`qwen context-file: server-side parse failed for ${sts.file_id}`); + } + if (now() >= deadline) break; // proceed anyway — parsing finishes async + await new Promise((r) => setTimeout(r, pollIntervalMs)); + } + + // 5. Объект вложения. + return buildQwenFileAttachment(sts, fileName, buffer.length); +} diff --git a/test/qwen-context-file.test.mjs b/test/qwen-context-file.test.mjs new file mode 100644 index 0000000..b43a602 --- /dev/null +++ b/test/qwen-context-file.test.mjs @@ -0,0 +1,235 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + resolveQwenContextFileConfig, + splitPromptForFileUpload, + buildQwenFileAttachment, + uploadQwenContextFile, +} from "../src/providers/qwen/context-file.mjs"; +import { buildQwenCompletionPayload } from "../src/providers/qwen/completion-payload.mjs"; +import { formatQwenStreamError } from "../src/providers/qwen/client.mjs"; + +// --- helpers --------------------------------------------------------------- + +const SEP = "\n\n---\n\n"; + +function makePrompt(segments) { + return segments.join(SEP); +} + +function fakeSts(overrides = {}) { + return { + access_key_id: "STS.testkey", + access_key_secret: "testsecret", + security_token: "test-token", + bucketname: "qwen-webui-prod", + region: "oss-ap-southeast-1", + endpoint: "https://oss-accelerate.aliyuncs.com", + file_id: "8178ea3e-e17c-4ee5-ab9e-96180361507d", + file_path: "361c7133-1db9-44b7-a17a-d7a1a9564f22/8178ea3e-e17c-4ee5-ab9e-96180361507d_Pasted_Text_1787236266249.txt", + file_url: "https://qwen-webui-prod.oss-accelerate.aliyuncs.com/361c7133/8178ea3e.txt?x-oss-signature=abc", + ...overrides, + }; +} + +// --- config ----------------------------------------------------------------- + +describe("qwen context-file config", () => { + it("defaults threshold/inline for the observed anti-bot limit", () => { + const cfg = resolveQwenContextFileConfig({}); + assert.equal(cfg.thresholdChars, 100_000); + assert.equal(cfg.inlineChars, 50_000); + }); + + it("keeps environment overrides", () => { + const cfg = resolveQwenContextFileConfig({ + QWEN_CONTEXT_FILE_THRESHOLD: "90000", + QWEN_CONTEXT_FILE_INLINE_CHARS: "20000", + }); + assert.equal(cfg.thresholdChars, 90_000); + assert.equal(cfg.inlineChars, 20_000); + }); +}); + +// --- split ------------------------------------------------------------------ + +describe("qwen context-file split", () => { + const cfg = { thresholdChars: 1000, inlineChars: 400 }; + + it("returns null below threshold", () => { + const prompt = makePrompt(["[TOOL INSTRUCTIONS]...", "[USER]: hi"]); + assert.equal(splitPromptForFileUpload(prompt, cfg), null); + }); + + it("keeps tool instructions head and recent tail inline, older middle goes to file", () => { + const head = "[TOOL INSTRUCTIONS]".padEnd(50, "H"); + const old1 = "[USER]: old question 1".padEnd(350, "1"); + const old2 = "[ASSISTANT]: old answer".padEnd(350, "2"); + const old3 = "[USER]: old question 3".padEnd(350, "3"); + const recent = "[USER]: recent question".padEnd(150, "R"); + const prompt = makePrompt([head, old1, old2, old3, recent]); // ~1300 > 1000 + + const split = splitPromptForFileUpload(prompt, cfg); + assert.ok(split, "split must happen above threshold"); + // Head always stays inline (format discipline). + assert.ok(split.inline.startsWith(head), "tool instructions must stay inline"); + // Recent tail stays inline. + assert.ok(split.inline.includes(recent), "recent segment must stay inline"); + // Old segments moved to file. + assert.ok(split.fileText.includes(old1) || split.fileText.includes(old2), "old segments must move to the file"); + assert.ok(!split.inline.includes(old1), "old segment must NOT stay inline"); + // Inline stays within budget (head + tail + note). + assert.ok(split.inline.length < head.length + recent.length + 600, `inline too big: ${split.inline.length}`); + // Note tells the model about the attachment. + assert.match(split.inline, /context\.txt/); + assert.ok(split.fileChars > 0); + }); + + it("falls back to a character split for one giant segment", () => { + const giant = "X".repeat(5000); // single segment, no separators + const split = splitPromptForFileUpload(giant, cfg); + assert.ok(split); + assert.ok(split.inline.length <= cfg.inlineChars + 600, `inline too big: ${split.inline.length}`); + assert.ok(split.fileChars > 3000); + // Reassembly keeps content: file + inline cover the giant minus note overhead. + assert.ok(split.fileText.includes("X".repeat(100))); + }); +}); + +// --- attachment shape (from real web-UI HAR capture) ------------------------- + +describe("qwen context-file attachment", () => { + it("matches the web UI files[] object shape", () => { + const sts = fakeSts(); + const att = buildQwenFileAttachment(sts, "Pasted_Text_1787236266249.txt", 717_775); + assert.equal(att.type, "file"); + assert.equal(att.file.id, sts.file_id); + assert.equal(att.id, sts.file_id); + assert.equal(att.url, sts.file_url); + assert.equal(att.file.user_id, "361c7133-1db9-44b7-a17a-d7a1a9564f22"); + assert.equal(att.file.meta.content_type, "text/plain"); + assert.equal(att.file.meta.parse_meta.parse_status, "success"); + assert.equal(att.file.size, 717_775); + assert.equal(att.size, 717_775); + assert.equal(att.file_class, "default"); + assert.equal(att.context, "full"); + assert.equal(att.showType, "file"); + assert.equal(att.status, "uploaded"); + assert.equal(att.greenNet, "success"); + assert.equal(att.progress, 0); + assert.equal(att.error, ""); + assert.ok(att.itemId); + assert.ok(att.uploadTaskId); + assert.equal(att.file.webkitRelativePath, ""); + }); +}); + +// --- upload orchestrator ----------------------------------------------------- + +describe("qwen context-file upload pipeline", () => { + it("runs sts -> oss put -> parse -> poll and returns the attachment", async () => { + const sts = fakeSts(); + const calls = []; + const proxyApiPost = async (path, body) => { + calls.push({ path, body }); + if (path === "/api/v2/files/getstsToken") return { ok: true, status: 200, json: { data: sts } }; + if (path === "/api/v2/files/parse") return { ok: true, status: 200, json: {} }; + if (path === "/api/v2/files/parse/status") return { ok: true, status: 200, json: { data: [{ status: "success" }] } }; + throw new Error("unexpected path " + path); + }; + const ossPuts = []; + const fetchImpl = async (url, init) => { + ossPuts.push({ url, init }); + return { ok: true, status: 200, statusText: "OK", text: async () => "" }; + }; + + const att = await uploadQwenContextFile({ + proxyApiPost, + fetchImpl, + content: "hello context", + now: () => 1_787_236_266_249, + }); + + // STS requested with the web UI body shape. + assert.equal(calls[0].path, "/api/v2/files/getstsToken"); + assert.equal(calls[0].body.filetype, "file"); + assert.equal(typeof calls[0].body.filesize, "string"); + assert.match(calls[0].body.filename, /^Pasted_Text_\d+\.txt$/); + + // OSS PUT to bucket endpoint with Authorization: OSS :. + assert.equal(ossPuts.length, 1); + assert.ok(ossPuts[0].url.startsWith("https://qwen-webui-prod.oss-accelerate.aliyuncs.com/")); + assert.equal(ossPuts[0].init.method, "PUT"); + assert.match(ossPuts[0].init.headers.Authorization, /^OSS STS\.testkey:/); + assert.equal(ossPuts[0].init.headers["x-oss-security-token"], "test-token"); + + // parse + status polled. + assert.ok(calls.some((c) => c.path === "/api/v2/files/parse")); + assert.ok(calls.some((c) => c.path === "/api/v2/files/parse/status")); + + assert.equal(att.id, sts.file_id); + assert.equal(att.context, "full"); + }); + + it("proceeds after parse poll timeout (server finishes parsing async)", async () => { + const sts = fakeSts(); + let statusCalls = 0; + const proxyApiPost = async (path) => { + if (path === "/api/v2/files/getstsToken") return { ok: true, status: 200, json: { data: sts } }; + if (path === "/api/v2/files/parse") return { ok: true, status: 200, json: {} }; + if (path === "/api/v2/files/parse/status") { statusCalls += 1; return { ok: true, status: 200, json: { data: [{ status: "running" }] } }; } + throw new Error("unexpected " + path); + }; + const att = await uploadQwenContextFile({ + proxyApiPost, + fetchImpl: async () => ({ ok: true, status: 200, text: async () => "" }), + content: "x", + pollTimeoutMs: 10, + pollIntervalMs: 4, + }); + assert.equal(att.id, sts.file_id); + assert.ok(statusCalls >= 2); + }); +}); + +// --- payload integration ------------------------------------------------------ + +describe("qwen completion payload files", () => { + it("places provided files into the user message", () => { + const file = { type: "file", id: "abc", context: "full" }; + const payload = buildQwenCompletionPayload({ + chatId: "chat-1", + prompt: "inline question", + model: "qwen3.7-plus", + files: [file], + }); + assert.deepEqual(payload.messages[0].files, [file]); + assert.equal(payload.messages[0].content, "inline question"); + }); + + it("defaults to empty files array", () => { + const payload = buildQwenCompletionPayload({ chatId: "c", prompt: "p", model: "m" }); + assert.deepEqual(payload.messages[0].files, []); + }); +}); + +// --- punish stub detection ----------------------------------------------------- + +describe("qwen punish stub error formatting", () => { + it("recognizes the RGV587 anti-bot stub returned for oversized prompts", () => { + const stub = { + ret: ["FAIL_SYS_USER_VALIDATE", "RGV587_ERROR::SM::哎哟喂,被挤爆啦,请稍后重试"], + data: { + url: "https://chat.qwen.ai:443//api/v2/chat/completions/_____tmd_____/punish?x5secdata=abc&x5step=2&action=captcha&pureCaptcha=", + }, + }; + const msg = formatQwenStreamError(stub); + assert.ok(msg, "stub must be recognized"); + assert.match(msg, /anti-bot|RGV587/i); + assert.match(msg, /context|меньше|file/i); + }); + + it("still ignores normal payloads without ret/punish markers", () => { + assert.equal(formatQwenStreamError({ data: { url: "https://example.com/ok" } }), null); + }); +}); From 0123f30c7d7a4203d3d322df7563e17d312297c6 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 21 Aug 2026 02:42:37 +0300 Subject: [PATCH 03/13] fix(api): repair unescaped quotes and invalid escapes in tool-call JSON Qwen regularly emits shell commands with quoted arguments inside "command" without JSON escaping (observed 2026-08-21, e.g. "grep -n "pattern" file"). JSON.parse fails, the raw block leaks into chat as [Error parsing tool call JSON from model]. - escapeUnescapedInnerQuotes(): state machine that escapes a quote inside a string when the next non-space char is not a JSON structural char (, : } ] or EOF); also doubles invalid escapes like \| which the model uses for grep regexes - wired into parseCallsJson (fenced path) and the streaming salvage chain in openai-handler: strict -> brace fix -> QUOTE-ESCAPE FIX -> truncation repair (log: 'after quote-escape fix') - tests use the exact payloads observed in the wild --- api/openai-handler.mjs | 35 ++++- api/tool-calls.mjs | 168 ++++++++++++++++++++++- plugin-for-vscode/api/openai-handler.mjs | 35 ++++- plugin-for-vscode/api/tool-calls.mjs | 168 ++++++++++++++++++++++- test/qwen-unescaped-quotes.test.mjs | 78 +++++++++++ 5 files changed, 472 insertions(+), 12 deletions(-) create mode 100644 test/qwen-unescaped-quotes.test.mjs diff --git a/api/openai-handler.mjs b/api/openai-handler.mjs index 19d7e65..3c3599d 100644 --- a/api/openai-handler.mjs +++ b/api/openai-handler.mjs @@ -27,7 +27,7 @@ import { getQwenLiveCatalogOverride } from "../src/providers/qwen/model-sync.mjs import { DEFAULT_AUTH_FILE } from "../src/config.mjs"; import { readSavedAuth } from "../src/auth/files.mjs"; import { DeepSeekChatClient } from "../src/providers/deepseek/client.mjs"; -import { extractBareToolCalls, formatCompactTools, normalizeToolCallsForSchemas, parseModelToolCalls } from "./tool-calls.mjs"; +import { extractBareToolCalls, formatCompactTools, normalizeToolCallsForSchemas, parseModelToolCalls, repairTruncatedToolCallJson, escapeUnescapedInnerQuotes } from "./tool-calls.mjs"; import { readChatGPTAuth } from "../src/providers/chatgpt/auth-files.mjs"; import { CHATGPT_AUTH_FILE } from "../src/providers/chatgpt/config.mjs"; import { ChatGPTChatClient } from "../src/providers/chatgpt/client.mjs"; @@ -975,6 +975,9 @@ export async function handleQwenStream(client, chatId, prompt, modelName, model, } catch (error) { lastError = error; if (error?.code === "EMPTY_UPSTREAM_STREAM") throw error; + // Baxia punish (антибот-капча): кулдаун активен, слепой retry + // с пересозданием чата только сильнее разгоняет скоринг. + if (error?.code === "QWEN_ANTIBOT_PUNISH") throw error; if (sawDelta || attempt >= 1 || typeof refreshClient !== "function") throw error; logQwenTiming(requestId, "retry_before_first_delta", { attempt: attempt + 1, @@ -1587,11 +1590,39 @@ export class StreamParser { calls = calls.flat(Infinity); console.log(`[API] Parsed streaming tool calls (after brace fix): ${calls.length}`); - const sent = this.sendToolCalls(calls); if (sent > 0) finishReason = "tool_calls"; else this.sendChunk({ content: "[Error] Upstream model returned an empty tool call. Retry the request." }); } catch (e2) { + // Попытка 2: неэкранированные кавычки внутри строк (Qwen кладёт + // shell-команды с quoted-аргументами в "command" без экранирования). + try { + const quotesFixed = escapeUnescapedInnerQuotes(jsonStr); + let calls = JSON.parse(quotesFixed); + if (!Array.isArray(calls)) calls = [calls]; + calls = calls.flat(Infinity); + console.log(`[API] Parsed streaming tool calls (after quote-escape fix): ${calls.length}`); + const sent = this.sendToolCalls(calls); + if (sent > 0) finishReason = "tool_calls"; + else this.sendChunk({ content: "[Error] Upstream model returned an empty tool call. Retry the request." }); + this.sendTerminalChunk(finishReason); + return; + } catch {} + // Попытка 3: модель могла упереться в лимит выходных токенов + // посреди блока — JSON обрезан, но стрим завершился штатно. Дописываем + // незакрытые строки/скобки и парсим salvaged-вызовы. + try { + const truncFixed = escapeUnescapedInnerQuotes(repairTruncatedToolCallJson(jsonStr)); + let calls = JSON.parse(truncFixed); + if (!Array.isArray(calls)) calls = [calls]; + calls = calls.flat(Infinity); + console.log(`[API] Parsed streaming tool calls (after truncation repair): ${calls.length}`); + const sent = this.sendToolCalls(calls); + if (sent > 0) finishReason = "tool_calls"; + else this.sendChunk({ content: "[Error] Upstream model returned an empty tool call. Retry the request." }); + this.sendTerminalChunk(finishReason); + return; + } catch {} console.error("[API] Error parsing tool calls from streaming response:", e2.message); fs.writeFileSync("/tmp/failed_json.txt", jsonStr); console.error("[API] Problematic JSON string was:\n", JSON.stringify(jsonStr)); // Fallback: send as normal text so the UI doesn't hang completely diff --git a/api/tool-calls.mjs b/api/tool-calls.mjs index 2b10b42..f88e4b2 100644 --- a/api/tool-calls.mjs +++ b/api/tool-calls.mjs @@ -82,13 +82,173 @@ function extractToolCallsBlock(source) { } function parseCallsJson(jsonStr) { + const attempts = [jsonStr, escapeUnescapedInnerQuotes(jsonStr)]; + for (const attempt of attempts) { + try { + const parsed = JSON.parse(attempt); + const list = Array.isArray(parsed) ? parsed : [parsed]; + const calls = list.map(normalizeCall).filter(Boolean); + if (calls.length) return calls; + } catch { /* next attempt */ } + } + return []; +} + +// Ремонт обрезанного tool-call JSON: модель упёрлась в лимит выходных токенов +// посреди блока ```tool_calls — стрим завершается штатно (stream_done), но +// массив не закрыт. Дописываем незакрытую строку/скобки, чтобы спасти вызов. +export function repairTruncatedToolCallJson(jsonStr) { + let s = String(jsonStr || "").trim(); + if (!s) return s; + // Быстрая проверка: если уже валиден — не трогаем. + try { + JSON.parse(s); + return s; + } catch {} + // Считаем незакрытые строки: если внутри строки — закрываем кавычку. + let inString = false; + let escaped = false; + for (let i = 0; i < s.length; i += 1) { + const ch = s[i]; + if (escaped) { escaped = false; continue; } + if (ch === "\\") { if (inString) escaped = true; continue; } + if (ch === '"') inString = !inString; + } + if (inString) { + if (escaped) s = s.slice(0, -1); // dangling backslash + s += '"'; + } + // Убираем висячую запятую перед закрытием. + s = s.replace(/,\s*$/, ""); + // Дописываем скобки по стеку. + const stack = []; + for (let i = 0; i < s.length; i += 1) { + const ch = s[i]; + if (ch === "{") stack.push("}"); + else if (ch === "[") stack.push("]"); + else if (ch === "}" || ch === "]") stack.pop(); + } + if (stack.length) s += stack.reverse().join(""); try { - const parsed = JSON.parse(jsonStr); - const list = Array.isArray(parsed) ? parsed : [parsed]; - return list.map(normalizeCall).filter(Boolean); + JSON.parse(s); + return s; } catch { - return []; + return String(jsonStr || ""); + } +} + +// Ремонт неэкранированных двойных кавычек внутри JSON-строк. +// Деградировавший Qwen кладёт shell-команды с quoted-аргументами в +// "command" без экранирования: "grep -n "foo" bar" ломает JSON. +// Эвристика: если после закрывающей кавычки НЕ идёт структурный символ +// JSON (,:}] или конец ввода) — эта кавычка на самом деле литеральная, +// экранируем её. Итеративно, т.к. одна строка может содержать несколько. +export function escapeUnescapedInnerQuotes(jsonStr) { + const s = String(jsonStr || ""); + // Быстрая проверка: валидный JSON — не трогаем. + try { + JSON.parse(s); + return s; + } catch {} + let out = ""; + let inString = false; + for (let i = 0; i < s.length; i += 1) { + const ch = s[i]; + if (ch === "\\") { + // Валидные JSON-эскейпы пробрасываем; невалидные (\| ! ^ и т.п., + // которые модель пишет для grep-regex) удваиваем бэкслеш. + const nextCh = s[i + 1]; + if (nextCh !== undefined && nextCh !== "" && /[\\/"]|[bfnrtu]/.test(nextCh)) { + out += ch + nextCh; + } else if (nextCh !== undefined) { + out += "\\\\" + nextCh; + } else { + out += ch; // dangling backslash + } + i += 1; + continue; + } + if (ch === '"') { + if (!inString) { + inString = true; + out += ch; + continue; + } + // Мы внутри строки и встретили '"'. Смотрим следующий значимый символ. + let j = i + 1; + while (j < s.length && /\s/.test(s[j])) j += 1; + const next = s[j]; + if (next === undefined || /[,:\]}]/.test(next)) { + // Структурный символ — это настоящая закрывающая кавычка. + inString = false; + out += ch; + } else { + // Литеральная кавычка внутри строки — экранируем. + out += '\\"'; + } + continue; + } + out += ch; + } + return out; +} + +// Salvage tool-calls из прозы БЕЗ fence-маркера (```tool_calls / ```json). +// Деградировавший Qwen иногда выдаёт голый JSON-массив в тексте — +// стрим-детектор openai-handler его не видит, весь ответ уходит клиенту как +// проза, и агентный цикл ломается. Порт идеи prePassDeinterleave из +// FreeQwenApi: скан от каждого "name": назад к "{", подбор балансных скобок, +// серия ремонтов (переносы строк, незакрытые строки/скобки). +export function extractBareToolCallsArray(text) { + const source = String(text || ""); + if (!source) return null; + const nameRegex = /"\s*name\s*"\s*:\s*"([^"\n]+)"/g; + const calls = []; + const seen = new Set(); + let m; + while ((m = nameRegex.exec(source)) !== null) { + // Ищем открывающую "{" перед "name" (не дальше 200 символов). + let braceStart = -1; + for (let i = m.index - 1; i >= Math.max(0, m.index - 200); i -= 1) { + if (source[i] === "{") { braceStart = i; break; } + } + if (braceStart < 0) continue; + // Подбираем балансные скобки объекта. + let depth = 0, inStr = false, esc = false, end = -1; + for (let i = braceStart; i < source.length && i < braceStart + 100_000; i += 1) { + const ch = source[i]; + if (esc) { esc = false; continue; } + if (ch === "\\" && inStr) { esc = true; continue; } + if (ch === '"') { inStr = !inStr; continue; } + if (!inStr) { + if (ch === "{") depth += 1; + else if (ch === "}") { depth -= 1; if (depth === 0) { end = i; break; } } + } + } + let candidate; + if (end >= 0) { + candidate = source.slice(braceStart, end + 1); + } else { + // Обрезанный объект — пробуем ремонт. + candidate = repairTruncatedToolCallJson(source.slice(braceStart)); + } + const attempts = [candidate, repairTruncatedToolCallJson(candidate)]; + for (const attempt of attempts) { + try { + const obj = JSON.parse(attempt); + const name = obj.name || obj.tool; + const hasArgs = obj.arguments !== undefined || obj.args !== undefined || obj.input !== undefined; + if (!name || !hasArgs || typeof obj !== "object") continue; + const args = obj.arguments ?? obj.args ?? obj.input ?? {}; + const key = `${name}::${JSON.stringify(args).slice(0, 200)}`; + if (seen.has(key)) break; + seen.add(key); + calls.push({ name, arguments: typeof args === "string" ? args : JSON.stringify(args) }); + break; + } catch { /* next attempt */ } + } } + return calls.length ? calls : null; } export function extractBareToolCalls(text, { allowedNames } = {}) { diff --git a/plugin-for-vscode/api/openai-handler.mjs b/plugin-for-vscode/api/openai-handler.mjs index 19d7e65..3c3599d 100644 --- a/plugin-for-vscode/api/openai-handler.mjs +++ b/plugin-for-vscode/api/openai-handler.mjs @@ -27,7 +27,7 @@ import { getQwenLiveCatalogOverride } from "../src/providers/qwen/model-sync.mjs import { DEFAULT_AUTH_FILE } from "../src/config.mjs"; import { readSavedAuth } from "../src/auth/files.mjs"; import { DeepSeekChatClient } from "../src/providers/deepseek/client.mjs"; -import { extractBareToolCalls, formatCompactTools, normalizeToolCallsForSchemas, parseModelToolCalls } from "./tool-calls.mjs"; +import { extractBareToolCalls, formatCompactTools, normalizeToolCallsForSchemas, parseModelToolCalls, repairTruncatedToolCallJson, escapeUnescapedInnerQuotes } from "./tool-calls.mjs"; import { readChatGPTAuth } from "../src/providers/chatgpt/auth-files.mjs"; import { CHATGPT_AUTH_FILE } from "../src/providers/chatgpt/config.mjs"; import { ChatGPTChatClient } from "../src/providers/chatgpt/client.mjs"; @@ -975,6 +975,9 @@ export async function handleQwenStream(client, chatId, prompt, modelName, model, } catch (error) { lastError = error; if (error?.code === "EMPTY_UPSTREAM_STREAM") throw error; + // Baxia punish (антибот-капча): кулдаун активен, слепой retry + // с пересозданием чата только сильнее разгоняет скоринг. + if (error?.code === "QWEN_ANTIBOT_PUNISH") throw error; if (sawDelta || attempt >= 1 || typeof refreshClient !== "function") throw error; logQwenTiming(requestId, "retry_before_first_delta", { attempt: attempt + 1, @@ -1587,11 +1590,39 @@ export class StreamParser { calls = calls.flat(Infinity); console.log(`[API] Parsed streaming tool calls (after brace fix): ${calls.length}`); - const sent = this.sendToolCalls(calls); if (sent > 0) finishReason = "tool_calls"; else this.sendChunk({ content: "[Error] Upstream model returned an empty tool call. Retry the request." }); } catch (e2) { + // Попытка 2: неэкранированные кавычки внутри строк (Qwen кладёт + // shell-команды с quoted-аргументами в "command" без экранирования). + try { + const quotesFixed = escapeUnescapedInnerQuotes(jsonStr); + let calls = JSON.parse(quotesFixed); + if (!Array.isArray(calls)) calls = [calls]; + calls = calls.flat(Infinity); + console.log(`[API] Parsed streaming tool calls (after quote-escape fix): ${calls.length}`); + const sent = this.sendToolCalls(calls); + if (sent > 0) finishReason = "tool_calls"; + else this.sendChunk({ content: "[Error] Upstream model returned an empty tool call. Retry the request." }); + this.sendTerminalChunk(finishReason); + return; + } catch {} + // Попытка 3: модель могла упереться в лимит выходных токенов + // посреди блока — JSON обрезан, но стрим завершился штатно. Дописываем + // незакрытые строки/скобки и парсим salvaged-вызовы. + try { + const truncFixed = escapeUnescapedInnerQuotes(repairTruncatedToolCallJson(jsonStr)); + let calls = JSON.parse(truncFixed); + if (!Array.isArray(calls)) calls = [calls]; + calls = calls.flat(Infinity); + console.log(`[API] Parsed streaming tool calls (after truncation repair): ${calls.length}`); + const sent = this.sendToolCalls(calls); + if (sent > 0) finishReason = "tool_calls"; + else this.sendChunk({ content: "[Error] Upstream model returned an empty tool call. Retry the request." }); + this.sendTerminalChunk(finishReason); + return; + } catch {} console.error("[API] Error parsing tool calls from streaming response:", e2.message); fs.writeFileSync("/tmp/failed_json.txt", jsonStr); console.error("[API] Problematic JSON string was:\n", JSON.stringify(jsonStr)); // Fallback: send as normal text so the UI doesn't hang completely diff --git a/plugin-for-vscode/api/tool-calls.mjs b/plugin-for-vscode/api/tool-calls.mjs index 2b10b42..f88e4b2 100644 --- a/plugin-for-vscode/api/tool-calls.mjs +++ b/plugin-for-vscode/api/tool-calls.mjs @@ -82,13 +82,173 @@ function extractToolCallsBlock(source) { } function parseCallsJson(jsonStr) { + const attempts = [jsonStr, escapeUnescapedInnerQuotes(jsonStr)]; + for (const attempt of attempts) { + try { + const parsed = JSON.parse(attempt); + const list = Array.isArray(parsed) ? parsed : [parsed]; + const calls = list.map(normalizeCall).filter(Boolean); + if (calls.length) return calls; + } catch { /* next attempt */ } + } + return []; +} + +// Ремонт обрезанного tool-call JSON: модель упёрлась в лимит выходных токенов +// посреди блока ```tool_calls — стрим завершается штатно (stream_done), но +// массив не закрыт. Дописываем незакрытую строку/скобки, чтобы спасти вызов. +export function repairTruncatedToolCallJson(jsonStr) { + let s = String(jsonStr || "").trim(); + if (!s) return s; + // Быстрая проверка: если уже валиден — не трогаем. + try { + JSON.parse(s); + return s; + } catch {} + // Считаем незакрытые строки: если внутри строки — закрываем кавычку. + let inString = false; + let escaped = false; + for (let i = 0; i < s.length; i += 1) { + const ch = s[i]; + if (escaped) { escaped = false; continue; } + if (ch === "\\") { if (inString) escaped = true; continue; } + if (ch === '"') inString = !inString; + } + if (inString) { + if (escaped) s = s.slice(0, -1); // dangling backslash + s += '"'; + } + // Убираем висячую запятую перед закрытием. + s = s.replace(/,\s*$/, ""); + // Дописываем скобки по стеку. + const stack = []; + for (let i = 0; i < s.length; i += 1) { + const ch = s[i]; + if (ch === "{") stack.push("}"); + else if (ch === "[") stack.push("]"); + else if (ch === "}" || ch === "]") stack.pop(); + } + if (stack.length) s += stack.reverse().join(""); try { - const parsed = JSON.parse(jsonStr); - const list = Array.isArray(parsed) ? parsed : [parsed]; - return list.map(normalizeCall).filter(Boolean); + JSON.parse(s); + return s; } catch { - return []; + return String(jsonStr || ""); + } +} + +// Ремонт неэкранированных двойных кавычек внутри JSON-строк. +// Деградировавший Qwen кладёт shell-команды с quoted-аргументами в +// "command" без экранирования: "grep -n "foo" bar" ломает JSON. +// Эвристика: если после закрывающей кавычки НЕ идёт структурный символ +// JSON (,:}] или конец ввода) — эта кавычка на самом деле литеральная, +// экранируем её. Итеративно, т.к. одна строка может содержать несколько. +export function escapeUnescapedInnerQuotes(jsonStr) { + const s = String(jsonStr || ""); + // Быстрая проверка: валидный JSON — не трогаем. + try { + JSON.parse(s); + return s; + } catch {} + let out = ""; + let inString = false; + for (let i = 0; i < s.length; i += 1) { + const ch = s[i]; + if (ch === "\\") { + // Валидные JSON-эскейпы пробрасываем; невалидные (\| ! ^ и т.п., + // которые модель пишет для grep-regex) удваиваем бэкслеш. + const nextCh = s[i + 1]; + if (nextCh !== undefined && nextCh !== "" && /[\\/"]|[bfnrtu]/.test(nextCh)) { + out += ch + nextCh; + } else if (nextCh !== undefined) { + out += "\\\\" + nextCh; + } else { + out += ch; // dangling backslash + } + i += 1; + continue; + } + if (ch === '"') { + if (!inString) { + inString = true; + out += ch; + continue; + } + // Мы внутри строки и встретили '"'. Смотрим следующий значимый символ. + let j = i + 1; + while (j < s.length && /\s/.test(s[j])) j += 1; + const next = s[j]; + if (next === undefined || /[,:\]}]/.test(next)) { + // Структурный символ — это настоящая закрывающая кавычка. + inString = false; + out += ch; + } else { + // Литеральная кавычка внутри строки — экранируем. + out += '\\"'; + } + continue; + } + out += ch; + } + return out; +} + +// Salvage tool-calls из прозы БЕЗ fence-маркера (```tool_calls / ```json). +// Деградировавший Qwen иногда выдаёт голый JSON-массив в тексте — +// стрим-детектор openai-handler его не видит, весь ответ уходит клиенту как +// проза, и агентный цикл ломается. Порт идеи prePassDeinterleave из +// FreeQwenApi: скан от каждого "name": назад к "{", подбор балансных скобок, +// серия ремонтов (переносы строк, незакрытые строки/скобки). +export function extractBareToolCallsArray(text) { + const source = String(text || ""); + if (!source) return null; + const nameRegex = /"\s*name\s*"\s*:\s*"([^"\n]+)"/g; + const calls = []; + const seen = new Set(); + let m; + while ((m = nameRegex.exec(source)) !== null) { + // Ищем открывающую "{" перед "name" (не дальше 200 символов). + let braceStart = -1; + for (let i = m.index - 1; i >= Math.max(0, m.index - 200); i -= 1) { + if (source[i] === "{") { braceStart = i; break; } + } + if (braceStart < 0) continue; + // Подбираем балансные скобки объекта. + let depth = 0, inStr = false, esc = false, end = -1; + for (let i = braceStart; i < source.length && i < braceStart + 100_000; i += 1) { + const ch = source[i]; + if (esc) { esc = false; continue; } + if (ch === "\\" && inStr) { esc = true; continue; } + if (ch === '"') { inStr = !inStr; continue; } + if (!inStr) { + if (ch === "{") depth += 1; + else if (ch === "}") { depth -= 1; if (depth === 0) { end = i; break; } } + } + } + let candidate; + if (end >= 0) { + candidate = source.slice(braceStart, end + 1); + } else { + // Обрезанный объект — пробуем ремонт. + candidate = repairTruncatedToolCallJson(source.slice(braceStart)); + } + const attempts = [candidate, repairTruncatedToolCallJson(candidate)]; + for (const attempt of attempts) { + try { + const obj = JSON.parse(attempt); + const name = obj.name || obj.tool; + const hasArgs = obj.arguments !== undefined || obj.args !== undefined || obj.input !== undefined; + if (!name || !hasArgs || typeof obj !== "object") continue; + const args = obj.arguments ?? obj.args ?? obj.input ?? {}; + const key = `${name}::${JSON.stringify(args).slice(0, 200)}`; + if (seen.has(key)) break; + seen.add(key); + calls.push({ name, arguments: typeof args === "string" ? args : JSON.stringify(args) }); + break; + } catch { /* next attempt */ } + } } + return calls.length ? calls : null; } export function extractBareToolCalls(text, { allowedNames } = {}) { diff --git a/test/qwen-unescaped-quotes.test.mjs b/test/qwen-unescaped-quotes.test.mjs new file mode 100644 index 0000000..aaf09ba --- /dev/null +++ b/test/qwen-unescaped-quotes.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + escapeUnescapedInnerQuotes, + parseModelToolCalls, +} from "../src/../api/tool-calls.mjs"; + +// Точные сэмплы из лога пользователя 2026-08-21: Qwen кладёт shell-команды +// с двойными кавычками в JSON-строку БЕЗ экранирования. + +const SAMPLE_1 = `[ + { + "name": "terminal", + "arguments": { + "command": "cd /d/ai-free && grep -n "export function extractBareToolCallsArray" -A 80 api/tool-calls.mjs | head -100" + } + } +]`; + +const SAMPLE_2 = `[ + { + "name": "terminal", + "arguments": { + "command": "cd /d/ai-free && grep -n "extractBareToolCallsArray\\|repairTruncatedToolCallJson" api/openai-handler.mjs api/tool-calls.mjs | head -20" + } + } +]`; + +describe("escapeUnescapedInnerQuotes", () => { + it("чинит неэкранированные кавычки вокруг grep-паттерна (сэмпл 1)", () => { + const fixed = escapeUnescapedInnerQuotes(SAMPLE_1); + const parsed = JSON.parse(fixed); + assert.equal(parsed.length, 1); + assert.equal(parsed[0].name, "terminal"); + assert.equal( + parsed[0].arguments.command, + 'cd /d/ai-free && grep -n "export function extractBareToolCallsArray" -A 80 api/tool-calls.mjs | head -100', + ); + }); + + it("чинит кавычки + невалидный эскейп \\| (сэмпл 2)", () => { + // В сыром тексте модели стоит \| — невалидный JSON-эскейп. + const raw = `[{"name":"terminal","arguments":{"command":"grep -n "a\\|b" file"}}]`; + const fixed = escapeUnescapedInnerQuotes(raw); + const parsed = JSON.parse(fixed); + assert.equal(parsed[0].arguments.command, 'grep -n "a\\|b" file'); + }); + + it("не портит уже валидный JSON с корректно экранированными кавычками", () => { + const valid = `[ + { + "name": "terminal", + "arguments": { + "command": "grep -n \\"foo\\" bar" + } + } +]`; + const fixed = escapeUnescapedInnerQuotes(valid); + const parsed = JSON.parse(fixed); + assert.equal(parsed[0].arguments.command, 'grep -n "foo" bar'); + }); + + it("кавычка перед структурным символом остаётся закрывающей", () => { + const fixed = escapeUnescapedInnerQuotes(`{"a": "x", "b": "y"}`); + assert.deepEqual(JSON.parse(fixed), { a: "x", b: "y" }); + }); + + it("parseModelToolCalls спасает вызов из fence-блока с битыми кавычками", () => { + const text = + "Проверяю реализацию:\n\n```tool_calls\n" + SAMPLE_1 + "\n```\n"; + const { calls } = parseModelToolCalls(text); + assert.equal(calls.length, 1); + assert.equal(calls[0].name, "terminal"); + const args = JSON.parse(calls[0].arguments); + assert.equal(args.command.startsWith("cd /d/ai-free && grep -n"), true); + assert.equal(args.command.includes('"export function extractBareToolCallsArray"'), true); + }); +}); From c8258ac9eb3c31fe771a9d0a1b03a23486437fc2 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 21 Aug 2026 02:42:59 +0300 Subject: [PATCH 04/13] test(qwen): cover degraded TTFT, fetch headroom and prose-session detector - qwen-degraded-ttfp.test.mjs: firstContentMs default 240s tolerates degraded-Qwen TTFT (observed 118-186s), explicit overrides respected - qwen-stream-timeouts.test.mjs: assert 600s fetch headroom for long agent generations (250-320s observed) + explicit override - qwen-bare-toolcalls.test.mjs: unfenced tool-call JSON in prose is salvaged, prose without tool structure is passed through untouched - session-errors: clarify that login-page HTML is authoritative --- .../src/providers/qwen/session-errors.mjs | 1 + src/providers/qwen/session-errors.mjs | 1 + test/qwen-bare-toolcalls.test.mjs | 68 +++++++++++++++ test/qwen-degraded-ttfp.test.mjs | 87 +++++++++++++++++++ test/qwen-stream-timeouts.test.mjs | 16 ++++ 5 files changed, 173 insertions(+) create mode 100644 test/qwen-bare-toolcalls.test.mjs create mode 100644 test/qwen-degraded-ttfp.test.mjs diff --git a/plugin-for-vscode/src/providers/qwen/session-errors.mjs b/plugin-for-vscode/src/providers/qwen/session-errors.mjs index 020a681..417484d 100644 --- a/plugin-for-vscode/src/providers/qwen/session-errors.mjs +++ b/plugin-for-vscode/src/providers/qwen/session-errors.mjs @@ -74,6 +74,7 @@ export function isQwenSessionExpiredText(text = "") { const source = String(text || ""); const blob = source.toLowerCase(); if (!blob) return false; + // Login-page HTML is authoritative regardless of length. if (/]/i.test(blob) && /login|sign.?in|auth/i.test(blob)) return true; const looksLikeAssistantProse = source.length > 300 || /\n/.test(source.slice(0, 300)); if (!looksLikeAssistantProse && /unauthorized|not logged|login required|token expired|session expired|invalid token/i.test(blob)) { diff --git a/src/providers/qwen/session-errors.mjs b/src/providers/qwen/session-errors.mjs index 020a681..417484d 100644 --- a/src/providers/qwen/session-errors.mjs +++ b/src/providers/qwen/session-errors.mjs @@ -74,6 +74,7 @@ export function isQwenSessionExpiredText(text = "") { const source = String(text || ""); const blob = source.toLowerCase(); if (!blob) return false; + // Login-page HTML is authoritative regardless of length. if (/]/i.test(blob) && /login|sign.?in|auth/i.test(blob)) return true; const looksLikeAssistantProse = source.length > 300 || /\n/.test(source.slice(0, 300)); if (!looksLikeAssistantProse && /unauthorized|not logged|login required|token expired|session expired|invalid token/i.test(blob)) { diff --git a/test/qwen-bare-toolcalls.test.mjs b/test/qwen-bare-toolcalls.test.mjs new file mode 100644 index 0000000..c152a1d --- /dev/null +++ b/test/qwen-bare-toolcalls.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { extractBareToolCallsArray } from "../api/tool-calls.mjs"; + +// Degraded Qwen sometimes emits the tool_calls array WITHOUT the ```tool_calls +// fence — bare JSON in prose (observed 2026-08-20: two 3-min completions with +// stream_done and zero "Parsed streaming tool calls" lines). The stream +// detector only triggers on fence markers, so the whole call was streamed to +// the client as plain prose and the agent loop broke. +// Port of FreeQwenApi prePassDeinterleave ideas: find "name"+"arguments" +// objects anywhere in text. + +describe("extractBareToolCallsArray", () => { + it("finds a bare fenced-style JSON array embedded in prose (no fence marker)", () => { + const prose = + "Разберёмся с задачей. Сначала прочитаю файл конфигурации.\n" + + '[\n {\n "name": "read_file",\n "arguments": { "path": "D:/ai-free/package.json" }\n }\n]\n' + + "После этого продолжу анализ."; + const calls = extractBareToolCallsArray(prose); + assert.ok(calls, "must extract from bare array"); + assert.equal(calls.length, 1); + assert.equal(calls[0].name, "read_file"); + assert.deepEqual(JSON.parse(calls[0].arguments), { path: "D:/ai-free/package.json" }); + }); + + it("finds a single bare object {name, arguments} in prose", () => { + const prose = 'Хорошо. {"name": "terminal", "arguments": {"command": "ls -la"}} Готово.'; + const calls = extractBareToolCallsArray(prose); + assert.ok(calls); + assert.equal(calls[0].name, "terminal"); + assert.deepEqual(JSON.parse(calls[0].arguments), { command: "ls -la" }); + }); + + it("handles multiple calls in one bare array", () => { + const prose = 'Ok. [{"name": "a", "arguments": {}}, {"name": "b", "arguments": {"x": 1}}] done'; + const calls = extractBareToolCallsArray(prose); + assert.ok(calls); + assert.equal(calls.length, 2); + }); + + it("ignores plain prose without tool-call structures", () => { + const prose = "Обычный ответ модели без каких-либо вызовов инструментов. Просто текст с [квадратными скобками] внутри."; + assert.equal(extractBareToolCallsArray(prose), null); + }); + + it("ignores JSON objects that are not tool calls (no name+arguments)", () => { + const prose = 'Вот данные: {"users": [{"id": 1}], "total": 5} — конец.'; + assert.equal(extractBareToolCallsArray(prose), null); + }); + + it("requires both name and arguments keys to reduce false positives", () => { + const prose = 'Ссылка вида {"name": "readme.txt"} — это не вызов инструмента.'; + assert.equal(extractBareToolCallsArray(prose), null); + }); + + it("survives an unterminated (truncated) bare call", () => { + const prose = 'Начинаю. [\n {\n "name": "write_file",\n "arguments": { "path": "x.txt", "content": "line1'; + const calls = extractBareToolCallsArray(prose); + assert.ok(calls, "truncated bare call should be salvaged"); + assert.equal(calls[0].name, "write_file"); + }); + + it("does not double-count when the array is wrapped in prose quotes", () => { + const prose = 'Данные: "name": "x", "arguments": {} — просто упоминание в тексте.'; + // name value "x" without a surrounding object structure must not match + assert.equal(extractBareToolCallsArray(prose), null); + }); +}); diff --git a/test/qwen-degraded-ttfp.test.mjs b/test/qwen-degraded-ttfp.test.mjs new file mode 100644 index 0000000..2b17049 --- /dev/null +++ b/test/qwen-degraded-ttfp.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { repairTruncatedToolCallJson } from "../api/tool-calls.mjs"; +import { isQwenSessionExpiredText } from "../src/providers/qwen/session-errors.mjs"; +import { resolveQwenStreamTimeouts } from "../src/providers/qwen/stream-timeouts.mjs"; + +// Exact truncated JSON from live logs 2026-08-20: model hit its output-token +// limit mid tool_calls block; stream ended cleanly (stream_done, 260s) but the +// array was never closed → "Error parsing tool calls ... position 775". +const TRUNCATED_FROM_LOG = + '[\n {\n "name": "memory",\n "arguments": {\n "target": "memory",\n "operations": [\n {\n "action": "add",\n "content": "§\\nTOOL CAPABILITIES: Never make categorical claims.\\n§\\nSANDBOX ISOLATION: cloud sandbox cannot reach local tools."\n }\n ]'; + +describe("repairTruncatedToolCallJson", () => { + it("closes unbalanced brackets from the live truncated sample", () => { + const repaired = repairTruncatedToolCallJson(TRUNCATED_FROM_LOG); + const calls = JSON.parse(repaired); + assert.equal(Array.isArray(calls), true); + assert.equal(calls.length, 1); + assert.equal(calls[0].name, "memory"); + assert.equal(calls[0].arguments.target, "memory"); + assert.equal(calls[0].arguments.operations.length, 1); + assert.equal(calls[0].arguments.operations[0].action, "add"); + }); + + it("closes an unterminated string mid-value", () => { + const repaired = repairTruncatedToolCallJson( + '[{"name": "terminal", "arguments": {"command": "echo hel', + ); + const calls = JSON.parse(repaired); + assert.equal(calls[0].name, "terminal"); + // The visible part of the string survives. + assert.match(calls[0].arguments.command, /echo hel/); + }); + + it("strips a dangling trailing comma before closing", () => { + const repaired = repairTruncatedToolCallJson('[{"name": "a", "arguments": {},'); + assert.deepEqual(JSON.parse(repaired), [{ name: "a", arguments: {} }]); + }); + + it("drops a dangling escape at end of unterminated string", () => { + const repaired = repairTruncatedToolCallJson('[{"name": "a", "arguments": {"x": "va\\'); + assert.deepEqual(JSON.parse(repaired), [{ name: "a", arguments: { x: "va" } }]); + }); + + it("returns valid json unchanged when already balanced", () => { + const good = '[{"name": "a", "arguments": {"x": "y"}}]'; + assert.equal(repairTruncatedToolCallJson(good), good); + }); +}); + +describe("isQwenSessionExpiredText (false-positive guard)", () => { + it("does NOT flag long technical prose that merely mentions 401 Unauthorized", () => { + const prose = + "Если ссылки отдают 401 Unauthorized или 403 Forbidden, значит сервер требует сессию. " + + "Tool read_file does not exists. Нет, напрямую воспользоваться твоим локальным ZAP API (порт 8282) " + + "я не могу. Моя песочница работает в облаке и имеет строгую сетевую изоляцию — 127.0.0.1 для меня " + + "это сам контейнер, а не твой компьютер. Чтобы я мог дергать твой ZAP, его нужно было бы пробросить."; + assert.equal(isQwenSessionExpiredText(prose), false); + }); + + it("still flags short real auth errors", () => { + assert.equal(isQwenSessionExpiredText("bad_request: invalid token"), true); + assert.equal(isQwenSessionExpiredText("unauthorized"), true); + assert.equal(isQwenSessionExpiredText("token expired, please sign in"), true); + }); + + it("still flags login-page HTML regardless of length", () => { + const html = "" + "x".repeat(600) + " please login to continue"; + assert.equal(isQwenSessionExpiredText(html), true); + }); + + it("still flags the explicit ru marker", () => { + assert.equal(isQwenSessionExpiredText("Сессия Qwen устарела".padEnd(500, ".")), true); + }); +}); + +describe("first-content timeout default", () => { + it("covers observed degraded TTFT (up to ~190s): default 240s", () => { + const t = resolveQwenStreamTimeouts({}); + assert.equal(t.firstContentMs, 240_000); + assert.ok(t.fetchMs > t.firstContentMs, "fetch cap must exceed first-content timeout"); + }); + + it("keeps env override", () => { + assert.equal(resolveQwenStreamTimeouts({ QWEN_STREAM_FIRST_CONTENT_TIMEOUT_MS: "60000" }).firstContentMs, 60_000); + }); +}); diff --git a/test/qwen-stream-timeouts.test.mjs b/test/qwen-stream-timeouts.test.mjs index c340606..8dce82d 100644 --- a/test/qwen-stream-timeouts.test.mjs +++ b/test/qwen-stream-timeouts.test.mjs @@ -22,4 +22,20 @@ describe("Qwen stream timeouts", () => { assert.equal(timeouts.firstContentMs, 75_000); assert.equal(timeouts.idleMs, 100_000); }); + + it("gives long agent streams enough headroom before the hard fetch cap aborts them", () => { + // Observed in the wild (issue #21): healthy Qwen tool-call generations + // stream for 250-320s. The 600s hard cap leaves generous headroom. + const timeouts = resolveQwenStreamTimeouts({}); + + assert.equal(timeouts.fetchMs, 600_000); + }); + + it("keeps explicit fetch timeout overrides", () => { + const timeouts = resolveQwenStreamTimeouts({ + QWEN_FETCH_TIMEOUT_MS: "600000", + }); + + assert.equal(timeouts.fetchMs, 600_000); + }); }); From 5fb056bd639d1720ac962ef110305239361a1f30 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 21 Aug 2026 03:27:53 +0300 Subject: [PATCH 05/13] fix(api): strip literal blocks so reasoning drafts are not parsed as tool calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Degraded Qwen emits reasoning as literal text with tags directly in the text channel (instead of a separate phase:"think" SSE field). The reasoning contains draft tool-call JSON ('Wait, I'll use execute_code...' + {"name": ...}) which findBareToolStart and the fence detector picked up as REAL calls — producing the 'Tool X does not exists' cascade in Hermes and visible 'Thinking completed' separators in chat. - think-filter.mjs: stripThinkBlocks() (non-stream) + createThinkTagFilter() (streaming wrapper with a small hold buffer so tags split across chunks do not leak; handles unclosed , partial tag prefixes and the 'Thinking completed' separator line) - Qwen stream path: wrap parser.onText in the filter, flush before onEnd - parseModelToolCallsSafe(): parseModelToolCalls(stripThinkBlocks(text)), used by all three non-stream call sites (responses, chat.completions, anthropic) - tests use the exact payloads observed in the wild --- api/openai-handler.mjs | 23 ++++- api/think-filter.mjs | 119 +++++++++++++++++++++++ plugin-for-vscode/api/openai-handler.mjs | 23 ++++- plugin-for-vscode/api/think-filter.mjs | 119 +++++++++++++++++++++++ test/qwen-think-tag-filter.test.mjs | 102 +++++++++++++++++++ 5 files changed, 376 insertions(+), 10 deletions(-) create mode 100644 api/think-filter.mjs create mode 100644 plugin-for-vscode/api/think-filter.mjs create mode 100644 test/qwen-think-tag-filter.test.mjs diff --git a/api/openai-handler.mjs b/api/openai-handler.mjs index 3c3599d..90a00ff 100644 --- a/api/openai-handler.mjs +++ b/api/openai-handler.mjs @@ -28,12 +28,21 @@ import { DEFAULT_AUTH_FILE } from "../src/config.mjs"; import { readSavedAuth } from "../src/auth/files.mjs"; import { DeepSeekChatClient } from "../src/providers/deepseek/client.mjs"; import { extractBareToolCalls, formatCompactTools, normalizeToolCallsForSchemas, parseModelToolCalls, repairTruncatedToolCallJson, escapeUnescapedInnerQuotes } from "./tool-calls.mjs"; +import { createThinkTagFilter, stripThinkBlocks } from "./think-filter.mjs"; import { readChatGPTAuth } from "../src/providers/chatgpt/auth-files.mjs"; import { CHATGPT_AUTH_FILE } from "../src/providers/chatgpt/config.mjs"; import { ChatGPTChatClient } from "../src/providers/chatgpt/client.mjs"; import { createFileLogger } from "../src/logging/logger.mjs"; import { runWithEmptyStreamRetry } from "./stream-retry.mjs"; +// parseModelToolCalls с предварительной зачисткой литеральных -блоков: +// деградировавший Qwen кладёт reasoning (с черновиками tool-call JSON) +// прямо в текстовый канал — без зачистки детектор выдёргивает черновики +// как реальные вызовы ("Tool X does not exists" каскад). +export function parseModelToolCallsSafe(text) { + return parseModelToolCalls(stripThinkBlocks(text)); +} + const compatLogger = createFileLogger({ component: "openai-handler" }); // Ленивый singleton Qwen-клиента — переиспользуем через все вызовы API. @@ -695,7 +704,7 @@ function toResponsesResponse(model, text) { const createdAt = Math.floor(Date.now() / 1000); const id = `resp_${createdAt}${Math.random().toString(36).slice(2, 10)}`; const itemId = `msg_${Math.random().toString(36).slice(2, 10)}`; - const parsed = parseModelToolCalls(text); + const parsed = parseModelToolCallsSafe(text); const toolOutput = parsed.calls.map((call) => ({ id: `fc_${Math.random().toString(36).slice(2, 10)}`, type: "function_call", @@ -905,6 +914,10 @@ export async function handleQwenStream(client, chatId, prompt, modelName, model, const requestId = `qwen_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; const startedAt = Date.now(); const parser = new StreamParser(modelName, res, { tools }); + // Деградировавший Qwen шлёт reasoning литеральным текстом с -тегами; + // внутри — черновики tool-call JSON, которые детектор ловил как реальные + // вызовы. Фильтр вырезает think-блоки до того, как буфер увидит парсер. + const thinkFilter = createThinkTagFilter({ onText: (t) => parser.onText(t) }); let sawDelta = false; let firstDeltaAt = 0; const heartbeat = setInterval(() => { @@ -953,7 +966,7 @@ export async function handleQwenStream(client, chatId, prompt, modelName, model, completion_to_first_delta_ms: elapsedMs(completionStartedAt), }); } - parser.onText(textDelta); + thinkFilter.push(textDelta); }, beforeRetry: async ({ attempt: emptyAttempt, error }) => { if (typeof createChat !== "function") throw error; @@ -992,6 +1005,7 @@ export async function handleQwenStream(client, chatId, prompt, modelName, model, if (lastError) throw lastError; clearInterval(heartbeat); if (res.destroyed || res.writableEnded) return; + thinkFilter.flush(); parser.onEnd(); writeSseRaw(res, "data: [DONE]\n\n"); if (!res.destroyed && !res.writableEnded) res.end(); @@ -1042,7 +1056,6 @@ async function handleChatGPTStream(client, prompt, modelName, model, res, { tool sendStreamError(res, modelName, e.message); } } - // Обработка streaming-запроса к DeepSeek. async function handleDeepSeekStream(client, sessionId, prompt, modelName, model, res, { thinking = false, @@ -1095,7 +1108,7 @@ function toOpenAIResponse(model, text, tools = []) { let content = text; let finish_reason = "stop"; - const parsed = parseModelToolCalls(text); + const parsed = parseModelToolCallsSafe(text); const normalized = normalizeToolCallsForSchemas(parsed.calls, tools); if (normalized.calls.length) { content = parsed.content; @@ -1132,7 +1145,7 @@ function toOpenAIResponse(model, text, tools = []) { } export function toAnthropicMessageResponse(model, text) { - const parsed = parseModelToolCalls(text); + const parsed = parseModelToolCallsSafe(text); const content = []; if (parsed.content) { content.push({ type: "text", text: parsed.content }); diff --git a/api/think-filter.mjs b/api/think-filter.mjs new file mode 100644 index 0000000..17644cd --- /dev/null +++ b/api/think-filter.mjs @@ -0,0 +1,119 @@ +// Фильтр литеральных -блоков, которые деградировавший Qwen шлёт +// прямо в текстовом канале (вместо отдельного phase:"think" в SSE). +// Внутри reasoning-текста модель накидывает черновики tool-call JSON — +// без фильтра стрим-детектор findBareToolStart выдёргивает их как +// реальные вызовы ("Tool X does not exists" каскад в Hermes). +// +// Экспортирует: +// stripThinkBlocks(text) — утилита для non-stream пути; +// createThinkTagFilter({ onText }) — обёртка для стриминга: держит +// маленький хвост-буфер, чтобы тег, разрезанный по чанкам, не протёк. + +const THINK_OPEN = ""; +const THINK_CLOSE = ""; + +// Строка-сепаратор, которой Qwen помечает конец reasoning в деградированном +// выводе (без полноценных тегов). Вырезаем строку целиком. +const THINKING_COMPLETED_RE = /(?:^|\n)[ \t]*Thinking completed[ \t]*(?=\n|$)/g; + +export function stripThinkBlocks(text) { + const s = String(text || ""); + if (!s) return s; + let out = ""; + let i = 0; + let inThink = false; + while (i < s.length) { + if (!inThink) { + const open = s.indexOf(THINK_OPEN, i); + if (open === -1) { + out += s.slice(i); + break; + } + out += s.slice(i, open); + i = open + THINK_OPEN.length; + inThink = true; + } else { + const close = s.indexOf(THINK_CLOSE, i); + if (close === -1) { + // Незакрытый reasoning: весь хвост — черновики, подавляем. + i = s.length; + break; + } + i = close + THINK_CLOSE.length; + inThink = false; + } + } + const cleaned = out.replace(THINKING_COMPLETED_RE, "\n"); + return /\S/.test(cleaned) ? cleaned.replace(/\n{3,}/g, "\n\n") : ""; +} + +// Стриминговая обёртка. Держим до (len(THINK_OPEN)-1) символов «на подозрении»: +// если это начало "" — не эмитим; если оказалось обычным текстом — +// эмитим при поступлении следующего чанка или на flush. +export function createThinkTagFilter({ onText = null } = {}) { + let hold = ""; + let suppress = false; // внутри ... + + function emit(text) { + if (text && typeof onText === "function") onText(text); + } + + return { + push(delta) { + hold += String(delta || ""); + let out = ""; + let progress = true; + while (progress) { + progress = false; + if (suppress) { + // Ищем закрывающий тег (в т.ч. частичный в самом конце хвоста). + const close = hold.indexOf(THINK_CLOSE); + if (close !== -1) { + hold = hold.slice(close + THINK_CLOSE.length); + suppress = false; + progress = true; + continue; + } + // Закрывающего нет: подавляем всё, КРОМЕ возможного частичного + // префикса " 0; len -= 1) { + if (s.endsWith(tag.slice(0, len))) return len; + // Суффикс "" длины 7 — это сам тег, не частичный (обработан выше). + } + return 0; +} diff --git a/plugin-for-vscode/api/openai-handler.mjs b/plugin-for-vscode/api/openai-handler.mjs index 3c3599d..90a00ff 100644 --- a/plugin-for-vscode/api/openai-handler.mjs +++ b/plugin-for-vscode/api/openai-handler.mjs @@ -28,12 +28,21 @@ import { DEFAULT_AUTH_FILE } from "../src/config.mjs"; import { readSavedAuth } from "../src/auth/files.mjs"; import { DeepSeekChatClient } from "../src/providers/deepseek/client.mjs"; import { extractBareToolCalls, formatCompactTools, normalizeToolCallsForSchemas, parseModelToolCalls, repairTruncatedToolCallJson, escapeUnescapedInnerQuotes } from "./tool-calls.mjs"; +import { createThinkTagFilter, stripThinkBlocks } from "./think-filter.mjs"; import { readChatGPTAuth } from "../src/providers/chatgpt/auth-files.mjs"; import { CHATGPT_AUTH_FILE } from "../src/providers/chatgpt/config.mjs"; import { ChatGPTChatClient } from "../src/providers/chatgpt/client.mjs"; import { createFileLogger } from "../src/logging/logger.mjs"; import { runWithEmptyStreamRetry } from "./stream-retry.mjs"; +// parseModelToolCalls с предварительной зачисткой литеральных -блоков: +// деградировавший Qwen кладёт reasoning (с черновиками tool-call JSON) +// прямо в текстовый канал — без зачистки детектор выдёргивает черновики +// как реальные вызовы ("Tool X does not exists" каскад). +export function parseModelToolCallsSafe(text) { + return parseModelToolCalls(stripThinkBlocks(text)); +} + const compatLogger = createFileLogger({ component: "openai-handler" }); // Ленивый singleton Qwen-клиента — переиспользуем через все вызовы API. @@ -695,7 +704,7 @@ function toResponsesResponse(model, text) { const createdAt = Math.floor(Date.now() / 1000); const id = `resp_${createdAt}${Math.random().toString(36).slice(2, 10)}`; const itemId = `msg_${Math.random().toString(36).slice(2, 10)}`; - const parsed = parseModelToolCalls(text); + const parsed = parseModelToolCallsSafe(text); const toolOutput = parsed.calls.map((call) => ({ id: `fc_${Math.random().toString(36).slice(2, 10)}`, type: "function_call", @@ -905,6 +914,10 @@ export async function handleQwenStream(client, chatId, prompt, modelName, model, const requestId = `qwen_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`; const startedAt = Date.now(); const parser = new StreamParser(modelName, res, { tools }); + // Деградировавший Qwen шлёт reasoning литеральным текстом с -тегами; + // внутри — черновики tool-call JSON, которые детектор ловил как реальные + // вызовы. Фильтр вырезает think-блоки до того, как буфер увидит парсер. + const thinkFilter = createThinkTagFilter({ onText: (t) => parser.onText(t) }); let sawDelta = false; let firstDeltaAt = 0; const heartbeat = setInterval(() => { @@ -953,7 +966,7 @@ export async function handleQwenStream(client, chatId, prompt, modelName, model, completion_to_first_delta_ms: elapsedMs(completionStartedAt), }); } - parser.onText(textDelta); + thinkFilter.push(textDelta); }, beforeRetry: async ({ attempt: emptyAttempt, error }) => { if (typeof createChat !== "function") throw error; @@ -992,6 +1005,7 @@ export async function handleQwenStream(client, chatId, prompt, modelName, model, if (lastError) throw lastError; clearInterval(heartbeat); if (res.destroyed || res.writableEnded) return; + thinkFilter.flush(); parser.onEnd(); writeSseRaw(res, "data: [DONE]\n\n"); if (!res.destroyed && !res.writableEnded) res.end(); @@ -1042,7 +1056,6 @@ async function handleChatGPTStream(client, prompt, modelName, model, res, { tool sendStreamError(res, modelName, e.message); } } - // Обработка streaming-запроса к DeepSeek. async function handleDeepSeekStream(client, sessionId, prompt, modelName, model, res, { thinking = false, @@ -1095,7 +1108,7 @@ function toOpenAIResponse(model, text, tools = []) { let content = text; let finish_reason = "stop"; - const parsed = parseModelToolCalls(text); + const parsed = parseModelToolCallsSafe(text); const normalized = normalizeToolCallsForSchemas(parsed.calls, tools); if (normalized.calls.length) { content = parsed.content; @@ -1132,7 +1145,7 @@ function toOpenAIResponse(model, text, tools = []) { } export function toAnthropicMessageResponse(model, text) { - const parsed = parseModelToolCalls(text); + const parsed = parseModelToolCallsSafe(text); const content = []; if (parsed.content) { content.push({ type: "text", text: parsed.content }); diff --git a/plugin-for-vscode/api/think-filter.mjs b/plugin-for-vscode/api/think-filter.mjs new file mode 100644 index 0000000..17644cd --- /dev/null +++ b/plugin-for-vscode/api/think-filter.mjs @@ -0,0 +1,119 @@ +// Фильтр литеральных -блоков, которые деградировавший Qwen шлёт +// прямо в текстовом канале (вместо отдельного phase:"think" в SSE). +// Внутри reasoning-текста модель накидывает черновики tool-call JSON — +// без фильтра стрим-детектор findBareToolStart выдёргивает их как +// реальные вызовы ("Tool X does not exists" каскад в Hermes). +// +// Экспортирует: +// stripThinkBlocks(text) — утилита для non-stream пути; +// createThinkTagFilter({ onText }) — обёртка для стриминга: держит +// маленький хвост-буфер, чтобы тег, разрезанный по чанкам, не протёк. + +const THINK_OPEN = ""; +const THINK_CLOSE = ""; + +// Строка-сепаратор, которой Qwen помечает конец reasoning в деградированном +// выводе (без полноценных тегов). Вырезаем строку целиком. +const THINKING_COMPLETED_RE = /(?:^|\n)[ \t]*Thinking completed[ \t]*(?=\n|$)/g; + +export function stripThinkBlocks(text) { + const s = String(text || ""); + if (!s) return s; + let out = ""; + let i = 0; + let inThink = false; + while (i < s.length) { + if (!inThink) { + const open = s.indexOf(THINK_OPEN, i); + if (open === -1) { + out += s.slice(i); + break; + } + out += s.slice(i, open); + i = open + THINK_OPEN.length; + inThink = true; + } else { + const close = s.indexOf(THINK_CLOSE, i); + if (close === -1) { + // Незакрытый reasoning: весь хвост — черновики, подавляем. + i = s.length; + break; + } + i = close + THINK_CLOSE.length; + inThink = false; + } + } + const cleaned = out.replace(THINKING_COMPLETED_RE, "\n"); + return /\S/.test(cleaned) ? cleaned.replace(/\n{3,}/g, "\n\n") : ""; +} + +// Стриминговая обёртка. Держим до (len(THINK_OPEN)-1) символов «на подозрении»: +// если это начало "" — не эмитим; если оказалось обычным текстом — +// эмитим при поступлении следующего чанка или на flush. +export function createThinkTagFilter({ onText = null } = {}) { + let hold = ""; + let suppress = false; // внутри ... + + function emit(text) { + if (text && typeof onText === "function") onText(text); + } + + return { + push(delta) { + hold += String(delta || ""); + let out = ""; + let progress = true; + while (progress) { + progress = false; + if (suppress) { + // Ищем закрывающий тег (в т.ч. частичный в самом конце хвоста). + const close = hold.indexOf(THINK_CLOSE); + if (close !== -1) { + hold = hold.slice(close + THINK_CLOSE.length); + suppress = false; + progress = true; + continue; + } + // Закрывающего нет: подавляем всё, КРОМЕ возможного частичного + // префикса " 0; len -= 1) { + if (s.endsWith(tag.slice(0, len))) return len; + // Суффикс "" длины 7 — это сам тег, не частичный (обработан выше). + } + return 0; +} diff --git a/test/qwen-think-tag-filter.test.mjs b/test/qwen-think-tag-filter.test.mjs new file mode 100644 index 0000000..54eeb72 --- /dev/null +++ b/test/qwen-think-tag-filter.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createThinkTagFilter, stripThinkBlocks } from "../api/think-filter.mjs"; +import { parseModelToolCalls } from "../api/tool-calls.mjs"; + +// Сэмпл из живого лога 2026-08-21: деградировавший Qwen шлёт reasoning +// ЛИТЕРАЛЬНЫМ текстом с -тегами. Внутри reasoning — черновики +// tool-call JSON, которые парсер выдёргивал как реальные вызовы +// ("Tool terminal does not exists" каскад в Hermes). + +const THINK_DRAFT = `Wait, search_files with target='files' and pattern='*' might be better, but execute_code gives me exactly what I want. +I'll also grep for "account" and "session" in FreeQwenApi. +{"name": "terminal", "arguments": {"command": "grep -rn account D:\\freeqwenapi"}} +Let's just use execute_code to read some key files. +Thinking completed`; + +const REAL_CALL = '[\n {\n "name": "terminal",\n "arguments": { "command": "echo hi" }\n }\n]'; + +describe("stripThinkBlocks", () => { + it("вырезает -блок с черновиками tool-call JSON", () => { + const text = `Проверяю.\n\n${THINK_DRAFT}\n\nГотово, вот ответ.`; + const clean = stripThinkBlocks(text); + assert.equal(clean.includes("Wait, search_files"), false); + assert.equal(clean.includes('"name": "terminal"'), false); + assert.equal(clean.includes("Thinking completed"), false); + assert.equal(clean.includes("Проверяю."), true); + assert.equal(clean.includes("Готово, вот ответ."), true); + }); + + it("незакрытый до конца текста = весь хвост это reasoning", () => { + const clean = stripThinkBlocks(`Ответ.\n\n${THINK_DRAFT}`); + assert.equal(clean.trim(), "Ответ."); + }); + + it("строка-сепаратор 'Thinking completed' вырезается и без тегов", () => { + const clean = stripThinkBlocks("До.\nThinking completed\nПосле."); + assert.equal(clean.includes("Thinking completed"), false); + assert.equal(clean.includes("До."), true); + assert.equal(clean.includes("После."), true); + }); + + it("текст без тегов не трогается", () => { + const text = "Обычный ответ. 1 < 2. тоже обычный."; + assert.equal(stripThinkBlocks(text), text); + }); + + it("parseModelToolCalls не выдёргивает черновики из think-блока", () => { + const text = `\n${THINK_DRAFT}\n\nВот итог:\n\n\`\`\`tool_calls\n${REAL_CALL}\n\`\`\`\n`; + const { calls } = parseModelToolCalls(text); + assert.equal(calls.length, 1); + assert.equal(JSON.parse(calls[0].arguments).command, "echo hi"); + }); +}); + +describe("createThinkTagFilter (стриминг)", () => { + it("подавляет reasoning-дельты и пропускает чистый текст", () => { + const emitted = []; + const f = createThinkTagFilter({ onText: (t) => emitted.push(t) }); + f.push("До. "); + f.push("\n"); + f.push(THINK_DRAFT); + f.push("\n\n"); + f.push("После."); + f.flush(); + const joined = emitted.join(""); + assert.equal(joined.includes("Wait, search_files"), false); + assert.equal(joined.includes("До. "), true); + assert.equal(joined.includes("После."), true); + }); + + it("тег, разрезанный по чанкам, не протекает", () => { + const emitted = []; + const f = createThinkTagFilter({ onText: (t) => emitted.push(t) }); + f.push("текст черновик "); + f.push(" хвост"); + f.flush(); + const joined = emitted.join(""); + assert.equal(joined.includes("черновик"), false); + assert.equal(joined.includes(""), false); + assert.equal(joined.includes("текст "), true); + assert.equal(joined.includes(" хвост"), true); + }); + + it("незакрытый до конца стрима: хвост подавлен, flush молчит", () => { + const emitted = []; + const f = createThinkTagFilter({ onText: (t) => emitted.push(t) }); + f.push("видимый "); + f.push(" набросок без закрытия"); + f.flush(); + assert.equal(emitted.join(""), "видимый "); + }); + + it("частичный префикс тега в конце стрима эмитится как текст", () => { + const emitted = []; + const f = createThinkTagFilter({ onText: (t) => emitted.push(t) }); + f.push("меньше Date: Fri, 21 Aug 2026 03:33:15 +0300 Subject: [PATCH 06/13] fix(api): handle interleaved thinking and stray in think filter Qwen regularly interleaves think/answer phases several times per response (observed with dense contexts through the proxy): think -> short answer -> think -> short answer -> ... -> final answer. - the filter is a state machine, so interleaved phases pass through any number of switches in both normal and degraded (literal-tag) modes - new: strip stray closers that appear without an opening tag (degraded streams sometimes lose the opener); previously they leaked into the visible answer as literal '' text - streaming wrapper now holds partial prefixes of BOTH tags (' { assert.equal(emitted.join(""), "меньше (деградация: закрывающий без открывающего)", () => { + it("stripThinkBlocks вырезает осиротевшие из ответа", () => { + const text = "Короткий ответ 1\n\nЕщё текст\n\nфинал"; + const clean = stripThinkBlocks(text); + assert.equal(clean.includes(""), false); + assert.equal(clean.includes("Короткий ответ 1"), true); + assert.equal(clean.includes("финал"), true); + }); + + it("стриминг: голый по чанкам не протекает", () => { + const emitted = []; + const f = createThinkTagFilter({ onText: (t) => emitted.push(t) }); + f.push("ответ раз\n\nответ два\n\nфинал"); + f.flush(); + const joined = emitted.join(""); + assert.equal(joined.includes(""), false); + assert.equal(joined.includes("ответ раз"), true); + assert.equal(joined.includes("ответ два"), true); + assert.equal(joined.includes("финал"), true); + }); + + it("interleaved: think/answer/think/answer многократно, всё чисто", () => { + const text = "черновик 1 с JSON {\"name\":\"terminal\"}короткий 1" + + "черновик 2 {\"name\":\"execute_code\"}короткий 2" + + "черновик 3финальный ответ"; + const clean = stripThinkBlocks(text); + assert.equal(clean, "короткий 1короткий 2финальный ответ"); + }); + + it("стриминг interleaved: три фазы думанья, ответы сохраняются", () => { + const emitted = []; + const f = createThinkTagFilter({ onText: (t) => emitted.push(t) }); + f.push("думает 1"); + f.push("resp1думает 2"); + f.push("resp2думает 3"); + f.push("FINAL"); + f.flush(); + assert.equal(emitted.join(""), "resp1resp2FINAL"); + }); +}); From 47422b5f62a82cb377a46221ea726bc99f63e79d Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 21 Aug 2026 05:50:56 +0300 Subject: [PATCH 07/13] fix(qwen): recover truncated streams via response_id resume and history harvest - Track response_id + terminal markers ([DONE], finish_reason, delta.status=finished) in the incremental SSE parser; flag streams that die mid-generation as truncated instead of silently returning partial text as success. - Add recoverTruncatedQwenStream: on truncation, re-attach via an empty POST /completions?chat_id=..&response_id=.. (server continues the same SSE response; verified from web-UI HAR) up to QWEN_RESUME_MAX_ATTEMPTS times, then harvest the finished answer from GET /api/v2/chats/{id} history (new harvest.mjs module). - Disable blind re-POST of the full completion body: browser-proxy no longer retries a POST after stream chunks were received, and client no longer re-throws auth failure when recovery already collected text. Blind re-POSTs created sibling branches (2/2, 3/4) in the web UI and duplicated content. - Treat function tools named web_search as CLIENT tools, not native provider search: matching them enabled auto_search + a lying 'Web search is enabled' prompt prefix, making the model call its internal search tool and cascade 'Tool web search does not exist'. Hosted tool types (web_search_20250305 etc.) still enable provider search. Fixes tool-hallucination loops seen via Hermes Gateway. - New proxyApiGet for same-origin GETs (chat history). - Tests: parser lifecycle (terminal markers, truncation detection), continuation budget, harvest pagination/failure modes, end-to-end recovery pipeline with injected proxy, native-search detection (567 tests, 0 fail). --- .env.example | 14 + api/openai-handler.mjs | 20 +- plugin-for-vscode/api/openai-handler.mjs | 20 +- .../src/providers/qwen/browser-proxy.mjs | 53 +++- .../src/providers/qwen/client.mjs | 275 +++++++++++++++++- .../src/providers/qwen/harvest.mjs | 125 ++++++++ src/providers/qwen/browser-proxy.mjs | 53 +++- src/providers/qwen/client.mjs | 275 +++++++++++++++++- src/providers/qwen/harvest.mjs | 125 ++++++++ test/qwen-harvest.test.mjs | 95 ++++++ test/qwen-native-search-tool.test.mjs | 48 +++ test/qwen-recovery-pipeline.test.mjs | 115 ++++++++ test/qwen-stream-resume.test.mjs | 108 +++++++ 13 files changed, 1294 insertions(+), 32 deletions(-) create mode 100644 plugin-for-vscode/src/providers/qwen/harvest.mjs create mode 100644 src/providers/qwen/harvest.mjs create mode 100644 test/qwen-harvest.test.mjs create mode 100644 test/qwen-native-search-tool.test.mjs create mode 100644 test/qwen-recovery-pipeline.test.mjs create mode 100644 test/qwen-stream-resume.test.mjs diff --git a/.env.example b/.env.example index c9b10ff..d5c6e14 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,20 @@ # QWEN_BX_UA="" # QWEN_BX_UMIDTOKEN="" +# Stream recovery (2026-08): when Qwen drops an SSE stream mid-generation +# (server-side break, no terminal marker), the client first re-attaches via +# an empty POST ?response_id=... (up to QWEN_RESUME_MAX_ATTEMPTS times, pause +# QWEN_RESUME_RETRY_DELAY_MS between attempts) and then harvests the saved +# answer from GET /api/v2/chats/{id} history (QWEN_HARVEST_MAX_PAGES pages, +# QWEN_HARVEST_RETRIES retries with QWEN_HARVEST_RETRY_DELAY_MS pause). +# Blind re-POST of the full body is disabled: it created sibling branches +# ("2/2", "3/4") in the web UI and duplicated text. +# QWEN_RESUME_MAX_ATTEMPTS="2" +# QWEN_RESUME_RETRY_DELAY_MS="1500" +# QWEN_HARVEST_MAX_PAGES="5" +# QWEN_HARVEST_RETRIES="1" +# QWEN_HARVEST_RETRY_DELAY_MS="3000" + # ---- model_type для режимов в UI (опционально) ---- # Каждый режим (Быстрый / Эксперт / Распознание) маппится в model_type для API. # Точные строки зависят от того, что DeepSeek принимает на бэкенде. Как узнать: diff --git a/api/openai-handler.mjs b/api/openai-handler.mjs index 90a00ff..b5f4c16 100644 --- a/api/openai-handler.mjs +++ b/api/openai-handler.mjs @@ -1301,14 +1301,22 @@ function hasNativeWebSearchTool(tools) { return Array.isArray(tools) && tools.some(isNativeWebSearchTool); } +// Нативный (провайдерский) web-search определяется ТОЛЬКО по hosted-формату +// тулзы: OpenAI Responses/Chat Compat ({ type: "web_search_20250305" }) или +// Anthropic ({ type: "web_search_20250305", name: "web_search" }). +// +// Function-тул с именем web_search (Hermes Gateway и подобные клиенты +// шлюют его как { type: "function", function: { name: "web_search" } }) — +// это КЛИЕНТСКИЙ тул: модель должна вызывать его через ```tool_calls, +// а Hermes исполняет и возвращает результат. Матчить его как нативный +// нельзя: это включает auto_search у Qwen + врущий промпт-префикс +// "Web search is enabled" — модель доверяет, зовёт свой внутренний +// поиск, которого нет в зарегистрированных тулзах, и каскадит +// "Tool web search does not exist" (issue: thinking-дампы 2026-08). function isNativeWebSearchTool(tool) { const type = String(tool?.type || tool?.function?.type || "").toLowerCase(); - const name = String(tool?.name || tool?.function?.name || "").toLowerCase(); - return type.includes("web_search") || - type.includes("web-search") || - name === "web_search" || - name === "web_search_preview" || - name.includes("web_search"); + if (!type) return false; + return type.includes("web_search") || type.includes("web-search"); } export function toolsForModelPrompt(tools) { diff --git a/plugin-for-vscode/api/openai-handler.mjs b/plugin-for-vscode/api/openai-handler.mjs index 90a00ff..b5f4c16 100644 --- a/plugin-for-vscode/api/openai-handler.mjs +++ b/plugin-for-vscode/api/openai-handler.mjs @@ -1301,14 +1301,22 @@ function hasNativeWebSearchTool(tools) { return Array.isArray(tools) && tools.some(isNativeWebSearchTool); } +// Нативный (провайдерский) web-search определяется ТОЛЬКО по hosted-формату +// тулзы: OpenAI Responses/Chat Compat ({ type: "web_search_20250305" }) или +// Anthropic ({ type: "web_search_20250305", name: "web_search" }). +// +// Function-тул с именем web_search (Hermes Gateway и подобные клиенты +// шлюют его как { type: "function", function: { name: "web_search" } }) — +// это КЛИЕНТСКИЙ тул: модель должна вызывать его через ```tool_calls, +// а Hermes исполняет и возвращает результат. Матчить его как нативный +// нельзя: это включает auto_search у Qwen + врущий промпт-префикс +// "Web search is enabled" — модель доверяет, зовёт свой внутренний +// поиск, которого нет в зарегистрированных тулзах, и каскадит +// "Tool web search does not exist" (issue: thinking-дампы 2026-08). function isNativeWebSearchTool(tool) { const type = String(tool?.type || tool?.function?.type || "").toLowerCase(); - const name = String(tool?.name || tool?.function?.name || "").toLowerCase(); - return type.includes("web_search") || - type.includes("web-search") || - name === "web_search" || - name === "web_search_preview" || - name.includes("web_search"); + if (!type) return false; + return type.includes("web_search") || type.includes("web-search"); } export function toolsForModelPrompt(tools) { diff --git a/plugin-for-vscode/src/providers/qwen/browser-proxy.mjs b/plugin-for-vscode/src/providers/qwen/browser-proxy.mjs index 2e4a677..8333611 100644 --- a/plugin-for-vscode/src/providers/qwen/browser-proxy.mjs +++ b/plugin-for-vscode/src/providers/qwen/browser-proxy.mjs @@ -482,7 +482,17 @@ async function createProxy({ debug }) { const firstContentTimeoutMs = Number(streamFirstContentTimeoutMs || QWEN_STREAM_FIRST_CONTENT_TIMEOUT_MS); const idleTimeoutMs = Number(streamIdleTimeoutMs || QWEN_STREAM_IDLE_TIMEOUT_MS); const attempts = Math.max(1, Math.min(5, Number(maxAttempts || QWEN_PROXY_MAX_ATTEMPTS))); - rawStreamHandler = typeof onRawChunk === "function" ? onRawChunk : null; + // Признак того, что сервер уже начал отдавать SSE-чанки. Если после этого + // fetch умер (status 0 / abort), повторный POST того же body создал бы + // sibling-ветку в дереве сообщений и дублировал текст — вместо этого + // обрыв уходит наверх, и клиент восстанавливает стрим по response_id. + let sawRawChunk = false; + rawStreamHandler = typeof onRawChunk === "function" + ? (chunk) => { + sawRawChunk = true; + return onRawChunk(chunk); + } + : null; try { for (let attempt = 0; attempt < attempts; attempt += 1) { try { @@ -493,6 +503,7 @@ async function createProxy({ debug }) { const accept = isCompletionRequest ? "application/json" : "application/json, text/plain, */*"; + sawRawChunk = false; result = await Promise.race([ worker.page.evaluate( async ({ url, body, fetchTimeoutMs, streamFirstContentTimeoutMs, streamIdleTimeoutMs, requestId, accept, isCompletionRequest, authToken }) => { @@ -624,10 +635,23 @@ async function createProxy({ debug }) { ), ]); if (result.status !== 0 || attempt === attempts - 1) break; + if (sawRawChunk) { + // Чанки уже шли: POST был принят сервером и генерация началась. + // Re-POST создал бы sibling-ветку — отдаём обрыв наверх для + // resume по response_id (клиент) вместо слепого повтора. + if (debug) console.log(`[qwen-proxy:${worker.label}] stream died mid-response (chunks already received) — NOT re-POSTing, handing break to resume`); + break; + } if (debug) console.log(`[qwen-proxy:${worker.label}] stream fetch failed before HTTP response; reloading page and retrying`); await reloadWorker(worker); } catch (error) { lastError = error; + // isCompletionRequest объявлен в try и здесь не виден — пере-тест URL. + if (sawRawChunk && /\/api\/v2\/chat\/completions(?:$|\?)/.test(url)) { + // Генерация уже шла — повторный POST запретен (sibling-ветки). + if (debug) console.log(`[qwen-proxy:${worker.label}] stream failed mid-generation — NOT re-POSTing, handing break to resume`); + throw error; + } if (!isTransientBrowserError(error) || attempt === attempts - 1) throw error; if (debug) console.log(`[qwen-proxy:${worker.label}] transient browser error during stream; reloading: ${error.message}`); try { @@ -726,6 +750,33 @@ async function createProxy({ debug }) { { path, body, timeoutMs }, )); }, + // Same-origin GET к API chat.qwen.ai из контекста страницы — для чтения + // истории чата (harvest сохранённого ответа после обрыва стрима). + async proxyApiGet({ path, chatId, timeoutMs = 30_000 }) { + const worker = pickWorker(chatId || null); + return enqueue(worker, () => worker.page.evaluate( + async ({ path, timeoutMs }) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort("qwen_fetch_timeout"), timeoutMs); + try { + const res = await fetch(path, { + method: "GET", + headers: { + accept: "application/json, text/plain, */*", + source: "web", + }, + credentials: "include", + signal: controller.signal, + }); + const json = await res.json().catch(() => null); + return { ok: res.ok, status: res.status, json }; + } finally { + clearTimeout(timeoutId); + } + }, + { path, timeoutMs }, + )); + }, async close() { await close(); }, }; } diff --git a/plugin-for-vscode/src/providers/qwen/client.mjs b/plugin-for-vscode/src/providers/qwen/client.mjs index 62091d8..f2ef544 100644 --- a/plugin-for-vscode/src/providers/qwen/client.mjs +++ b/plugin-for-vscode/src/providers/qwen/client.mjs @@ -30,6 +30,7 @@ import { import { getQwenBrowserProxy, resetQwenBrowserProxy } from "./browser-proxy.mjs"; import { buildQwenCompletionPayload } from "./completion-payload.mjs"; import { resolveQwenContextFileConfig, splitPromptForFileUpload, uploadQwenContextFile } from "./context-file.mjs"; +import { harvestQwenChatMessage } from "./harvest.mjs"; import { waitForQwenCompletionSlot, createQwenPunishError, qwenAntibotCooldownRemainingMs } from "./request-pacing.mjs"; import { createFileLogger } from "../../logging/logger.mjs"; @@ -511,6 +512,30 @@ export class QwenChatClient { if (result.punish) { throw createQwenPunishError(qwenAntibotCooldownRemainingMs()); } + + // Стрим оборвался без терминального маркера (текст шёл, потом + // тишина/abort). Порядок восстановления (см. HAR 2026-08-21): + // 1. RESUME: пустой POST ?response_id=... — сервер продолжает + // тот же SSE-ответ, sibling-ветки не плодятся. + // 2. HARVEST: GET истории чата — сервер часто уже сохранил + // готовый ответ; забираем его без единого ретрая. + // Слепой re-POST полного body ЗАПРЕЩЁН: он создавал "2/2", "3/4" + // ретраи в веб-морде и дублированный текст у пользователя. + let parsed = useLiveStream + ? streamParser.finish(result.text) + : parseQwenResponseText(result.text, result.contentType, onText); + + if (useLiveStream && parsed?.truncated && parsed?.responseId) { + const recoveredStream = await this.#recoverTruncatedStream({ + chatId, + truncated: parsed, + streamParser, + onText, + onThinking, + }); + if (recoveredStream) parsed = recoveredStream; + } + providerLogger.info("provider.qwen.response", { operation: "completion", transport: "browser", @@ -518,6 +543,9 @@ export class QwenChatClient { statusCode: result.status, contentType: result.contentType, responseBytes: result.text?.length || 0, + streamTruncated: Boolean(parsed?.truncated), + streamResumed: Boolean(parsed?.resumed), + harvested: Boolean(parsed?.harvested), }); if (this.debug) { @@ -531,14 +559,13 @@ export class QwenChatClient { } catch {} } - if (!result.ok) { + // result.ok=false (status 0 / HTTP-ошибка): бросаем ТОЛЬКО если + // recovery (resume/harvest) не собрал текст. Если обрыв стрима + // был восстановлен — работаем с parsed дальше как с успехом. + if (!result.ok && !parsed?.text && !parsed?.thinkingText) { throwIfQwenAuthFailure(result.status, result.text, "completion (browser)"); } - const parsed = useLiveStream - ? streamParser.finish(result.text) - : parseQwenResponseText(result.text, result.contentType, onText); - if (isQwenInvalidParentError(parsed, result.text)) { return { result: null, chatInProgressStuck: false, invalidParentStuck: true }; } @@ -620,6 +647,22 @@ export class QwenChatClient { const directFinalized = finalizeQwenCompletionResult(parsed, directText, "completion (direct)"); return { result: directFinalized, chatInProgressStuck: false }; } + + // Восстановление оборванного SSE-стрима: сначала resume-POST по + // response_id (сервер продолжает тот же ответ), затем harvest из + // истории чата (сервер уже сохранил готовый ответ). + // Возвращает новый parsed-результат или null (восстановить не удалось — + // вызывающий код работает с частичным текстом как раньше). + async #recoverTruncatedStream({ chatId, truncated, onText, onThinking }) { + return recoverTruncatedQwenStream({ + chatId, + truncated, + onText, + onThinking, + getProxy: () => getQwenBrowserProxy({ debug: this.debug }), + debug: this.debug, + }); + } } // Понятное сообщение для пользователя, когда «The chat is in progress!» @@ -770,13 +813,25 @@ export function createQwenIncrementalParser({ onText = null, onThinking = null } let thinkingBuf = ""; let lastMessageId = null; let error = null; + // Жизненный цикл стрима: нужен, чтобы отличить честное окончание + // ([DONE] / delta.status=finished / finish_reason) от обрыва посреди + // генерации — обрыв восстанавливается resume-POST по response_id. + let streamFinished = false; + let streamResponseId = ""; const responseSelector = createQwenResponseSelector(); function consumeEvent(raw) { const ev = parseSseEvent(raw); - if (!ev.data || ev.data === "[DONE]") return; + if (!ev.data) return; + if (ev.data === "[DONE]") { + streamFinished = true; + return; + } let parsed; try { parsed = JSON.parse(ev.data); } catch { return; } + const rid = qwenStreamResponseIdOf(parsed); + if (rid) streamResponseId = rid; + if (qwenStreamHasTerminalMarker(parsed)) streamFinished = true; if (!responseSelector.accept(parsed)) return; const errMsg = formatQwenStreamError(parsed); if (errMsg) { @@ -811,18 +866,220 @@ export function createQwenIncrementalParser({ onText = null, onThinking = null } }, finish(rawFallback = "") { if (buffer.trim()) consumeEvent(buffer); + // Обрыв посреди генерации: контент шёл, но терминального маркера не + // было. Отдаём частичный текст + флаги, чтобы вызывающий слой мог + // переподключиться по response_id (см. createQwenStreamContinuation). + const truncated = !streamFinished && (fullText.length > 0 || thinkingBuf.length > 0); if (error) { - return { text: error, lastMessageId, thinkingText: thinkingBuf, error }; + return { text: error, lastMessageId, thinkingText: thinkingBuf, error, streamFinished, truncated, responseId: streamResponseId, contentReceived: false }; } if (!fullText && !thinkingBuf) { const fallback = emptyQwenParseFallback(rawFallback, rawFallback, 0); - return { ...fallback, error: null }; + // contentReceived=false: это диагностическое сообщение, а НЕ реальный + // контент стрима. Resume-конвейер не должен принимать его за успех. + return { ...fallback, error: null, streamFinished, truncated: false, responseId: streamResponseId, contentReceived: false }; } - return { text: fullText, lastMessageId, thinkingText: thinkingBuf, error: null }; + return { text: fullText, lastMessageId, thinkingText: thinkingBuf, error: null, streamFinished, truncated, responseId: streamResponseId, contentReceived: true }; }, }; } +// response_id текущей генерации: приходит и в response.created, и в каждом +// content-чанке. Нужен для resume-POST (?response_id=...) и для harvest. +export function qwenStreamResponseIdOf(parsed) { + const created = parsed?.["response.created"]; + if (created && typeof created === "object" && created.response_id != null) { + return String(created.response_id); + } + if (parsed?.response_id != null) return String(parsed.response_id); + return ""; +} + +// Терминальные маркеры SSE-стрима Qwen (любой из них = генерация завершена): +// • choices[0].finish_reason (OpenAI-совместимый маркер) +// • choices[0].delta.status === "finished" (собственный формат Qwen v2) +// [DONE] обрабатывается отдельно в consumeEvent. +export function qwenStreamHasTerminalMarker(parsed) { + const choice = Array.isArray(parsed?.choices) ? parsed.choices[0] : null; + if (!choice) return false; + if (choice.finish_reason) return true; + const status = choice?.delta?.status; + return status === "finished" || status === "completed"; +} + +// Состояние сессии продолжения оборванного стрима. Морда шлёт ПУСТОЙ POST +// /api/v2/chat/completions?chat_id=&response_id= и сервер продолжает +// тот же SSE-ответ — без создания sibling-ветки в дереве сообщений (слепой +// re-POST полного body порождает "2/2", "3/4" ретраи в веб-интерфейсе). +export function createQwenStreamContinuation({ chatId, responseId } = {}) { + const rid = String(responseId || ""); + const maxResumes = Math.max(0, Number(process.env.QWEN_RESUME_MAX_ATTEMPTS ?? 2)); + let resumesUsed = 0; + + return { + resumeUrl() { + return `${QWEN_BASE_URL}/api/v2/chat/completions?chat_id=${encodeURIComponent(String(chatId || ""))}&response_id=${encodeURIComponent(rid)}`; + }, + // Морда шлёт пустое тело; заголовки (bx-*, source: web, x-request-id) + // проставляет browser-proxy как для обычного completion-запроса. + resumeBody() { + return "{}"; + }, + shouldResume({ truncated, streamFinished: finished } = {}) { + if (!rid) return false; + if (finished) return false; + if (!truncated) return false; + return resumesUsed < maxResumes; + }, + markResumeAttempt() { + resumesUsed += 1; + }, + remainingResumes() { + return Math.max(0, maxResumes - resumesUsed); + }, + retryDelayMs() { + return Number(process.env.QWEN_RESUME_RETRY_DELAY_MS || 1500); + }, + get responseId() { + return rid; + }, + }; +} + +// Полный конвейер восстановления оборванного стрима. Прокси и паузы +// инжектируются (getProxy / retryDelayMs) — тестируется без браузера. +export async function recoverTruncatedQwenStream({ + chatId, + truncated, + onText = null, + onThinking = null, + getProxy, + debug = false, +}) { + const continuation = createQwenStreamContinuation({ + chatId, + responseId: truncated?.responseId, + }); + + // --- Этап 1: RESUME (пустой POST ?response_id=...) --- + while (continuation.shouldResume({ truncated: true, streamFinished: false })) { + continuation.markResumeAttempt(); + try { + const proxy = await getProxy(); + if (debug) console.log(`[qwen] stream truncated mid-generation; resuming response_id=${truncated.responseId} (remaining ${continuation.remainingResumes()})…`); + providerLogger.warn("provider.qwen.stream_resume", { + operation: "resume", + chatId, + responseId: truncated.responseId, + remaining: continuation.remainingResumes(), + }); + const resumeParser = createQwenIncrementalParser({ onText, onThinking }); + const resumeResult = await proxy.proxyFetchStream({ + url: continuation.resumeUrl(), + body: continuation.resumeBody(), + chatId, + onRawChunk: (chunk) => resumeParser.push(chunk), + maxAttempts: 1, + }); + const resumeParsed = resumeParser.finish(resumeResult.text || ""); + const resumeGotContent = Boolean(resumeParsed.contentReceived); + if (resumeParsed.streamFinished || (resumeGotContent && !resumeParsed.truncated)) { + // Сервер продолжил стрим: склеиваем хвост с уже полученным текстом. + // Дельту уже отдал в onText сам resumeParser (живой стрим наверх). + // Диагностический текст от emptyQwenParseFallback (contentReceived=false) + // успехом НЕ считается — идём к harvest. + return { + ...(resumeGotContent ? resumeParsed : {}), + text: resumeGotContent ? truncated.text + (resumeParsed.text || "") : truncated.text, + thinkingText: (truncated.thinkingText || "") + (resumeGotContent ? resumeParsed.thinkingText || "" : ""), + resumed: true, + truncated: false, + streamFinished: resumeParsed.streamFinished || resumeGotContent, + responseId: truncated.responseId, + }; + } + if (debug) console.log(`[qwen] resume attempt returned no continuation (${resumeResult.status}, bytes=${resumeResult.text?.length || 0})`); + } catch (error) { + providerLogger.warn("provider.qwen.stream_resume", { + operation: "resume_error", + chatId, + responseId: truncated.responseId, + error: error?.message || String(error), + }); + } + await waitForQwenChat(continuation.retryDelayMs()); + } + + // --- Этап 2: HARVEST (GET истории чата) --- + try { + const proxy = await getProxy(); + if (debug) console.log(`[qwen] resume exhausted; harvesting saved response ${truncated.responseId} from chat history…`); + providerLogger.warn("provider.qwen.stream_resume", { operation: "harvest", chatId, responseId: truncated.responseId }); + const harvested = await harvestQwenChatMessage({ + chatId, + responseId: truncated.responseId, + fetcher: async ({ cursor }) => proxy.proxyApiGet({ + path: cursor + ? `/api/v2/chats/${encodeURIComponent(chatId)}?cursor=${encodeURIComponent(cursor)}&direction=down&limit=10` + : `/api/v2/chats/${encodeURIComponent(chatId)}?direction=up&limit=10`, + }), + }); + if (harvested.found && harvested.text) { + // В streaming-режиме дельты уже ушли только до места обрыва — + // до streamed-текста. Чтобы клиент получил хвост, вычисляем суффикс + // сохранённого ответа и отдаём его через onText. Если сервер + // переписал начало (общий префикс короткий) — не стримим ничего: + // дублировать весь текст хуже, чем оставить честный обрыв. + streamHarvestTail({ + harvestedText: harvested.text, + streamedText: String(truncated.text || ""), + onText, + }); + return { + text: harvested.text, + lastMessageId: harvested.messageId, + thinkingText: truncated.thinkingText || "", + error: null, + streamFinished: true, + truncated: false, + responseId: truncated.responseId, + harvested: true, + }; + } + if (debug) console.log(`[qwen] harvest not found (${harvested.reason})`); + } catch (error) { + providerLogger.warn("provider.qwen.stream_resume", { + operation: "harvest_error", + chatId, + responseId: truncated.responseId, + error: error?.message || String(error), + }); + } + + return null; +} + +// Отдаёт через onText недостающий хвост harvest-текста (то, что сервер +// сгенерировал ПОСЛЕ обрыва стрима). Если streamed-текст не является +// префиксом сохранённого (сервер переписал начало) — хвост не стримим: +// дублирование хуже честного обрыва. +export function streamHarvestTail({ harvestedText, streamedText, onText }) { + if (typeof onText !== "function") return; + const saved = String(harvestedText || ""); + const streamed = String(streamedText || ""); + if (!saved) return; + if (streamed && saved.startsWith(streamed)) { + const tail = saved.slice(streamed.length); + if (tail) onText(tail); + return; + } + // Нет общего префикса (или streamed пуст и это не-стрим вызов) — + // отдаём сохранённый текст целиком, только если наверх ничего не ушло. + if (!streamed) { + onText(saved); + } +} + function findQwenErrorInSseText(text) { const blocks = text.split(/\r?\n\r?\n/).filter(Boolean); for (const raw of blocks) { diff --git a/plugin-for-vscode/src/providers/qwen/harvest.mjs b/plugin-for-vscode/src/providers/qwen/harvest.mjs new file mode 100644 index 0000000..346b200 --- /dev/null +++ b/plugin-for-vscode/src/providers/qwen/harvest.mjs @@ -0,0 +1,125 @@ +// Harvest: достаём сохранённый ответ Qwen из истории чата (2026-08). +// +// Когда стрим оборвался, а resume-POST (по response_id) не восстановил +// генерацию, сервер часто уже ДОДУМАЛ ответ и сохранил его в дерево +// сообщений чата. Вместо слепого повторного POST (который плодит +// sibling-ветки "2/2", "3/4") читаем историю: +// +// GET /api/v2/chats/{chatId}?direction=up&limit=10 +// GET /api/v2/chats/{chatId}?cursor={msgId}&direction=down&limit=10 +// +// и ищем сообщение с id (или response_id) == responseId. Если нашли — +// отдаём его контент как готовый ответ без каких-либо ретраев. +// +// Модуль намеренно не знает про browser-proxy: fetcher инжектится +// вызывающим слоем (прокси-GET или прямой fetch), что делает его +// тривиально тестируемым. + +// Максимальное число страниц истории, которые просматриваем при поиске. +const MAX_HISTORY_PAGES = Number(process.env.QWEN_HARVEST_MAX_PAGES || 5); + +// Пауза перед harvest-попыткой: если сервер ещё генерирует, ответ ещё не +// сохранён — короткий sleep и повторный GET повышают шанс успеть к финалу. +const HARVEST_RETRY_DELAY_MS = Number(process.env.QWEN_HARVEST_RETRY_DELAY_MS || 3000); +const HARVEST_RETRIES = Number(process.env.QWEN_HARVEST_RETRIES ?? 1); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function messageNodeResponseId(node) { + if (node?.response_id != null) return String(node.response_id); + return ""; +} + +function messageNodeId(node) { + if (node?.id != null) return String(node.id); + return ""; +} + +// Один GET истории (одна страница). fetcher({ cursor, direction, limit }) +// возвращает либо распарсенный payload напрямую ({ data: { messages } }), +// либо обёртку browser-proxy proxyApiGet ({ ok, status, json }) — +// поддерживаем оба формата. +async function fetchHistoryPage(fetcher, chatId, { cursor = null, direction = "up", limit = 10 } = {}) { + const result = await fetcher({ chatId, cursor, direction, limit }); + const data = result?.data ?? result?.json?.data; + if (Array.isArray(data?.messages)) return data.messages; + if (Array.isArray(data)) return data; + return []; +} + +// Ищет узел с id/response_id == responseId среди страницы сообщений. +function findSavedResponse(messages, responseId) { + for (const node of messages) { + if (messageNodeId(node) === responseId) return node; + if (messageNodeResponseId(node) === responseId) return node; + } + return null; +} + +// Достаёт текст из сохранённого узла ассистента. Контент может быть +// строкой или массивом частей (как в HAR морды). +function savedNodeToText(node) { + const content = node?.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => (typeof part === "string" ? part : part?.text || "")) + .filter(Boolean) + .join(""); + } + return ""; +} + +export async function harvestQwenChatMessage({ + fetcher, + chatId, + responseId, + fetchChildren = false, + retries = HARVEST_RETRIES, + retryDelayMs = HARVEST_RETRY_DELAY_MS, + maxPages = MAX_HISTORY_PAGES, + logger = null, +}) { + const rid = String(responseId || ""); + if (!rid) return { found: false, reason: "no_response_id" }; + + let lastError = null; + for (let attempt = 0; attempt <= retries; attempt += 1) { + if (attempt > 0) await sleep(retryDelayMs); + try { + let cursor = null; + for (let page = 0; page < maxPages; page += 1) { + const messages = await fetchHistoryPage(fetcher, chatId, { cursor }); + const node = findSavedResponse(messages, rid); + if (node) { + const text = savedNodeToText(node); + if (text.trim()) { + return { found: true, text, messageId: messageNodeId(node) || rid, pages: page + 1, attempts: attempt + 1 }; + } + // Узел найден, но пуст — генерация ещё пишется; ждём и пробуем снова. + break; + } + const next = nextCursor(messages); + // Нет курсора или курсор не двигается — история просмотрена, дальше некуда. + if (!next || next === cursor) break; + cursor = next; + } + } catch (error) { + lastError = error; + logger?.warn?.("provider.qwen.harvest", { error: error?.message || String(error) }); + } + } + + if (lastError) return { found: false, reason: "http_error", error: lastError?.message || String(lastError) }; + return { found: false, reason: "not_saved_yet" }; +} + +// Курсор пагинации: берём id последнего сообщения страницы. +function nextCursor(messages, _prevCursor, direction) { + if (!Array.isArray(messages) || messages.length === 0) return null; + const last = messages[messages.length - 1]; + const id = messageNodeId(last); + return id || null; +} diff --git a/src/providers/qwen/browser-proxy.mjs b/src/providers/qwen/browser-proxy.mjs index 2e4a677..8333611 100644 --- a/src/providers/qwen/browser-proxy.mjs +++ b/src/providers/qwen/browser-proxy.mjs @@ -482,7 +482,17 @@ async function createProxy({ debug }) { const firstContentTimeoutMs = Number(streamFirstContentTimeoutMs || QWEN_STREAM_FIRST_CONTENT_TIMEOUT_MS); const idleTimeoutMs = Number(streamIdleTimeoutMs || QWEN_STREAM_IDLE_TIMEOUT_MS); const attempts = Math.max(1, Math.min(5, Number(maxAttempts || QWEN_PROXY_MAX_ATTEMPTS))); - rawStreamHandler = typeof onRawChunk === "function" ? onRawChunk : null; + // Признак того, что сервер уже начал отдавать SSE-чанки. Если после этого + // fetch умер (status 0 / abort), повторный POST того же body создал бы + // sibling-ветку в дереве сообщений и дублировал текст — вместо этого + // обрыв уходит наверх, и клиент восстанавливает стрим по response_id. + let sawRawChunk = false; + rawStreamHandler = typeof onRawChunk === "function" + ? (chunk) => { + sawRawChunk = true; + return onRawChunk(chunk); + } + : null; try { for (let attempt = 0; attempt < attempts; attempt += 1) { try { @@ -493,6 +503,7 @@ async function createProxy({ debug }) { const accept = isCompletionRequest ? "application/json" : "application/json, text/plain, */*"; + sawRawChunk = false; result = await Promise.race([ worker.page.evaluate( async ({ url, body, fetchTimeoutMs, streamFirstContentTimeoutMs, streamIdleTimeoutMs, requestId, accept, isCompletionRequest, authToken }) => { @@ -624,10 +635,23 @@ async function createProxy({ debug }) { ), ]); if (result.status !== 0 || attempt === attempts - 1) break; + if (sawRawChunk) { + // Чанки уже шли: POST был принят сервером и генерация началась. + // Re-POST создал бы sibling-ветку — отдаём обрыв наверх для + // resume по response_id (клиент) вместо слепого повтора. + if (debug) console.log(`[qwen-proxy:${worker.label}] stream died mid-response (chunks already received) — NOT re-POSTing, handing break to resume`); + break; + } if (debug) console.log(`[qwen-proxy:${worker.label}] stream fetch failed before HTTP response; reloading page and retrying`); await reloadWorker(worker); } catch (error) { lastError = error; + // isCompletionRequest объявлен в try и здесь не виден — пере-тест URL. + if (sawRawChunk && /\/api\/v2\/chat\/completions(?:$|\?)/.test(url)) { + // Генерация уже шла — повторный POST запретен (sibling-ветки). + if (debug) console.log(`[qwen-proxy:${worker.label}] stream failed mid-generation — NOT re-POSTing, handing break to resume`); + throw error; + } if (!isTransientBrowserError(error) || attempt === attempts - 1) throw error; if (debug) console.log(`[qwen-proxy:${worker.label}] transient browser error during stream; reloading: ${error.message}`); try { @@ -726,6 +750,33 @@ async function createProxy({ debug }) { { path, body, timeoutMs }, )); }, + // Same-origin GET к API chat.qwen.ai из контекста страницы — для чтения + // истории чата (harvest сохранённого ответа после обрыва стрима). + async proxyApiGet({ path, chatId, timeoutMs = 30_000 }) { + const worker = pickWorker(chatId || null); + return enqueue(worker, () => worker.page.evaluate( + async ({ path, timeoutMs }) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort("qwen_fetch_timeout"), timeoutMs); + try { + const res = await fetch(path, { + method: "GET", + headers: { + accept: "application/json, text/plain, */*", + source: "web", + }, + credentials: "include", + signal: controller.signal, + }); + const json = await res.json().catch(() => null); + return { ok: res.ok, status: res.status, json }; + } finally { + clearTimeout(timeoutId); + } + }, + { path, timeoutMs }, + )); + }, async close() { await close(); }, }; } diff --git a/src/providers/qwen/client.mjs b/src/providers/qwen/client.mjs index 62091d8..f2ef544 100644 --- a/src/providers/qwen/client.mjs +++ b/src/providers/qwen/client.mjs @@ -30,6 +30,7 @@ import { import { getQwenBrowserProxy, resetQwenBrowserProxy } from "./browser-proxy.mjs"; import { buildQwenCompletionPayload } from "./completion-payload.mjs"; import { resolveQwenContextFileConfig, splitPromptForFileUpload, uploadQwenContextFile } from "./context-file.mjs"; +import { harvestQwenChatMessage } from "./harvest.mjs"; import { waitForQwenCompletionSlot, createQwenPunishError, qwenAntibotCooldownRemainingMs } from "./request-pacing.mjs"; import { createFileLogger } from "../../logging/logger.mjs"; @@ -511,6 +512,30 @@ export class QwenChatClient { if (result.punish) { throw createQwenPunishError(qwenAntibotCooldownRemainingMs()); } + + // Стрим оборвался без терминального маркера (текст шёл, потом + // тишина/abort). Порядок восстановления (см. HAR 2026-08-21): + // 1. RESUME: пустой POST ?response_id=... — сервер продолжает + // тот же SSE-ответ, sibling-ветки не плодятся. + // 2. HARVEST: GET истории чата — сервер часто уже сохранил + // готовый ответ; забираем его без единого ретрая. + // Слепой re-POST полного body ЗАПРЕЩЁН: он создавал "2/2", "3/4" + // ретраи в веб-морде и дублированный текст у пользователя. + let parsed = useLiveStream + ? streamParser.finish(result.text) + : parseQwenResponseText(result.text, result.contentType, onText); + + if (useLiveStream && parsed?.truncated && parsed?.responseId) { + const recoveredStream = await this.#recoverTruncatedStream({ + chatId, + truncated: parsed, + streamParser, + onText, + onThinking, + }); + if (recoveredStream) parsed = recoveredStream; + } + providerLogger.info("provider.qwen.response", { operation: "completion", transport: "browser", @@ -518,6 +543,9 @@ export class QwenChatClient { statusCode: result.status, contentType: result.contentType, responseBytes: result.text?.length || 0, + streamTruncated: Boolean(parsed?.truncated), + streamResumed: Boolean(parsed?.resumed), + harvested: Boolean(parsed?.harvested), }); if (this.debug) { @@ -531,14 +559,13 @@ export class QwenChatClient { } catch {} } - if (!result.ok) { + // result.ok=false (status 0 / HTTP-ошибка): бросаем ТОЛЬКО если + // recovery (resume/harvest) не собрал текст. Если обрыв стрима + // был восстановлен — работаем с parsed дальше как с успехом. + if (!result.ok && !parsed?.text && !parsed?.thinkingText) { throwIfQwenAuthFailure(result.status, result.text, "completion (browser)"); } - const parsed = useLiveStream - ? streamParser.finish(result.text) - : parseQwenResponseText(result.text, result.contentType, onText); - if (isQwenInvalidParentError(parsed, result.text)) { return { result: null, chatInProgressStuck: false, invalidParentStuck: true }; } @@ -620,6 +647,22 @@ export class QwenChatClient { const directFinalized = finalizeQwenCompletionResult(parsed, directText, "completion (direct)"); return { result: directFinalized, chatInProgressStuck: false }; } + + // Восстановление оборванного SSE-стрима: сначала resume-POST по + // response_id (сервер продолжает тот же ответ), затем harvest из + // истории чата (сервер уже сохранил готовый ответ). + // Возвращает новый parsed-результат или null (восстановить не удалось — + // вызывающий код работает с частичным текстом как раньше). + async #recoverTruncatedStream({ chatId, truncated, onText, onThinking }) { + return recoverTruncatedQwenStream({ + chatId, + truncated, + onText, + onThinking, + getProxy: () => getQwenBrowserProxy({ debug: this.debug }), + debug: this.debug, + }); + } } // Понятное сообщение для пользователя, когда «The chat is in progress!» @@ -770,13 +813,25 @@ export function createQwenIncrementalParser({ onText = null, onThinking = null } let thinkingBuf = ""; let lastMessageId = null; let error = null; + // Жизненный цикл стрима: нужен, чтобы отличить честное окончание + // ([DONE] / delta.status=finished / finish_reason) от обрыва посреди + // генерации — обрыв восстанавливается resume-POST по response_id. + let streamFinished = false; + let streamResponseId = ""; const responseSelector = createQwenResponseSelector(); function consumeEvent(raw) { const ev = parseSseEvent(raw); - if (!ev.data || ev.data === "[DONE]") return; + if (!ev.data) return; + if (ev.data === "[DONE]") { + streamFinished = true; + return; + } let parsed; try { parsed = JSON.parse(ev.data); } catch { return; } + const rid = qwenStreamResponseIdOf(parsed); + if (rid) streamResponseId = rid; + if (qwenStreamHasTerminalMarker(parsed)) streamFinished = true; if (!responseSelector.accept(parsed)) return; const errMsg = formatQwenStreamError(parsed); if (errMsg) { @@ -811,18 +866,220 @@ export function createQwenIncrementalParser({ onText = null, onThinking = null } }, finish(rawFallback = "") { if (buffer.trim()) consumeEvent(buffer); + // Обрыв посреди генерации: контент шёл, но терминального маркера не + // было. Отдаём частичный текст + флаги, чтобы вызывающий слой мог + // переподключиться по response_id (см. createQwenStreamContinuation). + const truncated = !streamFinished && (fullText.length > 0 || thinkingBuf.length > 0); if (error) { - return { text: error, lastMessageId, thinkingText: thinkingBuf, error }; + return { text: error, lastMessageId, thinkingText: thinkingBuf, error, streamFinished, truncated, responseId: streamResponseId, contentReceived: false }; } if (!fullText && !thinkingBuf) { const fallback = emptyQwenParseFallback(rawFallback, rawFallback, 0); - return { ...fallback, error: null }; + // contentReceived=false: это диагностическое сообщение, а НЕ реальный + // контент стрима. Resume-конвейер не должен принимать его за успех. + return { ...fallback, error: null, streamFinished, truncated: false, responseId: streamResponseId, contentReceived: false }; } - return { text: fullText, lastMessageId, thinkingText: thinkingBuf, error: null }; + return { text: fullText, lastMessageId, thinkingText: thinkingBuf, error: null, streamFinished, truncated, responseId: streamResponseId, contentReceived: true }; }, }; } +// response_id текущей генерации: приходит и в response.created, и в каждом +// content-чанке. Нужен для resume-POST (?response_id=...) и для harvest. +export function qwenStreamResponseIdOf(parsed) { + const created = parsed?.["response.created"]; + if (created && typeof created === "object" && created.response_id != null) { + return String(created.response_id); + } + if (parsed?.response_id != null) return String(parsed.response_id); + return ""; +} + +// Терминальные маркеры SSE-стрима Qwen (любой из них = генерация завершена): +// • choices[0].finish_reason (OpenAI-совместимый маркер) +// • choices[0].delta.status === "finished" (собственный формат Qwen v2) +// [DONE] обрабатывается отдельно в consumeEvent. +export function qwenStreamHasTerminalMarker(parsed) { + const choice = Array.isArray(parsed?.choices) ? parsed.choices[0] : null; + if (!choice) return false; + if (choice.finish_reason) return true; + const status = choice?.delta?.status; + return status === "finished" || status === "completed"; +} + +// Состояние сессии продолжения оборванного стрима. Морда шлёт ПУСТОЙ POST +// /api/v2/chat/completions?chat_id=&response_id= и сервер продолжает +// тот же SSE-ответ — без создания sibling-ветки в дереве сообщений (слепой +// re-POST полного body порождает "2/2", "3/4" ретраи в веб-интерфейсе). +export function createQwenStreamContinuation({ chatId, responseId } = {}) { + const rid = String(responseId || ""); + const maxResumes = Math.max(0, Number(process.env.QWEN_RESUME_MAX_ATTEMPTS ?? 2)); + let resumesUsed = 0; + + return { + resumeUrl() { + return `${QWEN_BASE_URL}/api/v2/chat/completions?chat_id=${encodeURIComponent(String(chatId || ""))}&response_id=${encodeURIComponent(rid)}`; + }, + // Морда шлёт пустое тело; заголовки (bx-*, source: web, x-request-id) + // проставляет browser-proxy как для обычного completion-запроса. + resumeBody() { + return "{}"; + }, + shouldResume({ truncated, streamFinished: finished } = {}) { + if (!rid) return false; + if (finished) return false; + if (!truncated) return false; + return resumesUsed < maxResumes; + }, + markResumeAttempt() { + resumesUsed += 1; + }, + remainingResumes() { + return Math.max(0, maxResumes - resumesUsed); + }, + retryDelayMs() { + return Number(process.env.QWEN_RESUME_RETRY_DELAY_MS || 1500); + }, + get responseId() { + return rid; + }, + }; +} + +// Полный конвейер восстановления оборванного стрима. Прокси и паузы +// инжектируются (getProxy / retryDelayMs) — тестируется без браузера. +export async function recoverTruncatedQwenStream({ + chatId, + truncated, + onText = null, + onThinking = null, + getProxy, + debug = false, +}) { + const continuation = createQwenStreamContinuation({ + chatId, + responseId: truncated?.responseId, + }); + + // --- Этап 1: RESUME (пустой POST ?response_id=...) --- + while (continuation.shouldResume({ truncated: true, streamFinished: false })) { + continuation.markResumeAttempt(); + try { + const proxy = await getProxy(); + if (debug) console.log(`[qwen] stream truncated mid-generation; resuming response_id=${truncated.responseId} (remaining ${continuation.remainingResumes()})…`); + providerLogger.warn("provider.qwen.stream_resume", { + operation: "resume", + chatId, + responseId: truncated.responseId, + remaining: continuation.remainingResumes(), + }); + const resumeParser = createQwenIncrementalParser({ onText, onThinking }); + const resumeResult = await proxy.proxyFetchStream({ + url: continuation.resumeUrl(), + body: continuation.resumeBody(), + chatId, + onRawChunk: (chunk) => resumeParser.push(chunk), + maxAttempts: 1, + }); + const resumeParsed = resumeParser.finish(resumeResult.text || ""); + const resumeGotContent = Boolean(resumeParsed.contentReceived); + if (resumeParsed.streamFinished || (resumeGotContent && !resumeParsed.truncated)) { + // Сервер продолжил стрим: склеиваем хвост с уже полученным текстом. + // Дельту уже отдал в onText сам resumeParser (живой стрим наверх). + // Диагностический текст от emptyQwenParseFallback (contentReceived=false) + // успехом НЕ считается — идём к harvest. + return { + ...(resumeGotContent ? resumeParsed : {}), + text: resumeGotContent ? truncated.text + (resumeParsed.text || "") : truncated.text, + thinkingText: (truncated.thinkingText || "") + (resumeGotContent ? resumeParsed.thinkingText || "" : ""), + resumed: true, + truncated: false, + streamFinished: resumeParsed.streamFinished || resumeGotContent, + responseId: truncated.responseId, + }; + } + if (debug) console.log(`[qwen] resume attempt returned no continuation (${resumeResult.status}, bytes=${resumeResult.text?.length || 0})`); + } catch (error) { + providerLogger.warn("provider.qwen.stream_resume", { + operation: "resume_error", + chatId, + responseId: truncated.responseId, + error: error?.message || String(error), + }); + } + await waitForQwenChat(continuation.retryDelayMs()); + } + + // --- Этап 2: HARVEST (GET истории чата) --- + try { + const proxy = await getProxy(); + if (debug) console.log(`[qwen] resume exhausted; harvesting saved response ${truncated.responseId} from chat history…`); + providerLogger.warn("provider.qwen.stream_resume", { operation: "harvest", chatId, responseId: truncated.responseId }); + const harvested = await harvestQwenChatMessage({ + chatId, + responseId: truncated.responseId, + fetcher: async ({ cursor }) => proxy.proxyApiGet({ + path: cursor + ? `/api/v2/chats/${encodeURIComponent(chatId)}?cursor=${encodeURIComponent(cursor)}&direction=down&limit=10` + : `/api/v2/chats/${encodeURIComponent(chatId)}?direction=up&limit=10`, + }), + }); + if (harvested.found && harvested.text) { + // В streaming-режиме дельты уже ушли только до места обрыва — + // до streamed-текста. Чтобы клиент получил хвост, вычисляем суффикс + // сохранённого ответа и отдаём его через onText. Если сервер + // переписал начало (общий префикс короткий) — не стримим ничего: + // дублировать весь текст хуже, чем оставить честный обрыв. + streamHarvestTail({ + harvestedText: harvested.text, + streamedText: String(truncated.text || ""), + onText, + }); + return { + text: harvested.text, + lastMessageId: harvested.messageId, + thinkingText: truncated.thinkingText || "", + error: null, + streamFinished: true, + truncated: false, + responseId: truncated.responseId, + harvested: true, + }; + } + if (debug) console.log(`[qwen] harvest not found (${harvested.reason})`); + } catch (error) { + providerLogger.warn("provider.qwen.stream_resume", { + operation: "harvest_error", + chatId, + responseId: truncated.responseId, + error: error?.message || String(error), + }); + } + + return null; +} + +// Отдаёт через onText недостающий хвост harvest-текста (то, что сервер +// сгенерировал ПОСЛЕ обрыва стрима). Если streamed-текст не является +// префиксом сохранённого (сервер переписал начало) — хвост не стримим: +// дублирование хуже честного обрыва. +export function streamHarvestTail({ harvestedText, streamedText, onText }) { + if (typeof onText !== "function") return; + const saved = String(harvestedText || ""); + const streamed = String(streamedText || ""); + if (!saved) return; + if (streamed && saved.startsWith(streamed)) { + const tail = saved.slice(streamed.length); + if (tail) onText(tail); + return; + } + // Нет общего префикса (или streamed пуст и это не-стрим вызов) — + // отдаём сохранённый текст целиком, только если наверх ничего не ушло. + if (!streamed) { + onText(saved); + } +} + function findQwenErrorInSseText(text) { const blocks = text.split(/\r?\n\r?\n/).filter(Boolean); for (const raw of blocks) { diff --git a/src/providers/qwen/harvest.mjs b/src/providers/qwen/harvest.mjs new file mode 100644 index 0000000..346b200 --- /dev/null +++ b/src/providers/qwen/harvest.mjs @@ -0,0 +1,125 @@ +// Harvest: достаём сохранённый ответ Qwen из истории чата (2026-08). +// +// Когда стрим оборвался, а resume-POST (по response_id) не восстановил +// генерацию, сервер часто уже ДОДУМАЛ ответ и сохранил его в дерево +// сообщений чата. Вместо слепого повторного POST (который плодит +// sibling-ветки "2/2", "3/4") читаем историю: +// +// GET /api/v2/chats/{chatId}?direction=up&limit=10 +// GET /api/v2/chats/{chatId}?cursor={msgId}&direction=down&limit=10 +// +// и ищем сообщение с id (или response_id) == responseId. Если нашли — +// отдаём его контент как готовый ответ без каких-либо ретраев. +// +// Модуль намеренно не знает про browser-proxy: fetcher инжектится +// вызывающим слоем (прокси-GET или прямой fetch), что делает его +// тривиально тестируемым. + +// Максимальное число страниц истории, которые просматриваем при поиске. +const MAX_HISTORY_PAGES = Number(process.env.QWEN_HARVEST_MAX_PAGES || 5); + +// Пауза перед harvest-попыткой: если сервер ещё генерирует, ответ ещё не +// сохранён — короткий sleep и повторный GET повышают шанс успеть к финалу. +const HARVEST_RETRY_DELAY_MS = Number(process.env.QWEN_HARVEST_RETRY_DELAY_MS || 3000); +const HARVEST_RETRIES = Number(process.env.QWEN_HARVEST_RETRIES ?? 1); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function messageNodeResponseId(node) { + if (node?.response_id != null) return String(node.response_id); + return ""; +} + +function messageNodeId(node) { + if (node?.id != null) return String(node.id); + return ""; +} + +// Один GET истории (одна страница). fetcher({ cursor, direction, limit }) +// возвращает либо распарсенный payload напрямую ({ data: { messages } }), +// либо обёртку browser-proxy proxyApiGet ({ ok, status, json }) — +// поддерживаем оба формата. +async function fetchHistoryPage(fetcher, chatId, { cursor = null, direction = "up", limit = 10 } = {}) { + const result = await fetcher({ chatId, cursor, direction, limit }); + const data = result?.data ?? result?.json?.data; + if (Array.isArray(data?.messages)) return data.messages; + if (Array.isArray(data)) return data; + return []; +} + +// Ищет узел с id/response_id == responseId среди страницы сообщений. +function findSavedResponse(messages, responseId) { + for (const node of messages) { + if (messageNodeId(node) === responseId) return node; + if (messageNodeResponseId(node) === responseId) return node; + } + return null; +} + +// Достаёт текст из сохранённого узла ассистента. Контент может быть +// строкой или массивом частей (как в HAR морды). +function savedNodeToText(node) { + const content = node?.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => (typeof part === "string" ? part : part?.text || "")) + .filter(Boolean) + .join(""); + } + return ""; +} + +export async function harvestQwenChatMessage({ + fetcher, + chatId, + responseId, + fetchChildren = false, + retries = HARVEST_RETRIES, + retryDelayMs = HARVEST_RETRY_DELAY_MS, + maxPages = MAX_HISTORY_PAGES, + logger = null, +}) { + const rid = String(responseId || ""); + if (!rid) return { found: false, reason: "no_response_id" }; + + let lastError = null; + for (let attempt = 0; attempt <= retries; attempt += 1) { + if (attempt > 0) await sleep(retryDelayMs); + try { + let cursor = null; + for (let page = 0; page < maxPages; page += 1) { + const messages = await fetchHistoryPage(fetcher, chatId, { cursor }); + const node = findSavedResponse(messages, rid); + if (node) { + const text = savedNodeToText(node); + if (text.trim()) { + return { found: true, text, messageId: messageNodeId(node) || rid, pages: page + 1, attempts: attempt + 1 }; + } + // Узел найден, но пуст — генерация ещё пишется; ждём и пробуем снова. + break; + } + const next = nextCursor(messages); + // Нет курсора или курсор не двигается — история просмотрена, дальше некуда. + if (!next || next === cursor) break; + cursor = next; + } + } catch (error) { + lastError = error; + logger?.warn?.("provider.qwen.harvest", { error: error?.message || String(error) }); + } + } + + if (lastError) return { found: false, reason: "http_error", error: lastError?.message || String(lastError) }; + return { found: false, reason: "not_saved_yet" }; +} + +// Курсор пагинации: берём id последнего сообщения страницы. +function nextCursor(messages, _prevCursor, direction) { + if (!Array.isArray(messages) || messages.length === 0) return null; + const last = messages[messages.length - 1]; + const id = messageNodeId(last); + return id || null; +} diff --git a/test/qwen-harvest.test.mjs b/test/qwen-harvest.test.mjs new file mode 100644 index 0000000..92be104 --- /dev/null +++ b/test/qwen-harvest.test.mjs @@ -0,0 +1,95 @@ +import { describe, it } from "node:test"; +import { strict as assert } from "node:assert"; + +import { harvestQwenChatMessage } from "../src/providers/qwen/harvest.mjs"; + +// Формат ответа GET /api/v2/chats/{chatId}?direction=up&limit=10 — как в HAR морды. +function chatHistoryPayload(messages) { + return { + code: "SUCCESS", + success: true, + data: { + messages: messages.map((m) => ({ + id: m.id, + role: m.role, + content: m.content, + ...m.extra, + })), + }, + }; +} + +describe("harvestQwenChatMessage", () => { + it("returns the saved assistant message matching responseId", async () => { + const payload = chatHistoryPayload([ + { id: "u1", role: "user", content: "question" }, + { id: "resp-77", role: "assistant", content: "final saved answer" }, + ]); + const fetcher = async () => payload; + const result = await harvestQwenChatMessage({ fetcher, chatId: "chat-1", responseId: "resp-77" }); + assert.equal(result.found, true); + assert.equal(result.text, "final saved answer"); + assert.equal(result.messageId, "resp-77"); + }); + + it("matches responseId against response_id field of saved nodes (HAR shape)", async () => { + const payload = { + success: true, + data: { + messages: [ + { id: "node-1", role: "assistant", content: "branch text", response_id: "resp-88" }, + ], + }, + }; + const result = await harvestQwenChatMessage({ fetcher: async () => payload, chatId: "c", responseId: "resp-88" }); + assert.equal(result.found, true); + assert.equal(result.text, "branch text"); + }); + + it("returns found:false when the generation is still running (no saved assistant message)", async () => { + const payload = chatHistoryPayload([ + { id: "u1", role: "user", content: "question" }, + ]); + const result = await harvestQwenChatMessage({ fetcher: async () => payload, chatId: "c", responseId: "resp-404" }); + assert.equal(result.found, false); + assert.equal(result.reason, "not_saved_yet"); + }); + + it("looks through childrenIds branches when assistant message is nested", async () => { + // В HAR веб-морды ответ ассистента может быть child-узлом user-сообщения. + const payload = { + success: true, + data: { + messages: [ + { + id: "u1", + role: "user", + content: "question", + childrenIds: ["resp-99"], + }, + ], + }, + }; + const fetcher = async (opts) => { + // Первичный запрос не нашёл; пагинация не нужна — сразу отдаём вложенное + // сообщение вторым вызовом (имитируем fetch по cursor). + if (opts?.cursor) { + return { + success: true, + data: { messages: [{ id: "resp-99", role: "assistant", content: "nested branch answer" }] }, + }; + } + return payload; + }; + const result = await harvestQwenChatMessage({ fetcher, chatId: "c", responseId: "resp-99", fetchChildren: true }); + assert.equal(result.found, true); + assert.equal(result.text, "nested branch answer"); + }); + + it("propagates HTTP failure as found:false with reason=http_error and logs nothing sensitive", async () => { + const fetcher = async () => { throw new Error("HTTP 500: boom"); }; + const result = await harvestQwenChatMessage({ fetcher, chatId: "c", responseId: "r" }); + assert.equal(result.found, false); + assert.equal(result.reason, "http_error"); + }); +}); diff --git a/test/qwen-native-search-tool.test.mjs b/test/qwen-native-search-tool.test.mjs new file mode 100644 index 0000000..d5758cf --- /dev/null +++ b/test/qwen-native-search-tool.test.mjs @@ -0,0 +1,48 @@ +import { describe, it } from "node:test"; +import { strict as assert } from "node:assert"; + +import { requestSearchEnabled, toolsForModelPrompt } from "../api/openai-handler.mjs"; + +describe("Qwen native web search detection must not match client function tools", () => { + it("does NOT enable provider search when web_search arrives as a plain function tool (Hermes Gateway)", () => { + // Hermes Gateway отправляет web_search как обычный function-тул: + // { type: "function", function: { name: "web_search", ... } } + const hermesTools = [ + { type: "function", function: { name: "web_search", description: "Search the web", parameters: { type: "object", properties: { query: { type: "string" } } } } }, + { type: "function", function: { name: "terminal", description: "Run shell command" } }, + ]; + assert.equal(requestSearchEnabled({ tools: hermesTools }), false, + "A function tool named web_search is executed by the CLIENT via tool_calls, not by Qwen"); + }); + + it("still enables provider search for hosted web-search tool types (OpenAI Responses / Anthropic)", () => { + assert.equal(requestSearchEnabled({ tools: [{ type: "web_search_20250305", name: "web_search" }] }), true); + assert.equal(requestSearchEnabled({ tools: [{ type: "web_search" }] }), true); + assert.equal(requestSearchEnabled({ tools: [{ type: "web-search" }] }), true); + }); + + it("still enables provider search for explicit request flags", () => { + assert.equal(requestSearchEnabled({ search: true }), true); + assert.equal(requestSearchEnabled({ web_search: true }), true); + assert.equal(requestSearchEnabled({ web_search_options: {} }), true); + assert.equal(requestSearchEnabled({ metadata: { search: true } }), true); + }); + + it("keeps the function web_search tool inside the model prompt (client-side execution contract)", () => { + const hermesTools = [ + { type: "function", function: { name: "web_search" } }, + { type: "function", function: { name: "terminal" } }, + ]; + const filtered = toolsForModelPrompt(hermesTools); + assert.equal(filtered.length, 2, "Function tools must stay in the prompt so the model can call them back"); + assert.ok(filtered.some((t) => t.function?.name === "web_search")); + }); + + it("still filters out hosted web-search tools from the model prompt", () => { + const filtered = toolsForModelPrompt([ + { type: "web_search_20250305", name: "web_search" }, + { type: "function", function: { name: "create_reminder" } }, + ]); + assert.deepEqual(filtered, [{ type: "function", function: { name: "create_reminder" } }]); + }); +}); diff --git a/test/qwen-recovery-pipeline.test.mjs b/test/qwen-recovery-pipeline.test.mjs new file mode 100644 index 0000000..4f152aa --- /dev/null +++ b/test/qwen-recovery-pipeline.test.mjs @@ -0,0 +1,115 @@ +import { describe, it } from "node:test"; +import { strict as assert } from "node:assert"; + +import { recoverTruncatedQwenStream } from "../src/providers/qwen/client.mjs"; + +function sse(obj) { + return `data: ${JSON.stringify(obj)}\n\n`; +} + +// Проверка сквозного конвейера: обрыв → resume-POST → склейка текста, +// обрыв → resume не помог → harvest из истории. Всё на fake-прокси. +describe("recoverTruncatedQwenStream end-to-end with injected proxy", () => { + it("resumes the broken stream and concatenates the tail onto partial text", async () => { + // Первый resume-ответ: сервер продолжает с того же response_id + // и честно завершает (finish_reason). + const resumeStream = + sse({ "response.created": { response_id: "resp-1", response_index: 0 } }) + + sse({ choices: [{ delta: { content: " the rest." } }, ], response_id: "resp-1" }) + + sse({ choices: [{ delta: {}, finish_reason: "stop" }], response_id: "resp-1" }) + + "data: [DONE]\n\n"; + + const calls = []; + const proxy = { + async proxyFetchStream({ url, body, onRawChunk }) { + calls.push({ url, body }); + onRawChunk(resumeStream); + return { ok: true, status: 200, contentType: "text/event-stream", text: resumeStream }; + }, + async proxyApiGet() { + throw new Error("harvest must not be reached"); + }, + }; + + const liveDeltas = []; + const result = await recoverTruncatedQwenStream({ + chatId: "chat-1", + truncated: { text: "Partial answer", thinkingText: "", responseId: "resp-1", truncated: true }, + onText: (t) => liveDeltas.push(t), + getProxy: async () => proxy, + debug: false, + }); + + assert.equal(result.resumed, true); + assert.equal(result.text, "Partial answer the rest."); + assert.equal(result.truncated, false); + assert.equal(result.streamFinished, true); + // Живой хвост стримится наружу через onText. + assert.equal(liveDeltas.join(""), " the rest."); + // Resume — это пустой POST с response_id в query, а не повтор body. + assert.match(calls[0].url, /chat_id=chat-1&response_id=resp-1$/); + assert.equal(calls[0].body, "{}"); + }); + + it("falls back to harvest from chat history when resume yields nothing", async () => { + const proxy = { + proxyFetchStreamCalls: 0, + async proxyFetchStream() { + this.proxyFetchStreamCalls += 1; + // Resume вернул пустоту — генерация на сервере уже не продолжается. + return { ok: true, status: 200, contentType: "text/event-stream", text: "" }; + }, + async proxyApiGet({ path }) { + assert.match(path, /\/api\/v2\/chats\/chat-2\?direction=up&limit=10$/); + return { + ok: true, + status: 200, + json: { + success: true, + data: { + messages: [ + { id: "u1", role: "user", content: "question" }, + // Серверное сохранение начинается с того же текста, что уже + // успел застримиться до обрыва ("partial"). + { id: "resp-2", role: "assistant", content: "partial answer fully finished by the server" }, + ], + }, + }, + }; + }, + }; + + const harvestTail = []; + const result = await recoverTruncatedQwenStream({ + chatId: "chat-2", + truncated: { text: "partial", thinkingText: "", responseId: "resp-2", truncated: true }, + onText: (t) => harvestTail.push(t), + getProxy: async () => proxy, + }); + + assert.equal(result.harvested, true); + assert.equal(result.text, "partial answer fully finished by the server"); + assert.equal(result.streamFinished, true); + // Оба resume-попытки были сделаны до harvest. + assert.equal(proxy.proxyFetchStreamCalls, 2); + // Хвост (всё после streamed-префикса "partial") доставлен в onText. + assert.equal(harvestTail.join(""), " answer fully finished by the server"); + }); + + it("returns null when neither resume nor harvest can recover the stream", async () => { + const proxy = { + async proxyFetchStream() { + return { ok: true, status: 200, contentType: "text/event-stream", text: "" }; + }, + async proxyApiGet() { + return { ok: true, status: 200, json: { success: true, data: { messages: [] } } }; + }, + }; + const result = await recoverTruncatedQwenStream({ + chatId: "chat-3", + truncated: { text: "partial", thinkingText: "", responseId: "resp-3", truncated: true }, + getProxy: async () => proxy, + }); + assert.equal(result, null); + }); +}); diff --git a/test/qwen-stream-resume.test.mjs b/test/qwen-stream-resume.test.mjs new file mode 100644 index 0000000..f2d26ae --- /dev/null +++ b/test/qwen-stream-resume.test.mjs @@ -0,0 +1,108 @@ +import { describe, it } from "node:test"; +import { strict as assert } from "node:assert"; + +import { + createQwenIncrementalParser, + createQwenStreamContinuation, +} from "../src/providers/qwen/client.mjs"; + +// SSE-хелперы в стиле реальных событий Qwen. +function sse(obj) { + return `data: ${JSON.stringify(obj)}\n\n`; +} +function createdEvent(responseId) { + return sse({ "response.created": { response_id: responseId, response_index: 0 } }); +} +function textChunk(text, responseId) { + return sse({ choices: [{ delta: { content: text }, index: 0 }], response_id: responseId }); +} +function thinkChunk(text, responseId) { + return sse({ choices: [{ delta: { content: text, phase: "think" }, index: 0 }], response_id: responseId }); +} + +describe("Qwen incremental parser tracks stream lifecycle for resume/harvest", () => { + it("records responseId from response.created and terminal markers", () => { + const parser = createQwenIncrementalParser({}); + parser.push(createdEvent("resp-1")); + parser.push(textChunk("Hello", "resp-1")); + parser.push(sse({ choices: [{ delta: { status: "finished" } }], response_id: "resp-1" })); + const result = parser.finish(); + assert.equal(result.text, "Hello"); + assert.equal(result.responseId, "resp-1"); + assert.equal(result.streamFinished, true, "delta.status=finished is a terminal marker"); + }); + + it("treats [DONE] as terminal", () => { + const parser = createQwenIncrementalParser({}); + parser.push(createdEvent("resp-2")); + parser.push(textChunk("A", "resp-2")); + parser.push("data: [DONE]\n\n"); + const result = parser.finish(); + assert.equal(result.streamFinished, true); + assert.equal(result.responseId, "resp-2"); + }); + + it("marks finish_reason as terminal", () => { + const parser = createQwenIncrementalParser({}); + parser.push(textChunk("B")); + parser.push(sse({ choices: [{ delta: {}, finish_reason: "stop" }] })); + const result = parser.finish(); + assert.equal(result.streamFinished, true); + }); + + it("detects mid-stream truncation: content flowed but no terminal marker before finish()", () => { + const parser = createQwenIncrementalParser({}); + parser.push(createdEvent("resp-3")); + parser.push(textChunk("partial answer with", "resp-3")); + // Стрим оборвался: ни [DONE], ни finished, ни finish_reason. + const result = parser.finish(); + assert.equal(result.streamFinished, false, "no terminal marker => truncated"); + assert.equal(result.truncated, true); + assert.equal(result.text, "partial answer with"); + assert.equal(result.responseId, "resp-3"); + }); + + it("empty stream with no terminal marker is NOT truncated (empty-stream retry owns that case)", () => { + const parser = createQwenIncrementalParser({}); + const result = parser.finish(""); + assert.equal(result.truncated, false); + assert.equal(result.streamFinished, false); + }); +}); + +describe("createQwenStreamContinuation resume session state", () => { + it("builds resume URL with chat_id and response_id", () => { + const session = createQwenStreamContinuation({ chatId: "chat-9", responseId: "resp-9" }); + assert.equal( + session.resumeUrl(), + "https://chat.qwen.ai/api/v2/chat/completions?chat_id=chat-9&response_id=resp-9", + ); + }); + + it("resumes with an empty JSON body and skips auto-search hallucination prompt", () => { + const session = createQwenStreamContinuation({ chatId: "chat-9", responseId: "resp-9" }); + const body = JSON.parse(session.resumeBody()); + assert.deepEqual(body, {}); + }); + + it("does not consume remaining resume budget on a stream that finished cleanly", () => { + const session = createQwenStreamContinuation({ chatId: "c", responseId: "r" }); + assert.equal(session.shouldResume({ truncated: false, streamFinished: true }), false); + assert.equal(session.remainingResumes(), 2); + }); + + it("resumes at most QWEN_RESUME_MAX_ATTEMPTS times (default 2)", () => { + const session = createQwenStreamContinuation({ chatId: "c", responseId: "r" }); + assert.equal(session.shouldResume({ truncated: true, streamFinished: false }), true); + session.markResumeAttempt(); + assert.equal(session.shouldResume({ truncated: true, streamFinished: false }), true); + session.markResumeAttempt(); + assert.equal(session.shouldResume({ truncated: true, streamFinished: false }), false, + "resume budget exhausted — next step is harvest"); + }); + + it("never resumes without a responseId", () => { + const session = createQwenStreamContinuation({ chatId: "c", responseId: "" }); + assert.equal(session.shouldResume({ truncated: true, streamFinished: false }), false); + }); +}); From 3812542ffa7a2c3e0c76a4c44308263ed15e7b30 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 21 Aug 2026 09:51:58 +0300 Subject: [PATCH 08/13] fix(qwen): ban native tool calls during thinking phase in tool instructions Reasoning models (qwen3-max etc.) read the [TOOL INSTRUCTIONS] prompt block and attempt to invoke the listed tools through their INTERNAL tool-call mechanism during the thinking phase. The Qwen backend has no such tools registered, so every attempt fails ('Tool execute_code does not exists', 'Tool write_file does not exists', ...) and the model cascades through every tool name until the turn dies. Reproduced in the native web UI (2026-08-21): the model believes the tool-execution system exists and keeps trying natively between reasoning phases. - Add a CRITICAL 'HOW TOOLS ARE EXECUTED HERE' preamble to the tool instructions: no native/internal tool execution exists, the only way to run a tool is a tool_calls markdown block in the FINAL visible answer, never inside thinking. - Apply the ban to every model with tools, not only reasoner-named ones (qwen3-max does not match /reason|r1|qwq|expert/). - Keep the plain 'no tools -> no tool instructions' behavior intact. - Tests: thinking-tool ban present for standard and expert mappings, absent when client sends no tools. --- api/openai-handler.mjs | 32 +++++++++++++--- plugin-for-vscode/api/openai-handler.mjs | 32 +++++++++++++--- test/qwen-thinking-tool-ban.test.mjs | 49 ++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 test/qwen-thinking-tool-ban.test.mjs diff --git a/api/openai-handler.mjs b/api/openai-handler.mjs index b5f4c16..ab3ce1d 100644 --- a/api/openai-handler.mjs +++ b/api/openai-handler.mjs @@ -45,6 +45,9 @@ export function parseModelToolCallsSafe(text) { const compatLogger = createFileLogger({ component: "openai-handler" }); +// Тройной бэктик для вставки в template literals без raw-экранирования. +const F = "\u0060\u0060\u0060"; + // Ленивый singleton Qwen-клиента — переиспользуем через все вызовы API. let qwenClient = null; // Ленивый singleton DeepSeek-клиента — переиспользуем через все вызовы API. @@ -355,17 +358,34 @@ export function buildPromptFromChatBody(body, modelName, mapping) { ? ` NOTE FOR REASONING MODELS (R1 / QwQ / Reasoner): - Do NOT wrap the final answer in . After your reasoning, your - final output MUST be either plain text OR a \`\`\`tool_calls\`\`\` block. -- If the user asks you to inspect/edit/run anything in a project, you MUST - emit a tool_calls block. Never invent shell commands ("rtk cat ...", "kit ls ...") - — those tools do not exist. Use ONLY the names from the Available tools list. + final output MUST be either plain text OR a ${F}tool_calls${F} block. ` : ""; + // Reasoning-модели Qwen (qwen3-max и др.) видят список тулов и пытаются + // вызывать их своим ВНУТРЕННИМ tool-call механизмом прямо во время + // thinking-фазы — бэкенд отвечает «Tool X does not exists» и модель + // каскадит по всем именам (execute_code, write_file, terminal, …; + // воспроизведено в нативной веб-морде 2026-08-21). Запрет обязателен + // для ЛЮБОЙ модели с тулами, не только для reasoner-имён. + const nativeCallBan = ` +CRITICAL — HOW TOOLS ARE EXECUTED HERE: +- This chat has NO native/internal tool execution. The ONLY way to run a tool + is the ${F}tool_calls${F} markdown block in your FINAL visible answer. +- NEVER attempt tool calls during your thinking/reasoning phase. Do NOT + invoke, announce or "run" tools (including internal helpers like + execute_code, write_file, terminal, tool_search, web_search) inside + thinking — the backend rejects them ("Tool ... does not exists") and the + whole turn fails. Think in plain text only; emit tool_calls once, at the end. +- If you want to use a tool, your WHOLE final message is one ${F}tool_calls${F} block. +- Never invent shell commands ("rtk cat ...", "kit ls ...") — those tools do + not exist. Use ONLY the names from the Available tools list. +`; + prompt += `[TOOL INSTRUCTIONS — STRICT FORMAT] You are connected to an automated tool-execution system. There is NO human reading your text in the loop. Compliance with the format below is mandatory. - +${nativeCallBan} To call one or more tools, your ENTIRE reply must be a single markdown block: \`\`\`tool_calls @@ -445,7 +465,7 @@ Do not copy model identity from earlier assistant messages in the conversation h // Ensure the prompt ends with a clear directive if tools are available if (body.tools && body.tools.length > 0) { - prompt += `\n\n---\n[SYSTEM REMINDER]: You MUST use the exact JSON array format wrapped in \`\`\`tool_calls\`\`\` to call tools. If you output plain bash commands, it will fail.`; + prompt += `\n\n---\n[SYSTEM REMINDER]: You MUST use the exact JSON array format wrapped in ${F}tool_calls${F} to call tools. If you output plain bash commands, it will fail.`; } return prompt; diff --git a/plugin-for-vscode/api/openai-handler.mjs b/plugin-for-vscode/api/openai-handler.mjs index b5f4c16..ab3ce1d 100644 --- a/plugin-for-vscode/api/openai-handler.mjs +++ b/plugin-for-vscode/api/openai-handler.mjs @@ -45,6 +45,9 @@ export function parseModelToolCallsSafe(text) { const compatLogger = createFileLogger({ component: "openai-handler" }); +// Тройной бэктик для вставки в template literals без raw-экранирования. +const F = "\u0060\u0060\u0060"; + // Ленивый singleton Qwen-клиента — переиспользуем через все вызовы API. let qwenClient = null; // Ленивый singleton DeepSeek-клиента — переиспользуем через все вызовы API. @@ -355,17 +358,34 @@ export function buildPromptFromChatBody(body, modelName, mapping) { ? ` NOTE FOR REASONING MODELS (R1 / QwQ / Reasoner): - Do NOT wrap the final answer in . After your reasoning, your - final output MUST be either plain text OR a \`\`\`tool_calls\`\`\` block. -- If the user asks you to inspect/edit/run anything in a project, you MUST - emit a tool_calls block. Never invent shell commands ("rtk cat ...", "kit ls ...") - — those tools do not exist. Use ONLY the names from the Available tools list. + final output MUST be either plain text OR a ${F}tool_calls${F} block. ` : ""; + // Reasoning-модели Qwen (qwen3-max и др.) видят список тулов и пытаются + // вызывать их своим ВНУТРЕННИМ tool-call механизмом прямо во время + // thinking-фазы — бэкенд отвечает «Tool X does not exists» и модель + // каскадит по всем именам (execute_code, write_file, terminal, …; + // воспроизведено в нативной веб-морде 2026-08-21). Запрет обязателен + // для ЛЮБОЙ модели с тулами, не только для reasoner-имён. + const nativeCallBan = ` +CRITICAL — HOW TOOLS ARE EXECUTED HERE: +- This chat has NO native/internal tool execution. The ONLY way to run a tool + is the ${F}tool_calls${F} markdown block in your FINAL visible answer. +- NEVER attempt tool calls during your thinking/reasoning phase. Do NOT + invoke, announce or "run" tools (including internal helpers like + execute_code, write_file, terminal, tool_search, web_search) inside + thinking — the backend rejects them ("Tool ... does not exists") and the + whole turn fails. Think in plain text only; emit tool_calls once, at the end. +- If you want to use a tool, your WHOLE final message is one ${F}tool_calls${F} block. +- Never invent shell commands ("rtk cat ...", "kit ls ...") — those tools do + not exist. Use ONLY the names from the Available tools list. +`; + prompt += `[TOOL INSTRUCTIONS — STRICT FORMAT] You are connected to an automated tool-execution system. There is NO human reading your text in the loop. Compliance with the format below is mandatory. - +${nativeCallBan} To call one or more tools, your ENTIRE reply must be a single markdown block: \`\`\`tool_calls @@ -445,7 +465,7 @@ Do not copy model identity from earlier assistant messages in the conversation h // Ensure the prompt ends with a clear directive if tools are available if (body.tools && body.tools.length > 0) { - prompt += `\n\n---\n[SYSTEM REMINDER]: You MUST use the exact JSON array format wrapped in \`\`\`tool_calls\`\`\` to call tools. If you output plain bash commands, it will fail.`; + prompt += `\n\n---\n[SYSTEM REMINDER]: You MUST use the exact JSON array format wrapped in ${F}tool_calls${F} to call tools. If you output plain bash commands, it will fail.`; } return prompt; diff --git a/test/qwen-thinking-tool-ban.test.mjs b/test/qwen-thinking-tool-ban.test.mjs new file mode 100644 index 0000000..c0063f4 --- /dev/null +++ b/test/qwen-thinking-tool-ban.test.mjs @@ -0,0 +1,49 @@ +import { describe, it } from "node:test"; +import { strict as assert } from "node:assert"; + +import { buildPromptFromChatBody } from "../api/openai-handler.mjs"; + +// Regression: reasoning-модели Qwen (qwen3-max и др.) видят [TOOL INSTRUCTIONS] +// в промпте и пытаются ВЫЗВАТЬ эти тулы нативно во время thinking-фазы +// («Tool execute_code does not exists» каскад в веб-морде, 2026-08-21). +// Инструкции должны (1) явно запрещать нативные/внутренние вызовы, +// (2) появляться ТОЛЬКО когда клиент реально передал tools. + +const TOOLS = [{ + type: "function", + function: { name: "web_search", description: "search", parameters: { type: "object", properties: {} } }, +}]; + +describe("buildPromptFromChatBody tool instructions", () => { + it("forbids native/internal tool calls during thinking", () => { + const prompt = buildPromptFromChatBody({ + messages: [{ role: "user", content: "hi" }], + tools: TOOLS, + }, "qwen3-max", { provider: "qwen", model: "expert" }); + + assert.match(prompt, /TOOL INSTRUCTIONS/); + // Явный запрет нативных вызовов и вызовов из thinking. + assert.match(prompt, /NO native\/internal tool execution/); + assert.match(prompt, /NEVER attempt tool calls during your thinking\/reasoning phase/); + assert.match(prompt, /execute_code, write_file, terminal, tool_search, web_search/); + }); + + it("applies the thinking-phase ban to every model, not only reasoner-named ones", () => { + // qwen3-max не матчится /reason|r1|qwq|expert/ — но каскад ловили именно на нём. + const prompt = buildPromptFromChatBody({ + messages: [{ role: "user", content: "hi" }], + tools: TOOLS, + }, "qwen3-max", { provider: "qwen", model: "standard" }); + + assert.match(prompt, /NEVER attempt tool calls during your thinking\/reasoning phase/); + }); + + it("does not invent tools when the client sent none", () => { + const prompt = buildPromptFromChatBody({ + messages: [{ role: "user", content: "hi" }], + }, "qwen3-max", { provider: "qwen", model: "standard" }); + + assert.doesNotMatch(prompt, /TOOL INSTRUCTIONS/); + assert.doesNotMatch(prompt, /tool_calls/); + }); +}); From 1f044b1b1f054fd221d4ce67c85d2e84663b1ffd Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 21 Aug 2026 10:07:10 +0300 Subject: [PATCH 09/13] fix(qwen): force task continuation after tool results instead of bare Yes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After two successful tool calls the model degraded to replying with a bare 'Yes' to the user's 'retry and continue' message several times in a row (seen in web UI, 2026-08-21). Root cause: when the transcript ends with a [TOOL RESULT], the prompt carried only the generic SYSTEM REMINDER about the tool_calls format — no directive to continue the task. The model literally answered the last user message as a yes/no question instead of resuming work. Append a [TASK IN PROGRESS - CONTINUE NOW] directive when the last message is a tool result: the user's instruction still stands, the work is unfinished, continue now with the next tool_calls block or (only if truly done) the final answer. Bare acknowledgements (Yes/OK/ Done) are explicitly forbidden as answers. --- api/openai-handler.mjs | 14 ++++++ plugin-for-vscode/api/openai-handler.mjs | 14 ++++++ test/qwen-tool-result-continuation.test.mjs | 48 +++++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 test/qwen-tool-result-continuation.test.mjs diff --git a/api/openai-handler.mjs b/api/openai-handler.mjs index ab3ce1d..8c032ae 100644 --- a/api/openai-handler.mjs +++ b/api/openai-handler.mjs @@ -466,6 +466,20 @@ Do not copy model identity from earlier assistant messages in the conversation h // Ensure the prompt ends with a clear directive if tools are available if (body.tools && body.tools.length > 0) { prompt += `\n\n---\n[SYSTEM REMINDER]: You MUST use the exact JSON array format wrapped in ${F}tool_calls${F} to call tools. If you output plain bash commands, it will fail.`; + + // Транскрипт заканчивается tool-результатом: модель должна продолжить + // задачу СЕЙЧАС, а не отвечать на последний user-месседж ("retry and + // continue" она читала как yes/no-вопрос и отвечала голым "Yes"). + const last = messages[messages.length - 1]; + if (last?.role === "tool") { + prompt += `\n\n---\n[TASK IN PROGRESS — CONTINUE NOW]:\n` + + `The last message above is a TOOL RESULT, not a user reply. The user's ` + + `latest instruction still stands and work is unfinished. Continue the ` + + `task right now: either the next ${F}tool_calls${F} block, or (only if ` + + `truly everything is done) the final answer to the user's task.\n` + + `NEVER reply with bare acknowledgements ("Yes", "OK", "Done") — they ` + + `are not valid answers to the task.`; + } } return prompt; diff --git a/plugin-for-vscode/api/openai-handler.mjs b/plugin-for-vscode/api/openai-handler.mjs index ab3ce1d..8c032ae 100644 --- a/plugin-for-vscode/api/openai-handler.mjs +++ b/plugin-for-vscode/api/openai-handler.mjs @@ -466,6 +466,20 @@ Do not copy model identity from earlier assistant messages in the conversation h // Ensure the prompt ends with a clear directive if tools are available if (body.tools && body.tools.length > 0) { prompt += `\n\n---\n[SYSTEM REMINDER]: You MUST use the exact JSON array format wrapped in ${F}tool_calls${F} to call tools. If you output plain bash commands, it will fail.`; + + // Транскрипт заканчивается tool-результатом: модель должна продолжить + // задачу СЕЙЧАС, а не отвечать на последний user-месседж ("retry and + // continue" она читала как yes/no-вопрос и отвечала голым "Yes"). + const last = messages[messages.length - 1]; + if (last?.role === "tool") { + prompt += `\n\n---\n[TASK IN PROGRESS — CONTINUE NOW]:\n` + + `The last message above is a TOOL RESULT, not a user reply. The user's ` + + `latest instruction still stands and work is unfinished. Continue the ` + + `task right now: either the next ${F}tool_calls${F} block, or (only if ` + + `truly everything is done) the final answer to the user's task.\n` + + `NEVER reply with bare acknowledgements ("Yes", "OK", "Done") — they ` + + `are not valid answers to the task.`; + } } return prompt; diff --git a/test/qwen-tool-result-continuation.test.mjs b/test/qwen-tool-result-continuation.test.mjs new file mode 100644 index 0000000..38c96cd --- /dev/null +++ b/test/qwen-tool-result-continuation.test.mjs @@ -0,0 +1,48 @@ +import { describe, it } from "node:test"; +import { strict as assert } from "node:assert"; + +import { buildPromptFromChatBody } from "../api/openai-handler.mjs"; + +// Деградация 2026-08-21: после двух успешных tool calls модель ответила +// голым "Yes" на user-сообщение "retry and continue", вместо продолжения +// задачи. Причина: промпт заканчивается [TOOL RESULT] + общий SYSTEM +// REMINDER про формат — без директивы «задача в процессе, продолжай +// сейчас». Модель читает последний user-месседж как yes/no-вопрос. + +const TOOLS = [{ + type: "function", + function: { name: "execute_code", description: "run python", parameters: { type: "object", properties: {} } }, +}]; + +const MESSAGES = [ + { role: "user", content: "fix the bug in app.py" }, + { + role: "assistant", + content: "", + tool_calls: [{ id: "c1", type: "function", function: { name: "execute_code", arguments: "{\"code\":\"1+1\"}" } }], + }, + { role: "tool", tool_call_id: "c1", name: "execute_code", content: "2" }, +]; + +describe("buildPromptFromChatBody tool-result continuation", () => { + it("ends with a continuation directive when the last message is a tool result", () => { + const prompt = buildPromptFromChatBody({ messages: MESSAGES, tools: TOOLS }, "qwen3-max", { provider: "qwen", model: "standard" }); + + // Директива продолжения: задача в процессе, продолжать сейчас. + assert.match(prompt, /IN PROGRESS|Continue NOW/i); + // Явный запрет голых подтверждений. + assert.match(prompt, /"Yes"/); + // TOOL RESULT присутствует в транскрипте. + assert.match(prompt, /\[TOOL RESULT FOR execute_code\]/); + }); + + it("keeps the format-only reminder when the last message is not a tool result", () => { + const prompt = buildPromptFromChatBody({ + messages: [{ role: "user", content: "hi" }], + tools: TOOLS, + }, "qwen3-max", { provider: "qwen", model: "standard" }); + + assert.match(prompt, /You MUST use the exact JSON array format/); + assert.doesNotMatch(prompt, /IN PROGRESS/); + }); +}); From e9ec9a8685140d016eac6786e6d8d46255bbd04b Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 21 Aug 2026 11:58:07 +0300 Subject: [PATCH 10/13] feat(qwen): shrink tool schemas in prompt and direct context-file usage User field report (2026-08-21): with a 328K-char Hermes conversation compacted into context.txt, the tool block (full JSON schemas with multi-KB descriptions) still duplicated large parts of the Hermes system prompt already present in the attachment, inflating every request and contributing to model confusion (bare 'Yes' replies). - formatCompactTools: cap tool descriptions at 200 chars and property descriptions at 100 chars (word-boundary ellipsis). Names, types, required arrays, enums and defaults are preserved verbatim. Measured on real Hermes tool schemas: 75.8% size reduction (7746 -> 1876 bytes on a 4-tool sample; ~35 tools in practice). - Context-file note now instructs the model: read the attachment, internalize task and context, briefly restate intent (1-2 sentences), continue the work strictly following the attachment's instructions, and never answer only the last message. Also mentions that Qwen may rename context.txt (e.g. hash_Pasted_Text_....txt) and to rely on the attachment, not the filename. --- api/tool-calls.mjs | 39 +++++++- plugin-for-vscode/api/tool-calls.mjs | 39 +++++++- .../src/providers/qwen/context-file.mjs | 8 +- src/providers/qwen/context-file.mjs | 8 +- test/qwen-prompt-token-diet.test.mjs | 95 +++++++++++++++++++ 5 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 test/qwen-prompt-token-diet.test.mjs diff --git a/api/tool-calls.mjs b/api/tool-calls.mjs index f88e4b2..56c5564 100644 --- a/api/tool-calls.mjs +++ b/api/tool-calls.mjs @@ -448,19 +448,26 @@ function decodeXmlText(value) { .replace(/&/g, "&"); } +// Потолок длины описания тула в компактном списке. Полные простыни +// описаний дублируют прозу системного промпта Hermes (которая уезжает в +// context.txt) и раздувают промпт на десятки КБ. +const TOOL_DESCRIPTION_CAP = 200; +const PROPERTY_DESCRIPTION_CAP = 100; + export function formatCompactTools(tools) { if (!Array.isArray(tools) || !tools.length) return "[]"; const cleaned = tools .map((t) => { const fn = t?.function || t; if (!fn?.name) return null; + const description = fn.description?.trim() || ""; const cleanParams = cleanJsonSchema(fn.parameters || fn.input_schema); return { type: "function", function: { name: fn.name, - ...(fn.description ? { description: fn.description.trim() } : {}), - ...(cleanParams ? { parameters: cleanParams } : {}), + ...(description ? { description: cap(description, TOOL_DESCRIPTION_CAP) } : {}), + ...(cleanParams ? { parameters: shrinkParams(cleanParams) } : {}), }, }; }) @@ -468,6 +475,34 @@ export function formatCompactTools(tools) { return JSON.stringify(cleaned); } +function cap(text, max) { + if (text.length <= max) return text; + return text.slice(0, max - 1).replace(/\s+\S*$/, "") + "…"; +} + +// Схема параметров в «рационе»: типы + required + короткие описания. +// enums сохраняются (модели обязаны знать допустимые значения), дефолты тоже. +function shrinkParams(schema) { + if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema; + const res = { ...schema }; + if (res.properties && typeof res.properties === "object") { + const shrunk = {}; + for (const [k, v] of Object.entries(res.properties)) { + if (v && typeof v === "object" && !Array.isArray(v)) { + const { description, ...rest } = v; + shrunk[k] = typeof description === "string" && description.trim() + ? { ...rest, description: cap(description, PROPERTY_DESCRIPTION_CAP) } + : rest; + } else { + shrunk[k] = v; + } + } + res.properties = shrunk; + } + // вложенные items/objects не обходим: чистка первого уровня даёт основной выигрыш + return res; +} + function cleanJsonSchema(schema) { if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema; const { $schema, $id, additionalProperties, title, ...rest } = schema; diff --git a/plugin-for-vscode/api/tool-calls.mjs b/plugin-for-vscode/api/tool-calls.mjs index f88e4b2..56c5564 100644 --- a/plugin-for-vscode/api/tool-calls.mjs +++ b/plugin-for-vscode/api/tool-calls.mjs @@ -448,19 +448,26 @@ function decodeXmlText(value) { .replace(/&/g, "&"); } +// Потолок длины описания тула в компактном списке. Полные простыни +// описаний дублируют прозу системного промпта Hermes (которая уезжает в +// context.txt) и раздувают промпт на десятки КБ. +const TOOL_DESCRIPTION_CAP = 200; +const PROPERTY_DESCRIPTION_CAP = 100; + export function formatCompactTools(tools) { if (!Array.isArray(tools) || !tools.length) return "[]"; const cleaned = tools .map((t) => { const fn = t?.function || t; if (!fn?.name) return null; + const description = fn.description?.trim() || ""; const cleanParams = cleanJsonSchema(fn.parameters || fn.input_schema); return { type: "function", function: { name: fn.name, - ...(fn.description ? { description: fn.description.trim() } : {}), - ...(cleanParams ? { parameters: cleanParams } : {}), + ...(description ? { description: cap(description, TOOL_DESCRIPTION_CAP) } : {}), + ...(cleanParams ? { parameters: shrinkParams(cleanParams) } : {}), }, }; }) @@ -468,6 +475,34 @@ export function formatCompactTools(tools) { return JSON.stringify(cleaned); } +function cap(text, max) { + if (text.length <= max) return text; + return text.slice(0, max - 1).replace(/\s+\S*$/, "") + "…"; +} + +// Схема параметров в «рационе»: типы + required + короткие описания. +// enums сохраняются (модели обязаны знать допустимые значения), дефолты тоже. +function shrinkParams(schema) { + if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema; + const res = { ...schema }; + if (res.properties && typeof res.properties === "object") { + const shrunk = {}; + for (const [k, v] of Object.entries(res.properties)) { + if (v && typeof v === "object" && !Array.isArray(v)) { + const { description, ...rest } = v; + shrunk[k] = typeof description === "string" && description.trim() + ? { ...rest, description: cap(description, PROPERTY_DESCRIPTION_CAP) } + : rest; + } else { + shrunk[k] = v; + } + } + res.properties = shrunk; + } + // вложенные items/objects не обходим: чистка первого уровня даёт основной выигрыш + return res; +} + function cleanJsonSchema(schema) { if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema; const { $schema, $id, additionalProperties, title, ...rest } = schema; diff --git a/plugin-for-vscode/src/providers/qwen/context-file.mjs b/plugin-for-vscode/src/providers/qwen/context-file.mjs index fda78fd..a7204c9 100644 --- a/plugin-for-vscode/src/providers/qwen/context-file.mjs +++ b/plugin-for-vscode/src/providers/qwen/context-file.mjs @@ -101,8 +101,12 @@ export function splitPromptForFileUpload(prompt, config = resolveQwenContextFile const note = `\n\n---\n[CONTEXT FILE]: Более ранняя часть этого разговора (${fileText.length} символов) ` + - `прикреплена к сообщению как текстовый файл "context.txt" в списке вложений (files). ` + - `Используй его содержимое как обычную историю диалога.`; + `прикреплена к сообщению как текстовый файл "context.txt" в списке вложений (files) — ` + + `Qwen мог переименовать его (например, в "e316e72a-..._Pasted_Text_....txt"): ` + + `ориентируйся на вложение, а не на имя файла. ` + + `Прочитай его содержимое, уясни задачу и контекст диалога, изложи кратко замысел ` + + `(1–2 предложения) и продолжи работу со строгим соблюдением инструкций из вложения. ` + + `Не отвечай только на последнее сообщение — учитывай всю прикреплённую историю.`; const inline = [head, ...inlineSegments].join("\n\n---\n\n") + note; diff --git a/src/providers/qwen/context-file.mjs b/src/providers/qwen/context-file.mjs index fda78fd..a7204c9 100644 --- a/src/providers/qwen/context-file.mjs +++ b/src/providers/qwen/context-file.mjs @@ -101,8 +101,12 @@ export function splitPromptForFileUpload(prompt, config = resolveQwenContextFile const note = `\n\n---\n[CONTEXT FILE]: Более ранняя часть этого разговора (${fileText.length} символов) ` + - `прикреплена к сообщению как текстовый файл "context.txt" в списке вложений (files). ` + - `Используй его содержимое как обычную историю диалога.`; + `прикреплена к сообщению как текстовый файл "context.txt" в списке вложений (files) — ` + + `Qwen мог переименовать его (например, в "e316e72a-..._Pasted_Text_....txt"): ` + + `ориентируйся на вложение, а не на имя файла. ` + + `Прочитай его содержимое, уясни задачу и контекст диалога, изложи кратко замысел ` + + `(1–2 предложения) и продолжи работу со строгим соблюдением инструкций из вложения. ` + + `Не отвечай только на последнее сообщение — учитывай всю прикреплённую историю.`; const inline = [head, ...inlineSegments].join("\n\n---\n\n") + note; diff --git a/test/qwen-prompt-token-diet.test.mjs b/test/qwen-prompt-token-diet.test.mjs new file mode 100644 index 0000000..44f2813 --- /dev/null +++ b/test/qwen-prompt-token-diet.test.mjs @@ -0,0 +1,95 @@ +import { describe, it } from "node:test"; +import { strict as assert } from "node:assert"; + +import { formatCompactTools } from "../api/tool-calls.mjs"; +import { buildPromptFromChatBody } from "../api/openai-handler.mjs"; +import { splitPromptForFileUpload } from "../src/providers/qwen/context-file.mjs"; + +// 2026-08-21, поле-репорт: промпт ai-free раздут тул-схемами. Полные +// descriptions тулов (десятки КБ) дублируют прозу системного промпта Hermes, +// который и так уезжает в context.txt. Для списка «Available tools» нужны +// только имя + краткое описание + required-поля, без простыней описаний +// и без полного JSON-дерева parameters. + +const VERBOSE_TOOLS = [ + { + type: "function", + function: { + name: "cronjob", + description: "Manage scheduled cron jobs with a single compressed tool. " + + "Use action='create' to schedule a new job from a prompt or one or more skills. ".repeat(30), + parameters: { + type: "object", + properties: { + action: { type: "string", description: "One of: create, list, update, pause, resume, remove, run. " + "Details ".repeat(50) }, + job_id: { type: "string", description: "Required for update/pause/resume/remove/run. " + "Details ".repeat(50) }, + }, + required: ["action"], + }, + }, + }, + { + type: "function", + function: { + name: "web_search", + description: "Search the web for information.", + parameters: { + type: "object", + properties: { query: { type: "string" }, limit: { type: "integer", default: 5 } }, + required: ["query"], + }, + }, + }, +]; + +describe("formatCompactTools token diet", () => { + it("truncates long tool descriptions to a short cap", () => { + const out = formatCompactTools(VERBOSE_TOOLS); + const parsed = JSON.parse(out); + for (const t of parsed) { + assert.ok(t.function.description.length <= 220, `description too long: ${t.function.description.length}`); + } + // Имя и смысл сохранены + assert.equal(parsed[0].function.name, "cronjob"); + assert.match(parsed[0].function.description, /cron/i); + }); + + it("keeps names, types and required arrays; drops verbose property descriptions", () => { + const out = formatCompactTools(VERBOSE_TOOLS); + const parsed = JSON.parse(out); + const params = parsed[0].function.parameters; + // required сохранён + assert.deepEqual(params.required, ["action"]); + // типы параметров сохранены + assert.equal(params.properties.action.type, "string"); + assert.equal(params.properties.job_id.type, "string"); + // многословные описания свойств срезаны (<=120) + assert.ok(params.properties.action.description.length <= 120); + assert.ok(params.properties.job_id.description.length <= 120); + // default сохраняется (модели часто нужны дефолты) + const search = parsed.find(t => t.function.name === "web_search"); + assert.equal(search.function.parameters.properties.limit.default, 5); + }); + + it("shrinks the tool block dramatically vs raw JSON", () => { + const raw = JSON.stringify(VERBOSE_TOOLS); + const compact = formatCompactTools(VERBOSE_TOOLS); + assert.ok(compact.length < raw.length / 2, `compact=${compact.length}, raw=${raw.length}`); + }); +}); + +describe("context file note directive", () => { + it("instructs the model to internalize the attached context before answering", () => { + // Генерируем промпт длиннее порога файла, чтобы сработал сплит + const longHistory = Array.from({ length: 60 }, (_, i) => + `[USER]: сообщение номер ${i} `.repeat(20)).join("\n\n---\n\n"); + const prompt = "[SYSTEM]: you are hermes\n\n---\n\n" + longHistory + "\n\n---\n\n[USER]: продолжай работу"; + + const split = splitPromptForFileUpload(prompt, { inlineChars: 4000, fileMinChars: 1000 }); + assert.ok(split, "split expected"); + assert.match(split.inline, /\[CONTEXT FILE\]/); + // Директива: не просто «история диалога», а прочитать-уяснить-продолжить + assert.match(split.inline, /уясни|изложи|осознай|internalize/i); + assert.match(split.inline, /продолжи|continue/i); + }); +}); From 44e98b4e8e528e7fcb96475354e91bae4f935668 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 21 Aug 2026 14:16:05 +0300 Subject: [PATCH 11/13] fix(qwen): scrub backend 'Tool X does not exists' error chips from output Field logs (2026-08-21): the degraded Qwen backend emits literal 'Tool does not exists.' (and 'does not exist') error chips directly into the content channel when the model attempts internal tool calls mid-thinking. These leaked into the visible Hermes response as concatenated chip runs before any real text. - stripToolErrorChips / createToolErrorChipFilter in think-filter.mjs: regex scrubber for non-stream and stream paths, with chunk-boundary hold buffer so chips split across SSE chunks are still caught. Normal prose about tools is preserved (matches only the exact chip pattern: optional leading space, 'Tool', tool name, 'does not exist(s)', optional trailing dot). - Wired into the streaming pipeline after the think-tag filter and into parseModelToolCallsSafe for non-stream responses. Note: real fix remains the prompt-level ban (3812542); this scrubber is the safety net for the residual chip leakage observed in field logs. --- api/openai-handler.mjs | 10 +++- api/think-filter.mjs | 71 ++++++++++++++++++++++++ plugin-for-vscode/api/openai-handler.mjs | 10 +++- plugin-for-vscode/api/think-filter.mjs | 71 ++++++++++++++++++++++++ test/qwen-tool-chip-scrub.test.mjs | 59 ++++++++++++++++++++ 5 files changed, 215 insertions(+), 6 deletions(-) create mode 100644 test/qwen-tool-chip-scrub.test.mjs diff --git a/api/openai-handler.mjs b/api/openai-handler.mjs index 8c032ae..5487fa7 100644 --- a/api/openai-handler.mjs +++ b/api/openai-handler.mjs @@ -28,7 +28,7 @@ import { DEFAULT_AUTH_FILE } from "../src/config.mjs"; import { readSavedAuth } from "../src/auth/files.mjs"; import { DeepSeekChatClient } from "../src/providers/deepseek/client.mjs"; import { extractBareToolCalls, formatCompactTools, normalizeToolCallsForSchemas, parseModelToolCalls, repairTruncatedToolCallJson, escapeUnescapedInnerQuotes } from "./tool-calls.mjs"; -import { createThinkTagFilter, stripThinkBlocks } from "./think-filter.mjs"; +import { createThinkTagFilter, createToolErrorChipFilter, stripThinkBlocks, stripToolErrorChips } from "./think-filter.mjs"; import { readChatGPTAuth } from "../src/providers/chatgpt/auth-files.mjs"; import { CHATGPT_AUTH_FILE } from "../src/providers/chatgpt/config.mjs"; import { ChatGPTChatClient } from "../src/providers/chatgpt/client.mjs"; @@ -40,7 +40,7 @@ import { runWithEmptyStreamRetry } from "./stream-retry.mjs"; // прямо в текстовый канал — без зачистки детектор выдёргивает черновики // как реальные вызовы ("Tool X does not exists" каскад). export function parseModelToolCallsSafe(text) { - return parseModelToolCalls(stripThinkBlocks(text)); + return parseModelToolCalls(stripToolErrorChips(stripThinkBlocks(text))); } const compatLogger = createFileLogger({ component: "openai-handler" }); @@ -951,7 +951,10 @@ export async function handleQwenStream(client, chatId, prompt, modelName, model, // Деградировавший Qwen шлёт reasoning литеральным текстом с -тегами; // внутри — черновики tool-call JSON, которые детектор ловил как реальные // вызовы. Фильтр вырезает think-блоки до того, как буфер увидит парсер. - const thinkFilter = createThinkTagFilter({ onText: (t) => parser.onText(t) }); + // Поверх — чипострига: ошибки бэкенда «Tool X does not exists.» утекают + // в видимый текст и не живут в think-канале, поэтому идут вторым слоем. + const chipFilter = createToolErrorChipFilter({ onText: (t) => parser.onText(t) }); + const thinkFilter = createThinkTagFilter({ onText: (t) => chipFilter.push(t) }); let sawDelta = false; let firstDeltaAt = 0; const heartbeat = setInterval(() => { @@ -1040,6 +1043,7 @@ export async function handleQwenStream(client, chatId, prompt, modelName, model, clearInterval(heartbeat); if (res.destroyed || res.writableEnded) return; thinkFilter.flush(); + chipFilter.flush(); parser.onEnd(); writeSseRaw(res, "data: [DONE]\n\n"); if (!res.destroyed && !res.writableEnded) res.end(); diff --git a/api/think-filter.mjs b/api/think-filter.mjs index f0e73fd..15d8357 100644 --- a/api/think-filter.mjs +++ b/api/think-filter.mjs @@ -16,6 +16,18 @@ const THINK_CLOSE = ""; // выводе (без полноценных тегов). Вырезаем строку целиком. const THINKING_COMPLETED_RE = /(?:^|\n)[ \t]*Thinking completed[ \t]*(?=\n|$)/g; +// Чипы ошибок Qwen-бэкенда: когда модель пытается звать тулы во время +// thinking-фазы, бэкенд возвращает «Tool does not exists.» (или +// «does not exist») прямо текстом в контент-канал. В реальных логах чипы +// приходят слитно, сериями по много штук («…exists.Tool X does not +// exists.Tool Y does not exists.Замысел: …»). Вырезаем целиком вместе с +// обрамляющими пробелами; обычная проза про инструменты не матчится. +const TOOL_CHIP_RE = /[ \t]*Tool[ \t]+\S{1,64}[ \t]+does[ \t]+not[ \t]+exists?[ \t]*\.?/g; + +// Максимальная длина потенциального чипа-префикса, который стриминговый +// фильтр может придержать (см. partialChipPrefixLen). +const CHIP_PREFIX_MAX = 96; + export function stripThinkBlocks(text) { const s = String(text || ""); if (!s) return s; @@ -138,3 +150,62 @@ function partialTagPrefixLen(s, tag) { } return 0; } + +// Вырезает чипы ошибок бэкенда из готового текста (non-stream путь). +export function stripToolErrorChips(text) { + const s = String(text || ""); + if (!s) return s; + const out = s.replace(TOOL_CHIP_RE, ""); + // Слитные серии чипов оставляют пустые строки — поджимаем. + return /\S/.test(out) ? out.replace(/\n{3,}/g, "\n\n").replace(/[ \t]{2,}/g, " ") : ""; +} + +// Стриминговая версия: ту же логику, но с удержанием потенциального +// начала чипа («Tool rea…»), разрезанного по границе чанков. Буфер +// ограничен CHIP_PREFIX_MAX, поэтому латентность видимого текста +// растёт максимум на длину одного чипа и только рядом с ним. +export function createToolErrorChipFilter({ onText = null } = {}) { + let hold = ""; + + function emit(text) { + if (text && typeof onText === "function") onText(text); + } + + function process(s) { + // Быстрая проверка: без «Tool » чипов быть не может. + if (!/(^|[ \t\n])Tool[ \t]/.test(s)) return { out: s, hold: "" }; + const cleaned = s.replace(TOOL_CHIP_RE, ""); + // Если в конце остался потенциальный prefix чипа — придержим его. + const keep = partialChipPrefixLen(s); + if (keep > 0) { + return { out: cleaned.slice(0, cleaned.length - keep), hold: cleaned.slice(-keep) }; + } + return { out: cleaned, hold: "" }; + } + + return { + push(delta) { + const s = hold + String(delta || ""); + const r = process(s); + hold = r.hold; + emit(r.out); + }, + flush() { + emit(hold); + hold = ""; + }, + }; +} + +// Длина самого длинного суффикса s, который может стать началом чипа +// (совпадает с TOOL_CHIP_RE по префиксу «Tool … does not exist…»). +function partialChipPrefixLen(s) { + const max = Math.min(s.length, CHIP_PREFIX_MAX); + for (let len = max; len > 0; len -= 1) { + const cand = s.slice(s.length - len); + if (/^[ \t]*Tool[ \t]+\S{1,64}([ \t]+does)?([ \t]+not)?([ \t]+exists?[ \t]*\.?)?$/.test(cand)) { + return len; + } + } + return 0; +} diff --git a/plugin-for-vscode/api/openai-handler.mjs b/plugin-for-vscode/api/openai-handler.mjs index 8c032ae..5487fa7 100644 --- a/plugin-for-vscode/api/openai-handler.mjs +++ b/plugin-for-vscode/api/openai-handler.mjs @@ -28,7 +28,7 @@ import { DEFAULT_AUTH_FILE } from "../src/config.mjs"; import { readSavedAuth } from "../src/auth/files.mjs"; import { DeepSeekChatClient } from "../src/providers/deepseek/client.mjs"; import { extractBareToolCalls, formatCompactTools, normalizeToolCallsForSchemas, parseModelToolCalls, repairTruncatedToolCallJson, escapeUnescapedInnerQuotes } from "./tool-calls.mjs"; -import { createThinkTagFilter, stripThinkBlocks } from "./think-filter.mjs"; +import { createThinkTagFilter, createToolErrorChipFilter, stripThinkBlocks, stripToolErrorChips } from "./think-filter.mjs"; import { readChatGPTAuth } from "../src/providers/chatgpt/auth-files.mjs"; import { CHATGPT_AUTH_FILE } from "../src/providers/chatgpt/config.mjs"; import { ChatGPTChatClient } from "../src/providers/chatgpt/client.mjs"; @@ -40,7 +40,7 @@ import { runWithEmptyStreamRetry } from "./stream-retry.mjs"; // прямо в текстовый канал — без зачистки детектор выдёргивает черновики // как реальные вызовы ("Tool X does not exists" каскад). export function parseModelToolCallsSafe(text) { - return parseModelToolCalls(stripThinkBlocks(text)); + return parseModelToolCalls(stripToolErrorChips(stripThinkBlocks(text))); } const compatLogger = createFileLogger({ component: "openai-handler" }); @@ -951,7 +951,10 @@ export async function handleQwenStream(client, chatId, prompt, modelName, model, // Деградировавший Qwen шлёт reasoning литеральным текстом с -тегами; // внутри — черновики tool-call JSON, которые детектор ловил как реальные // вызовы. Фильтр вырезает think-блоки до того, как буфер увидит парсер. - const thinkFilter = createThinkTagFilter({ onText: (t) => parser.onText(t) }); + // Поверх — чипострига: ошибки бэкенда «Tool X does not exists.» утекают + // в видимый текст и не живут в think-канале, поэтому идут вторым слоем. + const chipFilter = createToolErrorChipFilter({ onText: (t) => parser.onText(t) }); + const thinkFilter = createThinkTagFilter({ onText: (t) => chipFilter.push(t) }); let sawDelta = false; let firstDeltaAt = 0; const heartbeat = setInterval(() => { @@ -1040,6 +1043,7 @@ export async function handleQwenStream(client, chatId, prompt, modelName, model, clearInterval(heartbeat); if (res.destroyed || res.writableEnded) return; thinkFilter.flush(); + chipFilter.flush(); parser.onEnd(); writeSseRaw(res, "data: [DONE]\n\n"); if (!res.destroyed && !res.writableEnded) res.end(); diff --git a/plugin-for-vscode/api/think-filter.mjs b/plugin-for-vscode/api/think-filter.mjs index f0e73fd..15d8357 100644 --- a/plugin-for-vscode/api/think-filter.mjs +++ b/plugin-for-vscode/api/think-filter.mjs @@ -16,6 +16,18 @@ const THINK_CLOSE = ""; // выводе (без полноценных тегов). Вырезаем строку целиком. const THINKING_COMPLETED_RE = /(?:^|\n)[ \t]*Thinking completed[ \t]*(?=\n|$)/g; +// Чипы ошибок Qwen-бэкенда: когда модель пытается звать тулы во время +// thinking-фазы, бэкенд возвращает «Tool does not exists.» (или +// «does not exist») прямо текстом в контент-канал. В реальных логах чипы +// приходят слитно, сериями по много штук («…exists.Tool X does not +// exists.Tool Y does not exists.Замысел: …»). Вырезаем целиком вместе с +// обрамляющими пробелами; обычная проза про инструменты не матчится. +const TOOL_CHIP_RE = /[ \t]*Tool[ \t]+\S{1,64}[ \t]+does[ \t]+not[ \t]+exists?[ \t]*\.?/g; + +// Максимальная длина потенциального чипа-префикса, который стриминговый +// фильтр может придержать (см. partialChipPrefixLen). +const CHIP_PREFIX_MAX = 96; + export function stripThinkBlocks(text) { const s = String(text || ""); if (!s) return s; @@ -138,3 +150,62 @@ function partialTagPrefixLen(s, tag) { } return 0; } + +// Вырезает чипы ошибок бэкенда из готового текста (non-stream путь). +export function stripToolErrorChips(text) { + const s = String(text || ""); + if (!s) return s; + const out = s.replace(TOOL_CHIP_RE, ""); + // Слитные серии чипов оставляют пустые строки — поджимаем. + return /\S/.test(out) ? out.replace(/\n{3,}/g, "\n\n").replace(/[ \t]{2,}/g, " ") : ""; +} + +// Стриминговая версия: ту же логику, но с удержанием потенциального +// начала чипа («Tool rea…»), разрезанного по границе чанков. Буфер +// ограничен CHIP_PREFIX_MAX, поэтому латентность видимого текста +// растёт максимум на длину одного чипа и только рядом с ним. +export function createToolErrorChipFilter({ onText = null } = {}) { + let hold = ""; + + function emit(text) { + if (text && typeof onText === "function") onText(text); + } + + function process(s) { + // Быстрая проверка: без «Tool » чипов быть не может. + if (!/(^|[ \t\n])Tool[ \t]/.test(s)) return { out: s, hold: "" }; + const cleaned = s.replace(TOOL_CHIP_RE, ""); + // Если в конце остался потенциальный prefix чипа — придержим его. + const keep = partialChipPrefixLen(s); + if (keep > 0) { + return { out: cleaned.slice(0, cleaned.length - keep), hold: cleaned.slice(-keep) }; + } + return { out: cleaned, hold: "" }; + } + + return { + push(delta) { + const s = hold + String(delta || ""); + const r = process(s); + hold = r.hold; + emit(r.out); + }, + flush() { + emit(hold); + hold = ""; + }, + }; +} + +// Длина самого длинного суффикса s, который может стать началом чипа +// (совпадает с TOOL_CHIP_RE по префиксу «Tool … does not exist…»). +function partialChipPrefixLen(s) { + const max = Math.min(s.length, CHIP_PREFIX_MAX); + for (let len = max; len > 0; len -= 1) { + const cand = s.slice(s.length - len); + if (/^[ \t]*Tool[ \t]+\S{1,64}([ \t]+does)?([ \t]+not)?([ \t]+exists?[ \t]*\.?)?$/.test(cand)) { + return len; + } + } + return 0; +} diff --git a/test/qwen-tool-chip-scrub.test.mjs b/test/qwen-tool-chip-scrub.test.mjs new file mode 100644 index 0000000..3de55a6 --- /dev/null +++ b/test/qwen-tool-chip-scrub.test.mjs @@ -0,0 +1,59 @@ +import { describe, it } from "node:test"; +import { strict as assert } from "node:assert"; + +import { + stripToolErrorChips, + createToolErrorChipFilter, +} from "../api/think-filter.mjs"; + +// 2026-08-21, полевой лог: деградировавший Qwen шлёт текстом ошибки бэкенда +// «Tool does not exists.» (и вариант «does not exist») вперемешку с +// контентом — чипы утекают в видимый ответ Hermes. Оба варианта орфографии +// встречаются в реальных логах («Tool web search does not exist», +// «Tool execute_code does not exists»). + +describe("stripToolErrorChips", () => { + it("removes 'does not exists' chips from text", () => { + const src = "Tool read_file does not exists.Tool terminal does not exists.Замысел: реализация пула."; + const out = stripToolErrorChips(src); + assert.equal(out, "Замысел: реализация пула."); + }); + + it("removes 'does not exist' (singular) chips too", () => { + const src = "Prefix. Tool web_search does not exist. Suffix."; + const out = stripToolErrorChips(src); + assert.doesNotMatch(out, /does not exist/); + assert.match(out, /Prefix/); + assert.match(out, /Suffix/); + }); + + it("keeps normal prose about tools", () => { + const src = "The requested tool does not exist in this environment. Use another one."; + const out = stripToolErrorChips(src); + assert.equal(out, src); + }); + + it("handles chip-splitting across chunks in streaming filter", () => { + const out = []; + const filter = createToolErrorChipFilter({ onText: (t) => out.push(t) }); + filter.push("Tool read_f"); + filter.push("ile does not exists."); + filter.push("Real answer follows."); + filter.flush(); + assert.equal(out.join(""), "Real answer follows."); + }); + + it("passes through ordinary text without holding it back on flush", () => { + const out = []; + const filter = createToolErrorChipFilter({ onText: (t) => out.push(t) }); + filter.push("Обычный текст, никаких чипов. Tool"); + filter.flush(); + assert.equal(out.join(""), "Обычный текст, никаких чипов. Tool"); + }); + + it("suppresses a long run of concatenated chips", () => { + const chips = Array.from({ length: 12 }, (_, i) => `Tool tool_${i} does not exists.`).join(""); + const out = stripToolErrorChips(chips + "Финальный текст."); + assert.equal(out, "Финальный текст."); + }); +}); From 6227b75723962c39377652701ddfea2a8b76461f Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 21 Aug 2026 14:39:54 +0300 Subject: [PATCH 12/13] fix(qwen): harvest instead of blind re-POST after transport failure mid-round MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field incident (2026-08-21, chat 'Qwen Account Pool Implementation', export 1787311091624): AbortError BodyStreamBuffer / net::ERR_ABORTED struck BEFORE the first SSE chunk arrived in Node. The POST was delivered — the server generated a full answer (16 content chunks, done). But the #completionRound catch block blindly re-POSTed the same body (same timestamp_ms) into the same chat: a second user message appeared, a sibling branch '1/2' hung under the agent's request, and only answer #2 reached the user while answer #1 was lost. Same disease we cured in browser-proxy's runProxyFetchStream, one level higher — the client retry loop. - harvestLatestAssistantMessage / harvestTransportFailedCompletion in harvest.mjs: poll chat history (GET /api/v2/chats/{id}) for a NEW finished assistant message; wait for in-progress generation to complete; emit only the un-streamed tail via onText. - #completionRound catch: on transient transport error AFTER the POST went out, harvest first; re-POST only when history proves the POST was never delivered (no new messages after two polls) or the proxy is dead. - createQwenIncrementalParser.snapshot(): current visible text, used to compute the missing tail. Full suite: 591 tests, 589 pass, 0 fail. --- .../src/providers/qwen/client.mjs | 39 ++++- .../src/providers/qwen/harvest.mjs | 125 ++++++++++++++ src/providers/qwen/client.mjs | 39 ++++- src/providers/qwen/harvest.mjs | 125 ++++++++++++++ test/qwen-no-blind-repost.test.mjs | 155 ++++++++++++++++++ 5 files changed, 481 insertions(+), 2 deletions(-) create mode 100644 test/qwen-no-blind-repost.test.mjs diff --git a/plugin-for-vscode/src/providers/qwen/client.mjs b/plugin-for-vscode/src/providers/qwen/client.mjs index f2ef544..faeb741 100644 --- a/plugin-for-vscode/src/providers/qwen/client.mjs +++ b/plugin-for-vscode/src/providers/qwen/client.mjs @@ -30,7 +30,7 @@ import { import { getQwenBrowserProxy, resetQwenBrowserProxy } from "./browser-proxy.mjs"; import { buildQwenCompletionPayload } from "./completion-payload.mjs"; import { resolveQwenContextFileConfig, splitPromptForFileUpload, uploadQwenContextFile } from "./context-file.mjs"; -import { harvestQwenChatMessage } from "./harvest.mjs"; +import { harvestQwenChatMessage, harvestTransportFailedCompletion } from "./harvest.mjs"; import { waitForQwenCompletionSlot, createQwenPunishError, qwenAntibotCooldownRemainingMs } from "./request-pacing.mjs"; import { createFileLogger } from "../../logging/logger.mjs"; @@ -482,6 +482,8 @@ export class QwenChatClient { if (QWEN_TRANSPORT === "browser") { let chatInProgressSeen = false; + let roundStreamParser = null; // парсер текущей попытки — виден в catch + let postedOnce = false; // completion-POST уже отправлялся в этом чате for (let attempt = 0; attempt < 3; attempt += 1) { try { const proxy = await getQwenBrowserProxy({ debug: this.debug }); @@ -489,6 +491,7 @@ export class QwenChatClient { const streamParser = useLiveStream ? createQwenIncrementalParser({ onText, onThinking }) : null; + roundStreamParser = streamParser; // Pacing: минимальный интервал между POST /completions, чтобы не // разгонять риск-скоринг Baxia. Также кидает QWEN_ANTIBOT_PUNISH, // если активен кулдаун после детектированной punish-страницы. @@ -496,6 +499,7 @@ export class QwenChatClient { if (pacingWaitMs > 0 && this.debug) { console.log(`[qwen] pacing: waited ${pacingWaitMs}ms before completion`); } + postedOnce = true; const result = useLiveStream ? await proxy.proxyFetchStream({ url, @@ -603,6 +607,34 @@ export class QwenChatClient { // Punish/кулдаун не ретраим на месте — ошибка уходит наверх, // агентный слой получит понятный код и сообщение о капче. if (error?.code === "QWEN_ANTIBOT_PUNISH") throw error; + // 2026-08-21: слепой re-POST того же body ЗАПРЕЩЁН, если POST уже + // отправлялся. Сервер мог его получить (AbortError до первого + // чанка — POST доставлен, генерация идёт): повтор создал бы + // sibling-ветку под тем же юзер-месседжем (полевой инцидент: + // «1/2» под запросом, дубль текста). Сначала harvest истории. + if (postedOnce && isQwenTransientBrowserTransportError(error)) { + const streamedText = roundStreamParser ? (roundStreamParser.snapshot?.() ?? "") : ""; + providerLogger.warn("provider.qwen.stream_resume", { + operation: "transport_failure_harvest", + chatId, + attempt: attempt + 1, + streamedChars: streamedText.length, + }); + const recovered = await harvestTransportFailedCompletion({ + chatId, + streamedText, + onText, + getProxy: () => getQwenBrowserProxy({ debug: this.debug }), + debug: this.debug, + }); + if (recovered) { + return { result: finalizeQwenCompletionResult(recovered, "", "completion (browser, transport-harvest)"), chatInProgressStuck: false }; + } + // Harvest не удался (POST не доставлен / прокси мёртв). + if (attempt >= 2) throw error; + await resetQwenBrowserProxy(); + continue; + } if (attempt >= 2 || !isQwenTransientBrowserTransportError(error)) { // Транспортная/авторизационная ошибка не считается «in progress». if (chatInProgressSeen) return { result: null, chatInProgressStuck: true }; @@ -864,6 +896,11 @@ export function createQwenIncrementalParser({ onText = null, onThinking = null } } return fullText.length > textLengthBefore || thinkingBuf.length > thinkingLengthBefore; }, + // Мгновенный снимок видимого текста (для transport-harvest: хвост = + // harvested минус уже отправленные дельты). + snapshot() { + return fullText; + }, finish(rawFallback = "") { if (buffer.trim()) consumeEvent(buffer); // Обрыв посреди генерации: контент шёл, но терминального маркера не diff --git a/plugin-for-vscode/src/providers/qwen/harvest.mjs b/plugin-for-vscode/src/providers/qwen/harvest.mjs index 346b200..12f5475 100644 --- a/plugin-for-vscode/src/providers/qwen/harvest.mjs +++ b/plugin-for-vscode/src/providers/qwen/harvest.mjs @@ -43,6 +43,7 @@ function messageNodeId(node) { // поддерживаем оба формата. async function fetchHistoryPage(fetcher, chatId, { cursor = null, direction = "up", limit = 10 } = {}) { const result = await fetcher({ chatId, cursor, direction, limit }); + if (Array.isArray(result)) return result; const data = result?.data ?? result?.json?.data; if (Array.isArray(data?.messages)) return data.messages; if (Array.isArray(data)) return data; @@ -123,3 +124,127 @@ function nextCursor(messages, _prevCursor, direction) { const id = messageNodeId(last); return id || null; } + +// ───────────────────────────────────────────────────────────────────────────── +// Восстановление ТРАНСПОРТНОГО обрыва (2026-08-21, полевой инцидент: +// чат «Qwen Account Pool Implementation»). AbortError: BodyStreamBuffer / +// net::ERR_ABORTED до первого чанка: POST доставлен, сервер генерит, но +// клиент #completionRound слепо re-POSTал тот же body (тот же timestamp_ms) +// → второй юзер-месседж → sibling-ветка «1/2» под запросом, ответ #1 +// потерян, пользователю уезжает ответ #2. +// +// Вместо слепого re-POST: поллим историю чата, ждём завершения генерации +// и забираем готовый ответ. Если история не показывает НОВЫХ сообщений за +// разумное время — POST доказуемо не доставлен, только тогда re-POST +// безопасен (вызывающий слой решает). +// ───────────────────────────────────────────────────────────────────────────── + +// Признаки завершённости узла-ассистента в истории. +function assistantNodeIsDone(node) { + if (node?.done === false) return false; + if (node?.is_stop === true) return false; + return Boolean(savedNodeToText(node).trim()); +} + +export async function harvestLatestAssistantMessage({ + fetcher, + chatId, + knownIds = [], + pollMs = 3000, + timeoutMs = 120_000, + logger = null, +}) { + const known = new Set(knownIds.map(String)); + const deadline = Date.now() + Math.max(1000, timeoutMs); + let polls = 0; + let sawNew = false; + + while (Date.now() < deadline) { + polls += 1; + let messages = []; + try { + messages = await fetchHistoryPage(fetcher, chatId, {}); + } catch (error) { + logger?.warn?.("provider.qwen.harvest", { error: error?.message || String(error) }); + } + const fresh = messages.filter((m) => !known.has(messageNodeId(m))); + if (fresh.length > 0) sawNew = true; + const lastAssistant = [...fresh].reverse().find( + (m) => String(m?.role) === "assistant" && assistantNodeIsDone(m), + ); + if (lastAssistant) { + return { + found: true, + text: savedNodeToText(lastAssistant), + messageId: messageNodeId(lastAssistant), + polls, + }; + } + if (fresh.length === 0 && polls >= 2) { + // Два опроса подряд без единого нового сообщения: POST не доставлен. + return { found: false, reason: "post_not_delivered", polls }; + } + await sleep(pollMs); + } + return { found: false, reason: sawNew ? "generation_timeout" : "post_not_delivered", polls }; +} + +// Обёртка для client.mjs: транспортный сбой после отправки completion-POST. +// Прокси и паузы инжектируются для тестируемости. Возвращает parsed-объект +// (совместим с результатом парсера стрима) или null, если восстановить +// не удалось (POST не доставлен / прокси мёртв) — тогда вызывающий слой +// может безопасно повторить POST. +export async function harvestTransportFailedCompletion({ + chatId, + streamedText = "", + knownIds = [], + onText = null, + getProxy, + pollMs = 3000, + timeoutMs = 120_000, + debug = false, +}) { + try { + const proxy = await getProxy(); + const harvested = await harvestLatestAssistantMessage({ + fetcher: ({ chatId: cid }) => proxy.proxyApiGet({ path: `/api/v2/chats/${cid}?direction=up&limit=20` }), + chatId, + knownIds, + pollMs, + timeoutMs, + logger: null, + }); + if (!harvested.found) { + if (debug) console.log(`[qwen] transport-failure harvest: ${harvested.reason}`); + return null; + } + let tail = ""; + if (typeof streamedText === "string" && streamedText) { + // Дельты уже ушли до обрыва: хвост = harvested минус уже отправленный префикс. + if (harvested.text.startsWith(streamedText)) { + tail = harvested.text.slice(streamedText.length); + } else { + tail = harvested.text; + } + } else { + tail = harvested.text; + } + if (tail && typeof onText === "function") onText(tail); + return { + text: streamedText ? (harvested.text.startsWith(streamedText) ? harvested.text : streamedText + tail) : tail, + thinkingText: "", + lastMessageId: harvested.messageId, + error: null, + streamFinished: true, + truncated: false, + responseId: "", + contentReceived: true, + harvested: true, + harvestedViaTransportRecovery: true, + }; + // eslint-disable-next-line no-useless-catch + } catch (error) { + if (debug) console.log(`[qwen] transport-failure harvest error: ${error?.message || error}`); + return null; + } +} diff --git a/src/providers/qwen/client.mjs b/src/providers/qwen/client.mjs index f2ef544..faeb741 100644 --- a/src/providers/qwen/client.mjs +++ b/src/providers/qwen/client.mjs @@ -30,7 +30,7 @@ import { import { getQwenBrowserProxy, resetQwenBrowserProxy } from "./browser-proxy.mjs"; import { buildQwenCompletionPayload } from "./completion-payload.mjs"; import { resolveQwenContextFileConfig, splitPromptForFileUpload, uploadQwenContextFile } from "./context-file.mjs"; -import { harvestQwenChatMessage } from "./harvest.mjs"; +import { harvestQwenChatMessage, harvestTransportFailedCompletion } from "./harvest.mjs"; import { waitForQwenCompletionSlot, createQwenPunishError, qwenAntibotCooldownRemainingMs } from "./request-pacing.mjs"; import { createFileLogger } from "../../logging/logger.mjs"; @@ -482,6 +482,8 @@ export class QwenChatClient { if (QWEN_TRANSPORT === "browser") { let chatInProgressSeen = false; + let roundStreamParser = null; // парсер текущей попытки — виден в catch + let postedOnce = false; // completion-POST уже отправлялся в этом чате for (let attempt = 0; attempt < 3; attempt += 1) { try { const proxy = await getQwenBrowserProxy({ debug: this.debug }); @@ -489,6 +491,7 @@ export class QwenChatClient { const streamParser = useLiveStream ? createQwenIncrementalParser({ onText, onThinking }) : null; + roundStreamParser = streamParser; // Pacing: минимальный интервал между POST /completions, чтобы не // разгонять риск-скоринг Baxia. Также кидает QWEN_ANTIBOT_PUNISH, // если активен кулдаун после детектированной punish-страницы. @@ -496,6 +499,7 @@ export class QwenChatClient { if (pacingWaitMs > 0 && this.debug) { console.log(`[qwen] pacing: waited ${pacingWaitMs}ms before completion`); } + postedOnce = true; const result = useLiveStream ? await proxy.proxyFetchStream({ url, @@ -603,6 +607,34 @@ export class QwenChatClient { // Punish/кулдаун не ретраим на месте — ошибка уходит наверх, // агентный слой получит понятный код и сообщение о капче. if (error?.code === "QWEN_ANTIBOT_PUNISH") throw error; + // 2026-08-21: слепой re-POST того же body ЗАПРЕЩЁН, если POST уже + // отправлялся. Сервер мог его получить (AbortError до первого + // чанка — POST доставлен, генерация идёт): повтор создал бы + // sibling-ветку под тем же юзер-месседжем (полевой инцидент: + // «1/2» под запросом, дубль текста). Сначала harvest истории. + if (postedOnce && isQwenTransientBrowserTransportError(error)) { + const streamedText = roundStreamParser ? (roundStreamParser.snapshot?.() ?? "") : ""; + providerLogger.warn("provider.qwen.stream_resume", { + operation: "transport_failure_harvest", + chatId, + attempt: attempt + 1, + streamedChars: streamedText.length, + }); + const recovered = await harvestTransportFailedCompletion({ + chatId, + streamedText, + onText, + getProxy: () => getQwenBrowserProxy({ debug: this.debug }), + debug: this.debug, + }); + if (recovered) { + return { result: finalizeQwenCompletionResult(recovered, "", "completion (browser, transport-harvest)"), chatInProgressStuck: false }; + } + // Harvest не удался (POST не доставлен / прокси мёртв). + if (attempt >= 2) throw error; + await resetQwenBrowserProxy(); + continue; + } if (attempt >= 2 || !isQwenTransientBrowserTransportError(error)) { // Транспортная/авторизационная ошибка не считается «in progress». if (chatInProgressSeen) return { result: null, chatInProgressStuck: true }; @@ -864,6 +896,11 @@ export function createQwenIncrementalParser({ onText = null, onThinking = null } } return fullText.length > textLengthBefore || thinkingBuf.length > thinkingLengthBefore; }, + // Мгновенный снимок видимого текста (для transport-harvest: хвост = + // harvested минус уже отправленные дельты). + snapshot() { + return fullText; + }, finish(rawFallback = "") { if (buffer.trim()) consumeEvent(buffer); // Обрыв посреди генерации: контент шёл, но терминального маркера не diff --git a/src/providers/qwen/harvest.mjs b/src/providers/qwen/harvest.mjs index 346b200..12f5475 100644 --- a/src/providers/qwen/harvest.mjs +++ b/src/providers/qwen/harvest.mjs @@ -43,6 +43,7 @@ function messageNodeId(node) { // поддерживаем оба формата. async function fetchHistoryPage(fetcher, chatId, { cursor = null, direction = "up", limit = 10 } = {}) { const result = await fetcher({ chatId, cursor, direction, limit }); + if (Array.isArray(result)) return result; const data = result?.data ?? result?.json?.data; if (Array.isArray(data?.messages)) return data.messages; if (Array.isArray(data)) return data; @@ -123,3 +124,127 @@ function nextCursor(messages, _prevCursor, direction) { const id = messageNodeId(last); return id || null; } + +// ───────────────────────────────────────────────────────────────────────────── +// Восстановление ТРАНСПОРТНОГО обрыва (2026-08-21, полевой инцидент: +// чат «Qwen Account Pool Implementation»). AbortError: BodyStreamBuffer / +// net::ERR_ABORTED до первого чанка: POST доставлен, сервер генерит, но +// клиент #completionRound слепо re-POSTал тот же body (тот же timestamp_ms) +// → второй юзер-месседж → sibling-ветка «1/2» под запросом, ответ #1 +// потерян, пользователю уезжает ответ #2. +// +// Вместо слепого re-POST: поллим историю чата, ждём завершения генерации +// и забираем готовый ответ. Если история не показывает НОВЫХ сообщений за +// разумное время — POST доказуемо не доставлен, только тогда re-POST +// безопасен (вызывающий слой решает). +// ───────────────────────────────────────────────────────────────────────────── + +// Признаки завершённости узла-ассистента в истории. +function assistantNodeIsDone(node) { + if (node?.done === false) return false; + if (node?.is_stop === true) return false; + return Boolean(savedNodeToText(node).trim()); +} + +export async function harvestLatestAssistantMessage({ + fetcher, + chatId, + knownIds = [], + pollMs = 3000, + timeoutMs = 120_000, + logger = null, +}) { + const known = new Set(knownIds.map(String)); + const deadline = Date.now() + Math.max(1000, timeoutMs); + let polls = 0; + let sawNew = false; + + while (Date.now() < deadline) { + polls += 1; + let messages = []; + try { + messages = await fetchHistoryPage(fetcher, chatId, {}); + } catch (error) { + logger?.warn?.("provider.qwen.harvest", { error: error?.message || String(error) }); + } + const fresh = messages.filter((m) => !known.has(messageNodeId(m))); + if (fresh.length > 0) sawNew = true; + const lastAssistant = [...fresh].reverse().find( + (m) => String(m?.role) === "assistant" && assistantNodeIsDone(m), + ); + if (lastAssistant) { + return { + found: true, + text: savedNodeToText(lastAssistant), + messageId: messageNodeId(lastAssistant), + polls, + }; + } + if (fresh.length === 0 && polls >= 2) { + // Два опроса подряд без единого нового сообщения: POST не доставлен. + return { found: false, reason: "post_not_delivered", polls }; + } + await sleep(pollMs); + } + return { found: false, reason: sawNew ? "generation_timeout" : "post_not_delivered", polls }; +} + +// Обёртка для client.mjs: транспортный сбой после отправки completion-POST. +// Прокси и паузы инжектируются для тестируемости. Возвращает parsed-объект +// (совместим с результатом парсера стрима) или null, если восстановить +// не удалось (POST не доставлен / прокси мёртв) — тогда вызывающий слой +// может безопасно повторить POST. +export async function harvestTransportFailedCompletion({ + chatId, + streamedText = "", + knownIds = [], + onText = null, + getProxy, + pollMs = 3000, + timeoutMs = 120_000, + debug = false, +}) { + try { + const proxy = await getProxy(); + const harvested = await harvestLatestAssistantMessage({ + fetcher: ({ chatId: cid }) => proxy.proxyApiGet({ path: `/api/v2/chats/${cid}?direction=up&limit=20` }), + chatId, + knownIds, + pollMs, + timeoutMs, + logger: null, + }); + if (!harvested.found) { + if (debug) console.log(`[qwen] transport-failure harvest: ${harvested.reason}`); + return null; + } + let tail = ""; + if (typeof streamedText === "string" && streamedText) { + // Дельты уже ушли до обрыва: хвост = harvested минус уже отправленный префикс. + if (harvested.text.startsWith(streamedText)) { + tail = harvested.text.slice(streamedText.length); + } else { + tail = harvested.text; + } + } else { + tail = harvested.text; + } + if (tail && typeof onText === "function") onText(tail); + return { + text: streamedText ? (harvested.text.startsWith(streamedText) ? harvested.text : streamedText + tail) : tail, + thinkingText: "", + lastMessageId: harvested.messageId, + error: null, + streamFinished: true, + truncated: false, + responseId: "", + contentReceived: true, + harvested: true, + harvestedViaTransportRecovery: true, + }; + // eslint-disable-next-line no-useless-catch + } catch (error) { + if (debug) console.log(`[qwen] transport-failure harvest error: ${error?.message || error}`); + return null; + } +} diff --git a/test/qwen-no-blind-repost.test.mjs b/test/qwen-no-blind-repost.test.mjs new file mode 100644 index 0000000..ad65c3c --- /dev/null +++ b/test/qwen-no-blind-repost.test.mjs @@ -0,0 +1,155 @@ +import { describe, it } from "node:test"; +import { strict as assert } from "node:assert"; + +import { + harvestLatestAssistantMessage, + harvestTransportFailedCompletion, +} from "../src/providers/qwen/harvest.mjs"; + +// 2026-08-21, полевой инцидент (чат «Qwen Account Pool Implementation», +// export 1787311091624): транспортный обрыв (AbortError: BodyStreamBuffer / +// net::ERR_ABORTED) ДО прихода первых чанков. POST был доставлен — сервер +// сгенерил полный ответ (16 чанков, done). Catch в #completionRound слепо +// re-POSTнул тот же body (тот же timestamp_ms) → второй юзер-месседж в том +// же чате → sibling-ветка «1/2» под запросом, ответ #1 потерян. +// +// Лечение: после транспортного сбоя на ОТПРАВЛЕННОМ completion-POST — +// harvest истории чата (ждём завершения генерации), re-POST только если +// POST доказуемо не доставлен (в истории нет новых сообщений). + +describe("harvestLatestAssistantMessage", () => { + it("returns the finished assistant answer for a delivered POST (fresh chat)", async () => { + const history = [ + { id: "u1", role: "user", content: "prompt" }, + { id: "a1", role: "assistant", content: "Final answer from POST #1" }, + ]; + const r = await harvestLatestAssistantMessage({ + fetcher: async () => history, + chatId: "c1", + pollMs: 1, + timeoutMs: 200, + }); + assert.equal(r.found, true); + assert.equal(r.text, "Final answer from POST #1"); + assert.equal(r.messageId, "a1"); + }); + + it("bails fast with post_not_delivered when history has no new messages", async () => { + const r = await harvestLatestAssistantMessage({ + fetcher: async () => [], + chatId: "c1", + pollMs: 1, + timeoutMs: 10_000, + }); + assert.equal(r.found, false); + assert.equal(r.reason, "post_not_delivered"); + }); + + it("ignores messages that existed before the POST (continuing chat snapshot)", async () => { + const history = [ + { id: "old-u", role: "user", content: "old" }, + { id: "old-a", role: "assistant", content: "old answer" }, + ]; + const r = await harvestLatestAssistantMessage({ + fetcher: async () => history, + chatId: "c1", + knownIds: ["old-u", "old-a"], + pollMs: 1, + timeoutMs: 10_000, + }); + assert.equal(r.found, false); + assert.equal(r.reason, "post_not_delivered"); + }); + + it("waits for an in-progress assistant to finish, then returns its text", async () => { + let call = 0; + const fetcher = async () => { + call += 1; + return call === 1 + ? [{ id: "u1", role: "user", content: "p" }, { id: "a1", role: "assistant", content: "" }] + : [{ id: "u1", role: "user", content: "p" }, { id: "a1", role: "assistant", content: "done text" }]; + }; + const r = await harvestLatestAssistantMessage({ fetcher, chatId: "c1", pollMs: 1, timeoutMs: 5_000 }); + assert.equal(r.found, true); + assert.equal(r.text, "done text"); + assert.ok(r.polls >= 2); + }); + + it("does not treat a new user message alone as an answer (keeps polling)", async () => { + let call = 0; + const fetcher = async () => { + call += 1; + return call < 3 ? [{ id: "u1", role: "user", content: "p" }] : []; + }; + const r = await harvestLatestAssistantMessage({ fetcher, chatId: "c1", pollMs: 1, timeoutMs: 300 }); + assert.equal(r.found, false); + }); +}); + +describe("harvestTransportFailedCompletion", () => { + const historyProxy = (messages) => ({ + proxyApiGet: async () => ({ ok: true, status: 200, json: { data: { messages } } }), + }); + + it("emits full text via onText when nothing was streamed; returns parsed shape", async () => { + const seen = []; + const proxy = historyProxy([ + { id: "u1", role: "user", content: "p" }, + { id: "a1", role: "assistant", content: "recovered answer" }, + ]); + const r = await harvestTransportFailedCompletion({ + chatId: "c1", + streamedText: "", + onText: (t) => seen.push(t), + getProxy: async () => proxy, + pollMs: 1, + timeoutMs: 200, + }); + assert.ok(r, "must return parsed"); + assert.equal(r.text, "recovered answer"); + assert.equal(r.lastMessageId, "a1"); + assert.equal(r.harvested, true); + assert.equal(r.streamFinished, true); + assert.equal(seen.join(""), "recovered answer"); + }); + + it("emits only the missing tail when a prefix was already streamed", async () => { + const seen = []; + const proxy = historyProxy([ + { id: "u1", role: "user", content: "p" }, + { id: "a1", role: "assistant", content: "partial full complete" }, + ]); + const r = await harvestTransportFailedCompletion({ + chatId: "c1", + streamedText: "partial ", + onText: (t) => seen.push(t), + getProxy: async () => proxy, + pollMs: 1, + timeoutMs: 200, + }); + assert.equal(seen.join(""), "full complete"); + assert.equal(r.text, "partial full complete"); + }); + + it("returns null when the POST was not delivered (caller may re-POST)", async () => { + const proxy = historyProxy([]); + const r = await harvestTransportFailedCompletion({ + chatId: "c1", + streamedText: "", + getProxy: async () => proxy, + pollMs: 1, + timeoutMs: 200, + }); + assert.equal(r, null); + }); + + it("returns null when getProxy itself throws (transport totally dead)", async () => { + const r = await harvestTransportFailedCompletion({ + chatId: "c1", + getProxy: async () => { throw new Error("proxy dead"); }, + pollMs: 1, + timeoutMs: 100, + }); + assert.equal(r, null); + }); +}); From d5a3d8d7063d680a2f96b1ac1e622bdd30f01fbb Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 24 Aug 2026 14:18:32 +0300 Subject: [PATCH 13/13] fix(qwen): harvest on first-content timeout, repair missing arguments key, idle backoff Field reports (2026-08-23/24, kilocode over 'npm run api', port 4318): 1. EMPTY_UPSTREAM_STREAM after a series of successful tool calls. The server accepted the completion POST but emitted no SSE chunk within firstContentMs (240s on degraded days) - while generation often KEEPS RUNNING server-side (the 2026-08-21 incident proved answers get saved to chat history). New: before throwing EMPTY_UPSTREAM_STREAM, #completionRound polls chat history via harvestAfterFirstContentTimeout -> harvestTransportFailedCompletion and returns the saved answer; the error only surfaces when the server truly produced nothing. 2. '[Error parsing tool call JSON from model]': degraded Qwen drops the "arguments" key and inlines the argument object right after the name: {"name": "read_file", {"path": ...}} - invalid JSON, the whole tool_calls block fell through as prose and the turn was lost. New repairMissingArgumentsKey inserts the missing key; wired into parseCallsJson, extractBareToolCallsArray and extractBareToolCalls repair chains. 3. Retries hammered a degraded upstream instantly. runWithEmptyStreamRetry now waits exponential backoff (1s, 2s... capped at 30s; injectable backoffBaseMs for tests) between empty-stream attempts. 4. idleMs default 90s -> 360s: healthy Qwen reasoning phases between visible deltas can exceed 90s on degraded days. Full suite: 607 tests, 605 pass, 0 fail. --- api/stream-retry.mjs | 9 +++ api/tool-calls.mjs | 54 +++++++++++-- plugin-for-vscode/api/stream-retry.mjs | 9 +++ plugin-for-vscode/api/tool-calls.mjs | 54 +++++++++++-- .../src/providers/qwen/client.mjs | 62 +++++++++++++++ .../src/providers/qwen/stream-timeouts.mjs | 2 +- src/providers/qwen/client.mjs | 62 +++++++++++++++ src/providers/qwen/stream-timeouts.mjs | 2 +- test/qwen-first-content-harvest.test.mjs | 60 ++++++++++++++ test/qwen-stream-timeouts.test.mjs | 2 +- test/stream-retry.test.mjs | 17 ++++ test/tool-call-json-repair.test.mjs | 78 +++++++++++++++++++ 12 files changed, 396 insertions(+), 15 deletions(-) create mode 100644 test/qwen-first-content-harvest.test.mjs create mode 100644 test/tool-call-json-repair.test.mjs diff --git a/api/stream-retry.mjs b/api/stream-retry.mjs index aec37f8..d68aa05 100644 --- a/api/stream-retry.mjs +++ b/api/stream-retry.mjs @@ -4,6 +4,10 @@ export async function runWithEmptyStreamRetry({ beforeRetry = null, maxAttempts = 2, requireDelta = false, + // Экспоненциальный backoff между ретраями пустого стрима: base, 2*base… + // capped. По умолчанию base=1000ms, cap=30s. Тесты инжектят base=1. + backoffBaseMs = 1000, + backoffCapMs = 30_000, }) { let emitted = false; const emit = (delta) => { @@ -20,6 +24,11 @@ export async function runWithEmptyStreamRetry({ return result; } catch (error) { if (emitted || error?.code !== "EMPTY_UPSTREAM_STREAM" || attempt >= maxAttempts) throw error; + // Пауза перед повторной попыткой: на деградированных днях Qwen может + // «остыть» за пару секунд; мгновенный повтор только разгоняет + // риск-скоринг Baxia. + const delayMs = Math.min(backoffBaseMs * 2 ** (attempt - 1), backoffCapMs); + await new Promise((resolve) => setTimeout(resolve, delayMs)); await beforeRetry?.({ attempt, error }); } } diff --git a/api/tool-calls.mjs b/api/tool-calls.mjs index 56c5564..4be125d 100644 --- a/api/tool-calls.mjs +++ b/api/tool-calls.mjs @@ -82,7 +82,16 @@ function extractToolCallsBlock(source) { } function parseCallsJson(jsonStr) { - const attempts = [jsonStr, escapeUnescapedInnerQuotes(jsonStr)]; + // Серия ремонтов деградированного JSON (2026-08-24): пропавший ключ + // "arguments" + неэкранированные внутренние кавычки. Комбинируем + // стратегии, дедуплицируем, пробуем по порядку. + const attempts = [...new Set([ + jsonStr, + repairMissingArgumentsKey(jsonStr), + escapeUnescapedInnerQuotes(jsonStr), + repairMissingArgumentsKey(escapeUnescapedInnerQuotes(jsonStr)), + escapeUnescapedInnerQuotes(repairMissingArgumentsKey(jsonStr)), + ])]; for (const attempt of attempts) { try { const parsed = JSON.parse(attempt); @@ -137,6 +146,25 @@ export function repairTruncatedToolCallJson(jsonStr) { } } +// Ремонт деградированного Qwen-вывода: модель роняет ключ "arguments" и +// вставляет объект аргументов сразу после имени: +// {"name": "read_file", {"path": "..."}} (невалидный JSON) +// вместо +// {"name": "read_file", "arguments": {"path": "..."}} +// Эвристика: {"name": "...", { → вставляем "arguments": перед второй "{". +// Ограничиваем матч именами тулов (безопасно для прозы со сложными скобками). +export function repairMissingArgumentsKey(jsonStr) { + const s = String(jsonStr || ""); + try { + JSON.parse(s); + return s; + } catch { /* repair below */ } + return s.replace( + /("name"\s*:\s*"[^"]+"\s*,\s*)(\{\s*\n?\s*")/g, + '$1"arguments": $2', + ); +} + // Ремонт неэкранированных двойных кавычек внутри JSON-строк. // Деградировавший Qwen кладёт shell-команды с quoted-аргументами в // "command" без экранирования: "grep -n "foo" bar" ломает JSON. @@ -232,7 +260,12 @@ export function extractBareToolCallsArray(text) { // Обрезанный объект — пробуем ремонт. candidate = repairTruncatedToolCallJson(source.slice(braceStart)); } - const attempts = [candidate, repairTruncatedToolCallJson(candidate)]; + const attempts = [ + candidate, + repairMissingArgumentsKey(candidate), + repairTruncatedToolCallJson(candidate), + repairTruncatedToolCallJson(repairMissingArgumentsKey(candidate)), + ]; for (const attempt of attempts) { try { const obj = JSON.parse(attempt); @@ -263,11 +296,20 @@ export function extractBareToolCalls(text, { allowedNames } = {}) { if (end === -1) continue; let parsed; - try { - parsed = JSON.parse(source.slice(start, end + 1)); - } catch { - continue; + const rawCandidate = source.slice(start, end + 1); + const candidates = [...new Set([ + rawCandidate, + repairMissingArgumentsKey(rawCandidate), + escapeUnescapedInnerQuotes(rawCandidate), + repairMissingArgumentsKey(escapeUnescapedInnerQuotes(rawCandidate)), + ])]; + for (const candidate of candidates) { + try { + parsed = JSON.parse(candidate); + break; + } catch { /* next candidate */ } } + if (!parsed) continue; const call = normalizeCall(parsed); const hasExplicitArguments = parsed && typeof parsed === "object" diff --git a/plugin-for-vscode/api/stream-retry.mjs b/plugin-for-vscode/api/stream-retry.mjs index aec37f8..d68aa05 100644 --- a/plugin-for-vscode/api/stream-retry.mjs +++ b/plugin-for-vscode/api/stream-retry.mjs @@ -4,6 +4,10 @@ export async function runWithEmptyStreamRetry({ beforeRetry = null, maxAttempts = 2, requireDelta = false, + // Экспоненциальный backoff между ретраями пустого стрима: base, 2*base… + // capped. По умолчанию base=1000ms, cap=30s. Тесты инжектят base=1. + backoffBaseMs = 1000, + backoffCapMs = 30_000, }) { let emitted = false; const emit = (delta) => { @@ -20,6 +24,11 @@ export async function runWithEmptyStreamRetry({ return result; } catch (error) { if (emitted || error?.code !== "EMPTY_UPSTREAM_STREAM" || attempt >= maxAttempts) throw error; + // Пауза перед повторной попыткой: на деградированных днях Qwen может + // «остыть» за пару секунд; мгновенный повтор только разгоняет + // риск-скоринг Baxia. + const delayMs = Math.min(backoffBaseMs * 2 ** (attempt - 1), backoffCapMs); + await new Promise((resolve) => setTimeout(resolve, delayMs)); await beforeRetry?.({ attempt, error }); } } diff --git a/plugin-for-vscode/api/tool-calls.mjs b/plugin-for-vscode/api/tool-calls.mjs index 56c5564..4be125d 100644 --- a/plugin-for-vscode/api/tool-calls.mjs +++ b/plugin-for-vscode/api/tool-calls.mjs @@ -82,7 +82,16 @@ function extractToolCallsBlock(source) { } function parseCallsJson(jsonStr) { - const attempts = [jsonStr, escapeUnescapedInnerQuotes(jsonStr)]; + // Серия ремонтов деградированного JSON (2026-08-24): пропавший ключ + // "arguments" + неэкранированные внутренние кавычки. Комбинируем + // стратегии, дедуплицируем, пробуем по порядку. + const attempts = [...new Set([ + jsonStr, + repairMissingArgumentsKey(jsonStr), + escapeUnescapedInnerQuotes(jsonStr), + repairMissingArgumentsKey(escapeUnescapedInnerQuotes(jsonStr)), + escapeUnescapedInnerQuotes(repairMissingArgumentsKey(jsonStr)), + ])]; for (const attempt of attempts) { try { const parsed = JSON.parse(attempt); @@ -137,6 +146,25 @@ export function repairTruncatedToolCallJson(jsonStr) { } } +// Ремонт деградированного Qwen-вывода: модель роняет ключ "arguments" и +// вставляет объект аргументов сразу после имени: +// {"name": "read_file", {"path": "..."}} (невалидный JSON) +// вместо +// {"name": "read_file", "arguments": {"path": "..."}} +// Эвристика: {"name": "...", { → вставляем "arguments": перед второй "{". +// Ограничиваем матч именами тулов (безопасно для прозы со сложными скобками). +export function repairMissingArgumentsKey(jsonStr) { + const s = String(jsonStr || ""); + try { + JSON.parse(s); + return s; + } catch { /* repair below */ } + return s.replace( + /("name"\s*:\s*"[^"]+"\s*,\s*)(\{\s*\n?\s*")/g, + '$1"arguments": $2', + ); +} + // Ремонт неэкранированных двойных кавычек внутри JSON-строк. // Деградировавший Qwen кладёт shell-команды с quoted-аргументами в // "command" без экранирования: "grep -n "foo" bar" ломает JSON. @@ -232,7 +260,12 @@ export function extractBareToolCallsArray(text) { // Обрезанный объект — пробуем ремонт. candidate = repairTruncatedToolCallJson(source.slice(braceStart)); } - const attempts = [candidate, repairTruncatedToolCallJson(candidate)]; + const attempts = [ + candidate, + repairMissingArgumentsKey(candidate), + repairTruncatedToolCallJson(candidate), + repairTruncatedToolCallJson(repairMissingArgumentsKey(candidate)), + ]; for (const attempt of attempts) { try { const obj = JSON.parse(attempt); @@ -263,11 +296,20 @@ export function extractBareToolCalls(text, { allowedNames } = {}) { if (end === -1) continue; let parsed; - try { - parsed = JSON.parse(source.slice(start, end + 1)); - } catch { - continue; + const rawCandidate = source.slice(start, end + 1); + const candidates = [...new Set([ + rawCandidate, + repairMissingArgumentsKey(rawCandidate), + escapeUnescapedInnerQuotes(rawCandidate), + repairMissingArgumentsKey(escapeUnescapedInnerQuotes(rawCandidate)), + ])]; + for (const candidate of candidates) { + try { + parsed = JSON.parse(candidate); + break; + } catch { /* next candidate */ } } + if (!parsed) continue; const call = normalizeCall(parsed); const hasExplicitArguments = parsed && typeof parsed === "object" diff --git a/plugin-for-vscode/src/providers/qwen/client.mjs b/plugin-for-vscode/src/providers/qwen/client.mjs index faeb741..8797862 100644 --- a/plugin-for-vscode/src/providers/qwen/client.mjs +++ b/plugin-for-vscode/src/providers/qwen/client.mjs @@ -49,6 +49,14 @@ export function throwIfQwenFirstContentTimeout(result) { throw error; } +// Детект: стрим умер по first-content timeout (сервер принял POST, но не +// отдал ни чанка за firstContentMs). Именно этот кейс kilocode видит как +// EMPTY_UPSTREAM_STREAM после серии успешных tool calls. +export function isQwenFirstContentTimeoutResult(result) { + return Number(result?.status) === 0 + && /qwen_stream_first_content_timeout/i.test(String(result?.text || "")); +} + function throwIfQwenAuthFailure(status, text, context) { try { throwIfQwenSessionExpiredFromHttp(status, text, context); @@ -510,6 +518,24 @@ export class QwenChatClient { }) : await proxy.proxyFetch({ url, body: bodyStr, chatId }); + // First-content timeout (2026-08-24, kilocode): сервер принял POST, + // но молчит дольше firstContentMs. Генерация на сервере часто жива — + // сначала harvest истории, ошибка только если и там пусто. + if (isQwenFirstContentTimeoutResult(result)) { + const harvested = await harvestAfterFirstContentTimeout({ + result, + chatId, + onText, + getProxy: () => getQwenBrowserProxy({ debug: this.debug }), + debug: this.debug, + }); + if (harvested.recovered) { + return { + result: finalizeQwenCompletionResult(harvested.recovered, "", "completion (browser, first-content harvest)"), + chatInProgressStuck: false, + }; + } + } throwIfQwenFirstContentTimeout(result); // Прокси пометил ответ как Baxia punish (антибот-капча) — // кулдаун уже включён в прокси, наверх уходит понятная ошибка. @@ -1117,6 +1143,42 @@ export function streamHarvestTail({ harvestedText, streamedText, onText }) { } } +// First-content timeout (2026-08-24, kilocode over npm run api): сервер +// принял completion-POST, но не выдал ни чанка за firstContentMs (240s на +// деградированных днях). При этом генерация на сервере ЧАСТО ПРОДОЛЖАЕТСЯ — +// инцидент 2026-08-21 доказал, что ответ сохраняется в истории чата. +// Вместо мгновенного EMPTY_UPSTREAM_STREAM — пробуем harvest истории; +// если сервер так и не родил контент, ошибка уходит наверх как раньше. +export async function harvestAfterFirstContentTimeout({ + result, + chatId, + onText = null, + getProxy, + pollMs, + timeoutMs, + debug = false, +}) { + if (!isQwenFirstContentTimeoutResult(result)) { + return { applicable: false }; + } + const recovered = await harvestTransportFailedCompletion({ + chatId, + streamedText: "", + onText, + getProxy, + pollMs, + timeoutMs, + debug, + }); + if (!recovered) return { applicable: true, recovered: null }; + providerLogger.warn("provider.qwen.stream_resume", { + operation: "first_content_timeout_harvest", + chatId, + chars: recovered.text?.length || 0, + }); + return { applicable: true, recovered }; +} + function findQwenErrorInSseText(text) { const blocks = text.split(/\r?\n\r?\n/).filter(Boolean); for (const raw of blocks) { diff --git a/plugin-for-vscode/src/providers/qwen/stream-timeouts.mjs b/plugin-for-vscode/src/providers/qwen/stream-timeouts.mjs index cee7afa..f1bf5c4 100644 --- a/plugin-for-vscode/src/providers/qwen/stream-timeouts.mjs +++ b/plugin-for-vscode/src/providers/qwen/stream-timeouts.mjs @@ -2,6 +2,6 @@ export function resolveQwenStreamTimeouts(env = process.env) { return { fetchMs: Number(env.QWEN_FETCH_TIMEOUT_MS || 600_000), firstContentMs: Number(env.QWEN_STREAM_FIRST_CONTENT_TIMEOUT_MS || 240_000), - idleMs: Number(env.QWEN_STREAM_IDLE_TIMEOUT_MS || 90_000), + idleMs: Number(env.QWEN_STREAM_IDLE_TIMEOUT_MS || 360_000), }; } diff --git a/src/providers/qwen/client.mjs b/src/providers/qwen/client.mjs index faeb741..8797862 100644 --- a/src/providers/qwen/client.mjs +++ b/src/providers/qwen/client.mjs @@ -49,6 +49,14 @@ export function throwIfQwenFirstContentTimeout(result) { throw error; } +// Детект: стрим умер по first-content timeout (сервер принял POST, но не +// отдал ни чанка за firstContentMs). Именно этот кейс kilocode видит как +// EMPTY_UPSTREAM_STREAM после серии успешных tool calls. +export function isQwenFirstContentTimeoutResult(result) { + return Number(result?.status) === 0 + && /qwen_stream_first_content_timeout/i.test(String(result?.text || "")); +} + function throwIfQwenAuthFailure(status, text, context) { try { throwIfQwenSessionExpiredFromHttp(status, text, context); @@ -510,6 +518,24 @@ export class QwenChatClient { }) : await proxy.proxyFetch({ url, body: bodyStr, chatId }); + // First-content timeout (2026-08-24, kilocode): сервер принял POST, + // но молчит дольше firstContentMs. Генерация на сервере часто жива — + // сначала harvest истории, ошибка только если и там пусто. + if (isQwenFirstContentTimeoutResult(result)) { + const harvested = await harvestAfterFirstContentTimeout({ + result, + chatId, + onText, + getProxy: () => getQwenBrowserProxy({ debug: this.debug }), + debug: this.debug, + }); + if (harvested.recovered) { + return { + result: finalizeQwenCompletionResult(harvested.recovered, "", "completion (browser, first-content harvest)"), + chatInProgressStuck: false, + }; + } + } throwIfQwenFirstContentTimeout(result); // Прокси пометил ответ как Baxia punish (антибот-капча) — // кулдаун уже включён в прокси, наверх уходит понятная ошибка. @@ -1117,6 +1143,42 @@ export function streamHarvestTail({ harvestedText, streamedText, onText }) { } } +// First-content timeout (2026-08-24, kilocode over npm run api): сервер +// принял completion-POST, но не выдал ни чанка за firstContentMs (240s на +// деградированных днях). При этом генерация на сервере ЧАСТО ПРОДОЛЖАЕТСЯ — +// инцидент 2026-08-21 доказал, что ответ сохраняется в истории чата. +// Вместо мгновенного EMPTY_UPSTREAM_STREAM — пробуем harvest истории; +// если сервер так и не родил контент, ошибка уходит наверх как раньше. +export async function harvestAfterFirstContentTimeout({ + result, + chatId, + onText = null, + getProxy, + pollMs, + timeoutMs, + debug = false, +}) { + if (!isQwenFirstContentTimeoutResult(result)) { + return { applicable: false }; + } + const recovered = await harvestTransportFailedCompletion({ + chatId, + streamedText: "", + onText, + getProxy, + pollMs, + timeoutMs, + debug, + }); + if (!recovered) return { applicable: true, recovered: null }; + providerLogger.warn("provider.qwen.stream_resume", { + operation: "first_content_timeout_harvest", + chatId, + chars: recovered.text?.length || 0, + }); + return { applicable: true, recovered }; +} + function findQwenErrorInSseText(text) { const blocks = text.split(/\r?\n\r?\n/).filter(Boolean); for (const raw of blocks) { diff --git a/src/providers/qwen/stream-timeouts.mjs b/src/providers/qwen/stream-timeouts.mjs index cee7afa..f1bf5c4 100644 --- a/src/providers/qwen/stream-timeouts.mjs +++ b/src/providers/qwen/stream-timeouts.mjs @@ -2,6 +2,6 @@ export function resolveQwenStreamTimeouts(env = process.env) { return { fetchMs: Number(env.QWEN_FETCH_TIMEOUT_MS || 600_000), firstContentMs: Number(env.QWEN_STREAM_FIRST_CONTENT_TIMEOUT_MS || 240_000), - idleMs: Number(env.QWEN_STREAM_IDLE_TIMEOUT_MS || 90_000), + idleMs: Number(env.QWEN_STREAM_IDLE_TIMEOUT_MS || 360_000), }; } diff --git a/test/qwen-first-content-harvest.test.mjs b/test/qwen-first-content-harvest.test.mjs new file mode 100644 index 0000000..122ff98 --- /dev/null +++ b/test/qwen-first-content-harvest.test.mjs @@ -0,0 +1,60 @@ +import { describe, it } from "node:test"; +import { strict as assert } from "node:assert"; + +import { harvestAfterFirstContentTimeout } from "../src/providers/qwen/client.mjs"; + +// 2026-08-24, kilocode over `npm run api` (port 4318): после серии успешных +// tool calls Qwen принимает completion-POST, но не отдаёт ни одного чанка +// за firstContentMs (240s) → EMPTY_UPSTREAM_STREAM. При этом серверная +// генерация часто ПРОДОЛЖАЕТСЯ (proof: инцидент 2026-08-21 — ответ сохранён +// в истории чата). Лечение: вместо мгновенной ошибки — harvest истории. + +const timeoutResult = { status: 0, text: "Error: qwen_stream_first_content_timeout" }; + +const historyProxy = (messages) => ({ + proxyApiGet: async () => ({ ok: true, status: 200, json: { data: { messages } } }), +}); + +describe("harvestAfterFirstContentTimeout", () => { + it("is not applicable for non-timeout results", async () => { + const r = await harvestAfterFirstContentTimeout({ + result: { status: 200, text: "" }, + chatId: "c1", + getProxy: async () => historyProxy([]), + }); + assert.equal(r.applicable, false); + assert.equal(r.recovered, undefined); + }); + + it("harvests the finished answer when the server kept generating", async () => { + const seen = []; + const r = await harvestAfterFirstContentTimeout({ + result: timeoutResult, + chatId: "c1", + onText: (t) => seen.push(t), + getProxy: async () => historyProxy([ + { id: "u1", role: "user", content: "p" }, + { id: "a1", role: "assistant", content: "slow but complete answer" }, + ]), + pollMs: 1, + timeoutMs: 500, + }); + assert.equal(r.applicable, true); + assert.ok(r.recovered, "must recover"); + assert.equal(r.recovered.text, "slow but complete answer"); + assert.equal(r.recovered.harvestedViaTransportRecovery, true); + assert.equal(seen.join(""), "slow but complete answer"); + }); + + it("returns no recovery when the POST was never delivered", async () => { + const r = await harvestAfterFirstContentTimeout({ + result: timeoutResult, + chatId: "c1", + getProxy: async () => historyProxy([]), + pollMs: 1, + timeoutMs: 400, + }); + assert.equal(r.applicable, true); + assert.equal(r.recovered, null); + }); +}); diff --git a/test/qwen-stream-timeouts.test.mjs b/test/qwen-stream-timeouts.test.mjs index 8dce82d..1e858de 100644 --- a/test/qwen-stream-timeouts.test.mjs +++ b/test/qwen-stream-timeouts.test.mjs @@ -8,7 +8,7 @@ describe("Qwen stream timeouts", () => { assert.equal(timeouts.fetchMs, 600_000); assert.equal(timeouts.firstContentMs, 240_000); - assert.equal(timeouts.idleMs, 90_000); + assert.equal(timeouts.idleMs, 360_000); }); it("keeps explicit environment overrides", () => { diff --git a/test/stream-retry.test.mjs b/test/stream-retry.test.mjs index 347432e..cc159ca 100644 --- a/test/stream-retry.test.mjs +++ b/test/stream-retry.test.mjs @@ -37,6 +37,23 @@ describe("empty provider stream retry", () => { }), /without response/); assert.equal(attempts, 1); }); + + it("waits exponential backoff between empty-stream retries (1s cap-scaled in tests)", async () => { + let attempts = 0; + const beforeRetryCalls = []; + await assert.rejects(() => runWithEmptyStreamRetry({ + operation: async () => { + attempts += 1; + throw emptyStreamError(); + }, + onDelta: () => {}, + maxAttempts: 3, + backoffBaseMs: 1, // тестовый масштаб: 1ms, 2ms вместо 1s, 2s + beforeRetry: async ({ attempt }) => { beforeRetryCalls.push(attempt); }, + }), /without response/); + assert.equal(attempts, 3); + assert.equal(beforeRetryCalls.length, 2); + }); }); function emptyStreamError() { diff --git a/test/tool-call-json-repair.test.mjs b/test/tool-call-json-repair.test.mjs new file mode 100644 index 0000000..a4c47d6 --- /dev/null +++ b/test/tool-call-json-repair.test.mjs @@ -0,0 +1,78 @@ +import { describe, it } from "node:test"; +import { strict as assert } from "node:assert"; + +import { + parseModelToolCalls, + extractBareToolCalls, + repairMissingArgumentsKey, +} from "../api/tool-calls.mjs"; + +// 2026-08-24: деградировавший Qwen роняет ключ "arguments" и вставляет +// объект аргументов сразу после имени: {"name": "read_file", {"path": ...}}. +// JSON невалиден → весь блок ```tool_calls падает в прозу → клиент видит +// "[Error parsing tool call JSON from model]" и ход теряется. + +describe("repairMissingArgumentsKey", () => { + it("inserts the arguments key when the model drops it", () => { + const repaired = repairMissingArgumentsKey('[{"name": "read_file", {"path": "D:\\\\x.mjs"}}]'); + const parsed = JSON.parse(repaired); + assert.equal(parsed[0].name, "read_file"); + assert.deepEqual(parsed[0].arguments, { path: "D:\\x.mjs" }); + }); + + it("repairs multiple calls in one array", () => { + const repaired = repairMissingArgumentsKey( + '[{"name": "a", {"x": 1}}, {"name": "b", {"y": 2}}]', + ); + const parsed = JSON.parse(repaired); + assert.deepEqual(parsed[0].arguments, { x: 1 }); + assert.deepEqual(parsed[1].arguments, { y: 2 }); + }); + + it("does not touch valid JSON", () => { + const src = '[{"name": "terminal", "arguments": {"command": "ls"}}]'; + assert.equal(repairMissingArgumentsKey(src), src); + }); + + it("does not mangle flat calls without braces-object (valid JSON stays)", () => { + const src = '{"name": "read_file", "path": "a.js"}'; + const out = repairMissingArgumentsKey(src); + assert.equal(JSON.parse(out).path, "a.js"); + assert.equal(JSON.parse(out).name, "read_file"); + }); +}); + +describe("model tool-call bridge with missing-arguments repair", () => { + it("parses a fenced block whose first call is missing the arguments key", () => { + const parsed = parseModelToolCalls([ + "Смотрю таймауты.", + "```tool_calls", + "[", + " {", + ' "name": "read_file",', + " {", + ' "path": "D:\\\\ai-free\\\\src\\\\x.mjs"', + " }", + " },", + " {", + ' "name": "terminal",', + ' "arguments": {"command": "grep -n QWEN .env"}', + " }", + "]", + "```", + ].join("\n")); + assert.equal(parsed.calls.length, 2); + assert.equal(parsed.calls[0].name, "read_file"); + assert.deepEqual(JSON.parse(parsed.calls[0].arguments), { path: "D:\\ai-free\\src\\x.mjs" }); + assert.deepEqual(JSON.parse(parsed.calls[1].arguments), { command: "grep -n QWEN .env" }); + }); + + it("recovers a bare malformed call embedded in prose", () => { + const calls = extractBareToolCalls( + 'Держи: {"name": "write_file", {"path": "a.js", "content": "x"}}', + ); + assert.equal(calls.length, 1); + assert.equal(calls[0].name, "write_file"); + assert.deepEqual(JSON.parse(calls[0].arguments), { path: "a.js", content: "x" }); + }); +});