QwenHandlerSupervisor is a FastAPI-based orchestrator that routes /v1/solve requests to a pool of upstream “browser containers” (workers). It adds:
-
Multi-container routing with health/busy checks and manual container exclusion via chat locks
-
Profile isolation (per-request browser profile directory) with process-local locking to prevent concurrent use
-
SOCKS proxy management (by
socks_idor direct URL override) -
Prompt registry backed by files (start prompt sent once when a new chat is created)
-
SQLite-backed state and observability (jobs, attempts, chat sessions, usage reports)
-
guest/archive protection: profiles with
chat_id='guest'/tag='guest'are blocked; archived chats (tag='archive'/disabled=1) are never reused; maintenance APIs are provided. -
Optional per-container I/O logging (JSONL, with secret redaction)
This repository ships the full multi-container mode.
CONFIG_PATHis required to start the app.
- Quick start
- How it works
- Upstream worker (Qwen Camoufox Inference Worker)
- Installation
- Configuration
- Running
- API
- Examples
- SQLite data model
- Logging & observability
- Operational notes & caveats
- Troubleshooting
- Create a virtual environment and install deps:
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
pip install pyyaml-
Prepare
config.yaml(see Configuration) and prompt files. -
Run:
export CONFIG_PATH=./config.yaml
export SQLITE_PATH=./data/orchestrator.sqlite
uvicorn src.app.main:app --host 0.0.0.0 --port 9000- Test health:
curl -s http://127.0.0.1:9000/healthAt a high level the request path for /v1/solve is:
- PromptRegistry resolves
prompt_id→ reads the prompt file (cached by mtime). - ProfileManager resolves
profile_id→ browserprofile_value+ SOCKS (socks_id→ URL) +allowed_containers. - ProfileLock prevents using the same
profile_idconcurrently. - ContainerSelector picks an enabled, non-busy container that is not excluded by chat locks.
- ChatManager reuses an existing chat session or creates a new one:
- when creating a new chat it sends
start_promptonce and storespage_urlcontaining/c/<chat_id>.
- when creating a new chat it sends
- UpstreamClient calls upstream
/analyze(or legacy/analyze_text) using:url = chat_session.page_urlprofile = profile_valuesocks = socks_url
All state is recorded in SQLite:
jobs— one row per/v1/solvejob_attempts— per-container attemptchat_sessions— persistent chat URLs and usage countersprofiles/socks— inventory seeded from YAML
This orchestrator does not drive the browser itself. Each configured container is expected to run an upstream HTTP service (one per container) named:
Qwen Camoufox Inference Worker — an HTTP service that controls exactly one Camoufox/Playwright browser instance inside a container and, via the Qwen Chat web UI, performs:
- Text analysis (clipboard → paste → send → wait for reply)
- Image analysis (copy image into the system clipboard → Ctrl+V into the input field → wait for attachment → send → wait for reply)
The worker is designed for one active browser and one task at a time (hard serialization). When the worker is busy, it should respond with HTTP 423; QwenHandlerSupervisor treats this as a transient “busy” signal and routes the job to another container (if available).
Repository (upstream component): https://github.com/kilax9276/qwen_inference_worker
The orchestrator talks to each worker using these endpoints (see src/app/upstream_client.py):
GET /healthGET /statusPOST /open— open a Qwen chat URL (optionally withprofile+socks)POST /analyze— analyze text or image (supports backward-compatible fallbacks like/analyze_text)
The exact worker implementation may be private or versioned separately; the orchestrator primarily relies on the endpoints and on HTTP 423 to detect “busy”.
- Python 3.10+ recommended
pip/ virtualenv- One or more upstream worker services (containers) that implement the Upstream API contract (see below)
The project pins core deps in requirements.txt:
fastapi,uvicornhttpxpydantic(+pydantic-settings)- tests:
pytest,pytest-asyncio
Additionally required for CONFIG_PATH mode: pyyaml (the config loader imports yaml at runtime).
Configuration is loaded from YAML file referenced by CONFIG_PATH.
containers[].base_url must point to the Qwen Camoufox Inference Worker HTTP service running inside that container.
allow_socks_override: true
containers:
- id: camoufox-1
base_url: http://127.0.0.1:8001
enabled: true
timeouts:
connect_seconds: 10
read_seconds: 120
analyze_retries: 1
socks:
- socks_id: s1
url: socks5://user:pass@1.2.3.4:1080
profiles:
- profile_id: p1
profile_value: /data/profiles/profile-001
socks_id: s1
allowed_containers: [camoufox-1]
max_uses: 100
pending_replace: false
prompts:
- prompt_id: default
file: ./prompts/default.txt
default_max_chat_uses: 50
container_io_log:
enabled: falseTop-level keys:
-
allow_socks_override(bool, default: true)
Allows per-request proxy override viaoptions.socks_override. -
containers(list)
Each item:id(str) — container identifier (used in routing and reporting)base_url(str) — upstream base URL (e.g.http://127.0.0.1:8001)enabled(bool, default: true) — iffalse, container is never selectedweight(int, default: 1) — reserved (currently selection is round-robin over available)timeouts.connect_seconds(float, default: 10)timeouts.read_seconds(float, default: 120)analyze_retries(int, default: 1) — retry count for transport errors (capped)
-
socks(list)
Each item:socks_id(str)url(str) — socks URL (credentials may be embedded)
-
profiles(list)
Each item:profile_id(str)profile_value(str) — path to a browser profile directory (passed to upstream)socks_id(str, optional) — default proxy for that profileallowed_containers(list[str]) — containers that may use this profilemax_uses(int, optional) — soft cap; when reached, profile is skipped by auto-selectionpending_replace(bool) — iftrue, profile is skipped by auto-selection
-
prompts(list)
Each item:prompt_id(str)file(str) — file path; relative paths are resolved against the config.yaml directorydefault_max_chat_uses(int, default: 50) — chat reuse limit
-
container_io_log(object)
Optional per-container I/O logging:enabled(bool)dir(str, default: ./logs/container-io) — directory for*.jsonlfilesmax_bytes(int),backup_count(int) — rotationinclude_bodies(bool) — log request/response bodiesredact_secrets(bool) — mask proxy credentials and common secret-like fieldsmax_field_chars(int) — truncate large string fieldslevel(str) — log level for IO logs
Each prompt file contains start_prompt text. When a new chat is created, the orchestrator sends this text once to upstream; upstream should return page_url containing /c/<chat_id>. This URL is then reused for subsequent messages.
CONFIG_PATH(required) — path to YAML configSQLITE_PATH(optional, default:./data/orchestrator.sqlite) — SQLite file pathORCH_LOG_LEVEL(optional, default: INFO) — orchestrator log level
export CONFIG_PATH=./config.yaml
export SQLITE_PATH=./data/orchestrator.sqlite
uvicorn src.app.main:app --host 0.0.0.0 --port 9000See ecosystem.config.js for a production-style PM2 configuration (sets absolute paths for CONFIG_PATH and SQLITE_PATH).
Base URL: http://<host>:9000
GET /health
Response:
{"ok": true}GET /v1/status?container_id=<id>
If container_id is omitted, returns status of the first enabled container.
GET /v1/status/all returns status for all enabled containers + DB path + guest blocked profiles and per-container chat-session enrichment (orchestrator_chat_session, orchestrator_flags).
POST /v1/solve
{
"request_id": "optional-id",
"input": {
"text": "string (optional)",
"image_b64": "base64 (optional)",
"image_ext": "png|jpg|... (required when image_b64 is set)"
},
"options": {
"prompt_id": "default",
"profile_id": "p1",
"socks_override": "s1 or socks5://user:pass@host:port",
"force_new_chat": false,
"max_chat_uses": 50,
"chat_url": "https://chat.qwen.ai/c/<id>",
"include_debug": false
}
}Rules:
- You must provide
input.textand/orinput.image_b64. - If
image_b64is provided,image_extis required. - In typical deployments
options.profile_idis required (auto-selection exists but is mainly for internal use). options.chat_urlpins the request to an existing stored chat session (must exist in SQLite).
Success:
{
"ok": true,
"final": { "text": "..." },
"meta": {
"job_id": "...",
"request_id": "...",
"prompt_id_selected": "default",
"container_ids_used": ["camoufox-1"],
"profile_id": "p1",
"socks_id": "s1",
"page_url": "https://chat.qwen.ai/c/abc123",
"started_at": "...",
"finished_at": "..."
}
}Error:
{
"ok": false,
"error": { "code": "CONTAINER_BUSY", "message": "..." },
"meta": { "job_id": "...", "request_id": "..." }
}Common error codes:
INVALID_REQUEST(HTTP 400)PROFILE_BLOCKED(HTTP 409) — profile is blocked due to guest chat (chat_id='guest'/tag='guest')PROFILE_BUSY(HTTP 503) — profile is locked by another in-flight requestCONTAINER_BUSY(HTTP 503) — no available containers / upstream returned busy (HTTP 423)UPSTREAM_ERROR(HTTP 502) — upstream 5xx or transport failures after retriesINTERNAL_ERROR(HTTP 500)
Locks are stored in SQLite (chat_sessions.locked_by/locked_until). While any chat in a container is locked and not expired, the container is excluded from routing.
Lock:
POST /v1/chats/lock (alias: /v1/chat/lock)
{
"chat_url": "https://chat.qwen.ai/c/abc123",
"locked_by": "operator",
"ttl_seconds": 600
}Unlock:
POST /v1/chats/unlock (alias: /v1/chat/unlock)
{
"chat_url": "https://chat.qwen.ai/c/abc123",
"locked_by": "operator"
}The orchestrator uses chat markers stored in chat_sessions to protect against “bad” chats:
- guest: if a profile has at least one chat with
chat_id='guest'(ortag='guest'), the profile is treated as blocked. The orchestrator:- will not send tasks to such chats;
- will not create new chats for that profile;
- returns
PROFILE_BLOCKED(HTTP 409) when the client explicitly pinsoptions.profile_id.
- archive: when
tag='archive'and/ordisabled=1is set, the chat is treated as archived and is never reused.
Maintenance endpoints:
GET /v1/profiles/blocked— list profiles blocked due to guest.POST /v1/profiles/{profile_id}/guest/clear— delete guest chat records for a profile (removes the block).POST /v1/profiles/{profile_id}/chats/archive— mark all chats of a profile asarchive(they won't be reused).
Tip: /v1/status/all shows blocked profiles under blocked.profiles, and per-container suitability in containers.*.orchestrator_flags.
All report endpoints require from and to query params in ISO8601 (offset-naive timestamps are treated as UTC).
GET /v1/reports/containers?from=...&to=...&limit=50&offset=0GET /v1/reports/profiles?from=...&to=...&limit=50&offset=0GET /v1/reports/prompts?from=...&to=...&limit=50&offset=0
Example:
curl -s "http://127.0.0.1:9000/v1/reports/containers?from=2026-02-01T00:00:00%2B00:00&to=2026-02-03T00:00:00%2B00:00"
curl -s "http://127.0.0.1:9000/v1/reports/profiles?from=2026-02-01T00:00:00%2B00:00&to=2026-02-03T00:00:00%2B00:00"
curl -s "http://127.0.0.1:9000/v1/reports/prompts?from=2026-02-01T00:00:00%2B00:00&to=2026-02-03T00:00:00%2B00:00"curl -s http://127.0.0.1:9000/v1/solve -H 'Content-Type: application/json' -d '{
"input": { "text": "Explain JWT in one paragraph" },
"options": { "prompt_id": "default", "profile_id": "p1" }
}'curl -s http://127.0.0.1:9000/v1/solve -H 'Content-Type: application/json' -d '{
"input": { "text": "Start fresh context" },
"options": { "prompt_id": "default", "profile_id": "p1", "force_new_chat": true }
}'
chat_urlmust exist in SQLite (was created by a previous request).
curl -s http://127.0.0.1:9000/v1/solve -H 'Content-Type: application/json' -d '{
"input": { "text": "Continue from previous context" },
"options": {
"prompt_id": "default",
"profile_id": "p1",
"chat_url": "https://chat.qwen.ai/c/abc123"
}
}'curl -s http://127.0.0.1:9000/v1/solve -H 'Content-Type: application/json' -d '{
"input": { "image_b64": "<BASE64>", "image_ext": "png" },
"options": { "prompt_id": "default", "profile_id": "p1" }
}'import requests
payload = {
"input": {"text": "Summarize OAuth2"},
"options": {"prompt_id": "default", "profile_id": "p1"}
}
r = requests.post("http://127.0.0.1:9000/v1/solve", json=payload, timeout=300)
r.raise_for_status()
print(r.json()["final"]["text"])SQLite is initialized automatically on startup (WAL mode when available).
Main tables:
socks(socks_id, url, created_at, updated_at)profiles(profile_id, profile_value, socks_id, allowed_containers_json, uses_count, max_uses, pending_replace, ...)chat_sessions(id, container_id, prompt_id, profile_id, socks_id, chat_id, page_url, uses_count, disabled, tag, locked_by, locked_until, ...)jobs(job_id, request_id, prompt_id, selected_prompt_id, decision_mode, ..., status, result_text, error_code, ...)job_attempts(attempt_id, job_id, container_id, prompt_id, role, ..., status, result_text, error_code, ...)
Chat markers semantics:
chat_id='guest'ortag='guest'→ the profile is blocked until cleared (POST /v1/profiles/{profile_id}/guest/clear).tag='archive'and/ordisabled=1→ the chat is archived and is not reused by the orchestrator.
Controlled via ORCH_LOG_LEVEL (or LOG_LEVEL). The orchestrator emits structured JSON messages for key lifecycle events (solve start/done, failures, etc.).
If enabled in config.yaml under container_io_log, the orchestrator writes JSONL logs per container:
logs/container-io/camoufox-1.jsonl
logs/container-io/camoufox-2.jsonl
...
Each line includes timestamps, request/response, status codes, duration, and best-effort redaction of sensitive values (e.g. proxy passwords).
- Profile locks are process-local. If you run
uvicornwith multiple workers, each worker has its own lock map. For strict safety, run with a single worker or implement a distributed lock. - Busy is best-effort. The orchestrator checks upstream
/status, but upstream can still become busy later (e.g. during start prompt). allowed_containersis enforced. A profile can only run on containers listed in itsallowed_containers.- Relative file paths in config (prompt files, IO log dir) are resolved against the directory containing
config.yaml. include_debugis intended for development only. If you rely on it, validate its output against your current schema.
The profile is blocked because chat_sessions contains guest records (chat_id='guest' and/or tag='guest'). While at least one guest record exists, the orchestrator will not use that profile and will not create new chats for it.
Check:
curl -s http://127.0.0.1:9000/v1/profiles/blocked
curl -s http://127.0.0.1:9000/v1/status/allClear:
curl -s -X POST http://127.0.0.1:9000/v1/profiles/<profile_id>/guest/clearImportant: if you explicitly pass options.profile_id, the orchestrator will not “switch” to another profile automatically — it will return an error for the selected profile.
Set CONFIG_PATH to a valid YAML file path before starting the app.
Install PyYAML:
pip install pyyaml- All containers are busy or locked
- A pinned
chat_urlpoints to a container that is currently busy - Upstream returned HTTP 423
Try:
/v1/status/allto inspect busy state- unlocking chats for that container
- adding more containers
Copyright © kilax9276 (Kolobov Aleksei)
Telegram: @kilax9276
Another request is currently using the same profile. Use different profile_id or wait for the in-flight request to finish.