Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 принимает на бэкенде. Как узнать:
Expand Down
128 changes: 109 additions & 19 deletions api/openai-handler.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,27 @@ 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 { 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";
import { createFileLogger } from "../src/logging/logger.mjs";
import { runWithEmptyStreamRetry } from "./stream-retry.mjs";

// parseModelToolCalls с предварительной зачисткой литеральных <think>-блоков:
// деградировавший Qwen кладёт reasoning (с черновиками tool-call JSON)
// прямо в текстовый канал — без зачистки детектор выдёргивает черновики
// как реальные вызовы ("Tool X does not exists" каскад).
export function parseModelToolCallsSafe(text) {
return parseModelToolCalls(stripToolErrorChips(stripThinkBlocks(text)));
}

const compatLogger = createFileLogger({ component: "openai-handler" });

// Тройной бэктик для вставки в template literals без raw-экранирования.
const F = "\u0060\u0060\u0060";

// Ленивый singleton Qwen-клиента — переиспользуем через все вызовы API.
let qwenClient = null;
// Ленивый singleton DeepSeek-клиента — переиспользуем через все вызовы API.
Expand Down Expand Up @@ -346,17 +358,34 @@ export function buildPromptFromChatBody(body, modelName, mapping) {
? `
NOTE FOR REASONING MODELS (R1 / QwQ / Reasoner):
- Do NOT wrap the final answer in <think>…</think>. 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
Expand Down Expand Up @@ -436,7 +465,21 @@ 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.`;

// Транскрипт заканчивается 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;
Expand Down Expand Up @@ -695,7 +738,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",
Expand Down Expand Up @@ -905,6 +948,13 @@ 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 литеральным текстом с <think>-тегами;
// внутри — черновики tool-call JSON, которые детектор ловил как реальные
// вызовы. Фильтр вырезает think-блоки до того, как буфер увидит парсер.
// Поверх — чипострига: ошибки бэкенда «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(() => {
Expand Down Expand Up @@ -953,7 +1003,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;
Expand All @@ -975,6 +1025,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,
Expand All @@ -989,6 +1042,8 @@ export async function handleQwenStream(client, chatId, prompt, modelName, model,
if (lastError) throw lastError;
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();
Expand Down Expand Up @@ -1039,7 +1094,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,
Expand Down Expand Up @@ -1092,7 +1146,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;
Expand Down Expand Up @@ -1129,7 +1183,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 });
Expand Down Expand Up @@ -1285,14 +1339,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) {
Expand Down Expand Up @@ -1587,11 +1649,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
Expand Down
9 changes: 9 additions & 0 deletions api/stream-retry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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 });
}
}
Expand Down
Loading