feat(qwen): multi-account pool with FreeQwenAPI-style menu and limit detection - #23
Open
firegoaway wants to merge 14 commits into
Open
feat(qwen): multi-account pool with FreeQwenAPI-style menu and limit detection#23firegoaway wants to merge 14 commits into
firegoaway wants to merge 14 commits into
Conversation
…r 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.
- 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
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
…ector - 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
…rsed as tool calls
Degraded Qwen emits reasoning as literal text with <think> 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 <think>, 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
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 </think> closers that appear without an opening tag
(degraded streams sometimes lose the opener); previously they leaked
into the visible answer as literal '</think>' text
- streaming wrapper now holds partial prefixes of BOTH tags ('<th..' and
'</th..') so tags split across chunks never leak
- tests: interleaved think/answer x3 phases, stray closers via strip and
streaming paths, chunk-split closers
…ry 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).
…ctions
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.
… Yes 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.
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.
…tput Field logs (2026-08-21): the degraded Qwen backend emits literal 'Tool <name> 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.
…id-round 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 Staks-sor#2 reached the user while answer Staks-sor#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.
…it detection - account-store.mjs: ~/.qwen-cli/accounts.json pool (FreeQwenAPI tokenManager model): round-robin getAvailableAccount, markRateLimited (resetAt cooldown, hours parsed from provider errorBody num), markInvalid/markValid, formatAccountStatus - account-setup.mjs: interactive menu for npm run login-qwen (1-add/2-relogin/ 3-remove/4-list/5-exit) reusing existing login flow with per-account profileDir - loginQwenAndSave accepts profileDir option; first run auto-migrates existing auth.json session to acc_1 - openai-handler: round-robin across pool (X-Telegram-User-Id affinity binding); upstream 401/unauthorized marks account invalid, 429/rate-limited sets cooldown in both stream and non-stream paths via markQwenAccountOnUpstreamError - browser-proxy: per-account persistent profiles from account records - sync desktop/plugin copies
- account-health.mjs: GET /api/v2/models/ probe per account (FreeQwenAPI testToken semantics, lightweight instead of completions POST which returned 504): 200/400 OK, 401/403 invalid, 429 rate-limited with hours from response body, updates store statuses - account-setup menu item 5: health-check all accounts from login-qwen - openai-handler: retry loop switches pool accounts mid-flight on auth/rate-limit/empty-stream errors (FreeQwenAPI retryAfterAccountSwitch semantics), up to 3 accounts per request; default path keeps refresh-and-retry behaviour - restore lost helpers (markQwenAccountOnUpstreamError, qwenRateLimitHours) that caused 'is not defined' crash in stream error path; marking wrapped in try/catch so store write failures never break error delivery - log active account in server console at routing and in qwen-timing
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Что сделано
Добавили поддержку нескольких аккаунтов для провайдера Qwen — перетащили проверенную схему из FreeQwenAPI (
tokenManager.js+accountSetup.js) в текущую систему, где логин происходит через ai-free и всё крутится на Playwright.По сути
src/providers/qwen/account-store.mjs(аналогtokenManager.js): пул аккаунтов живет в файле~/.qwen-cli/accounts.jsongetAvailableAccount()работает по принципу round-robin — по очереди берет аккаунты из списка (pointer % valid.length)markRateLimited(id, hours)ставит аккаунт на холодок с полемresetAt— время отдыха парсится из тела ошибки провайдера (полеnum), если не пришло — ставим дефолтные 24 часаmarkInvalid/markValidдля битых сессий;formatAccountStatusпоказывает статус: ✅ ОК / ⏳ отдыхает / ❌ битыйsrc/providers/qwen/account-setup.mjs(какaccountSetup.js+scripts/auth.js): интерактивная менюшка дляnpm run login-qwen1 добавить / 2 перелогин / 3 удалить / 4 список / 5 выход— всё как в FreeQwenAPI~/.qwen-cli/browser-profile-acc_<ts>, используется существующая функцияloginQwenAndSave()со скрытными скриптами и автоматическим ожиданием JWT (никаких ручных нажатий Enter, окно само закрывается — в отличие от FreeQwenAPI, там нужно было тыкать)auth.jsonавтоматом превращается вacc_1(берёт дефолтный профиль с сессией Google)Отлавливание лимитов провайдера (переосмысленный подход из FreeQwenAPI
chat.jsна 401/429):markInvalid) — он выпадает из ротации, пока не перелогинятсяmarkRateLimited(id, hours)) доresetAtmarkQwenAccountOnUpstreamError()(раньше в стримах ошибки проглатывались внутриhandleQwenStream, теперь и там помечаем)refreshClientперенесли до проверки на ошибку авторизации (раньше этот код был мёртвым для rate-limit ошибок)Маршрутизация: аккаунты раздаются по очереди (round-robin); заголовки
X-Telegram-User-Id/X-Chat-Idпривязывают пользователя к конкретному аккаунту (sticky affinity). Если аккаунтов в пуле нет — поведение не меняется, работает как раньше сauth.json.browser-proxy: у каждого аккаунта свой постоянный профиль, контексты изолированы друг от друга.
Все изменения синхронизированы между десктопной версией и плагином для VSCode.
Что не сделали (но планируется позже)
/accounts/test) — но само хранилище к этому готовоretryAfterAccountSwitch)Тесты
npm test— 594 теста, 592 прошли, 0 упало (2 пропущено, как и было до изменений)test/qwen-multiaccount.test.mjs: проверили, что аккаунты раздаются по кругу, что при rate-limite они уходят в холодок сresetAt, что битые/восстановленные правильно помечаются, и что всё сохраняется через переменнуюQWEN_ACCOUNTS_FILEHTTP 429 {"code":"RateLimited","num":8}аккаунт уходит в холодок на 8 часов; наHTTP 401 Unauthorized— помечается битым и больше не используетсяЭтот PR накладывается на #22 (пока тот не вольётся, все коммиты уже внутри).
Summary
Multi-account support for the Qwen provider, porting the proven architecture from FreeQwenAPI (
tokenManager.js+accountSetup.js) onto the current ai-free login flow and Playwright stack.What
src/providers/qwen/account-store.mjs(analog of FreeQwenAPItokenManager.js): pool in~/.qwen-cli/accounts.jsongetAvailableAccount()(samepointer % valid.lengthscheme)markRateLimited(id, hours)withresetAtcooldown — hours parsed from provider error body (numfield), default 24hmarkInvalid/markValidfor dead sessions;formatAccountStatus(✅ OK / ⏳ cooldown / ❌ invalid)src/providers/qwen/account-setup.mjs(analog ofaccountSetup.js+scripts/auth.js): interactive menu fornpm run login-qwen1 add / 2 relogin / 3 remove / 4 list / 5 exit, same as FreeQwenAPI~/.qwen-cli/browser-profile-acc_<ts>through the existingloginQwenAndSave(authFile, { profileDir })— stealth scripts and JWT auto-waiter preserved (no ENTER prompt, window closes itself — ai-free UX, unlike FreeQwenAPI)auth.jsonsession toacc_1(inherits default profile with Google session)chat.js401/429 handling):markInvalid— account leaves rotation until reloginmarkRateLimited(id, hours)— cooldown untilresetAtmarkQwenAccountOnUpstreamError()(stream path previously swallowed errors insidehandleQwenStream— now marked there)refreshClientmarking moved before the auth-error gate (was dead code for rate-limit errors)X-Telegram-User-Id/X-Chat-Idheader → sticky affinity binding (session-router over account-store); no accounts in pool → defaultauth.jsonbehaviour unchangedNot included (follow-ups)
/accounts/test) — the store API is ready for itretryAfterAccountSwitch)Tests
npm test— 594 tests, 592 pass, 0 fail (2 skipped, same as baseline)test/qwen-multiaccount.test.mjs: round-robin rotation, rate-limit exclusion withresetAt, invalid/markValid lifecycle, persistence viaQWEN_ACCOUNTS_FILEHTTP 429 {"code":"RateLimited","num":8}→ cooldown 8h;HTTP 401 Unauthorized→ invalid, account dropped from poolStacked on #22 (contains those commits until it merges).