Skip to content

feat(qwen): multi-account pool with FreeQwenAPI-style menu and limit detection - #23

Open
firegoaway wants to merge 14 commits into
Staks-sor:mainfrom
firegoaway:feat/qwen-multiaccount
Open

feat(qwen): multi-account pool with FreeQwenAPI-style menu and limit detection#23
firegoaway wants to merge 14 commits into
Staks-sor:mainfrom
firegoaway:feat/qwen-multiaccount

Conversation

@firegoaway

@firegoaway firegoaway commented Aug 21, 2026

Copy link
Copy Markdown

Что сделано

Добавили поддержку нескольких аккаунтов для провайдера Qwen — перетащили проверенную схему из FreeQwenAPI (tokenManager.js + accountSetup.js) в текущую систему, где логин происходит через ai-free и всё крутится на Playwright.

По сути

  • src/providers/qwen/account-store.mjs (аналог tokenManager.js): пул аккаунтов живет в файле ~/.qwen-cli/accounts.json

    • getAvailableAccount() работает по принципу 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-qwen

    • пункты: 1 добавить / 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):

    • если прилетает 401 / unauthorized / token expired → помечаем аккаунт как битый (markInvalid) — он выпадает из ротации, пока не перелогинятся
    • если 429 / RateLimited / quota → ставим на холодок (markRateLimited(id, hours)) до resetAt
    • это завязано и на стрим, и на обычные запросы через единый обработчик markQwenAccountOnUpstreamError() (раньше в стримах ошибки проглатывались внутри handleQwenStream, теперь и там помечаем)
    • refreshClient перенесли до проверки на ошибку авторизации (раньше этот код был мёртвым для rate-limit ошибок)
  • Маршрутизация: аккаунты раздаются по очереди (round-robin); заголовки X-Telegram-User-Id / X-Chat-Id привязывают пользователя к конкретному аккаунту (sticky affinity). Если аккаунтов в пуле нет — поведение не меняется, работает как раньше с auth.json.

  • browser-proxy: у каждого аккаунта свой постоянный профиль, контексты изолированы друг от друга.

  • Все изменения синхронизированы между десктопной версией и плагином для VSCode.

Что не сделали (но планируется позже)

  • активная проверка здоровья аккаунтов (как в FreeQwenAPI /accounts/test) — но само хранилище к этому готово
  • автоматический повтор запроса с другим аккаунтом, если первый упал (в FreeQwenAPI это retryAfterAccountSwitch)

Тесты

  • npm test — 594 теста, 592 прошли, 0 упало (2 пропущено, как и было до изменений)
  • отдельный набор тестов test/qwen-multiaccount.test.mjs: проверили, что аккаунты раздаются по кругу, что при rate-limite они уходят в холодок с resetAt, что битые/восстановленные правильно помечаются, и что всё сохраняется через переменную QWEN_ACCOUNTS_FILE
  • ручной смок: на HTTP 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 FreeQwenAPI tokenManager.js): pool in ~/.qwen-cli/accounts.json
    • round-robin getAvailableAccount() (same pointer % valid.length scheme)
    • markRateLimited(id, hours) with resetAt cooldown — hours parsed from provider error body (num field), default 24h
    • markInvalid / markValid for dead sessions; formatAccountStatus (✅ OK / ⏳ cooldown / ❌ invalid)
  • src/providers/qwen/account-setup.mjs (analog of accountSetup.js + scripts/auth.js): interactive menu for npm run login-qwen
    • 1 add / 2 relogin / 3 remove / 4 list / 5 exit, same as FreeQwenAPI
    • add = visible browser with per-account profile ~/.qwen-cli/browser-profile-acc_<ts> through the existing loginQwenAndSave(authFile, { profileDir }) — stealth scripts and JWT auto-waiter preserved (no ENTER prompt, window closes itself — ai-free UX, unlike FreeQwenAPI)
    • first run auto-migrates the existing auth.json session to acc_1 (inherits default profile with Google session)
  • Provider limit detection (semantic port of FreeQwenAPI chat.js 401/429 handling):
    • upstream 401 / unauthorized / token expiredmarkInvalid — account leaves rotation until relogin
    • upstream 429 / RateLimited / quotamarkRateLimited(id, hours) — cooldown until resetAt
    • wired into both stream and non-stream paths via single markQwenAccountOnUpstreamError() (stream path previously swallowed errors inside handleQwenStream — now marked there)
    • refreshClient marking moved before the auth-error gate (was dead code for rate-limit errors)
  • Routing: round-robin across pool; X-Telegram-User-Id / X-Chat-Id header → sticky affinity binding (session-router over account-store); no accounts in pool → default auth.json behaviour unchanged
  • browser-proxy: per-account persistent profiles taken from account records; context isolation preserved
  • desktop / plugin-for-vscode copies kept in sync

Not included (follow-ups)

  • active health-check probe (FreeQwenAPI /accounts/test) — the store API is ready for it
  • retry of a failed request on another account mid-flight (FreeQwenAPI retryAfterAccountSwitch)

Tests

  • npm test — 594 tests, 592 pass, 0 fail (2 skipped, same as baseline)
  • focused suite test/qwen-multiaccount.test.mjs: round-robin rotation, rate-limit exclusion with resetAt, invalid/markValid lifecycle, persistence via QWEN_ACCOUNTS_FILE
  • smoke: provider error HTTP 429 {"code":"RateLimited","num":8} → cooldown 8h; HTTP 401 Unauthorized → invalid, account dropped from pool

Stacked on #22 (contains those commits until it merges).

…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
@firegoaway

firegoaway commented Aug 21, 2026

Copy link
Copy Markdown
Author

KiloCode работает с npm run api на порту 4318
image

Проблема лишь в том, что KiloCode сам по себе часто стримит tool_calls между SSE-чанками, в которые часто попадает бэкграунд рутина
image
а потом всё равно продолжает рутину в том же духе.

Также замечена ошибка "Upstream model stream ended without response content.", которая, вероятнее всего, ничем не лечится
image

В пиковые часы Qwen даже при нормальном использовании в рамках своей экосистемы и веб-морды chat.qwen.ai часто выдаёт "Слишком много запросов. Попробуйте позже" или что-то вроде того. Так что редкие апстрим таймауты - вполне стандартная история для Qwen.

Как вариант, для борьбы с апстрим таймаутами можно реализовать Upstream Timeout Detector c последующей отправкой вейкап-чанков. Как только прокси детектит апстрим таймаут, он автоматически шлёт в тот же чат что-то типа "retry" или "again", продолжая слушать текущий апстрим.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant