From e75a0316e6087a108db450f9a95fac9d84be981e Mon Sep 17 00:00:00 2001 From: mirivlad Date: Thu, 9 Apr 2026 01:29:00 +0800 Subject: [PATCH] Document Qwen OAuth HTTP flow --- QWEN_OAUTH_HTTP_FLOW.md | 368 ++++++++++++++++++++++++++++++++++++++++ README.md | 4 + 2 files changed, 372 insertions(+) create mode 100644 QWEN_OAUTH_HTTP_FLOW.md diff --git a/QWEN_OAUTH_HTTP_FLOW.md b/QWEN_OAUTH_HTTP_FLOW.md new file mode 100644 index 0000000..0afc249 --- /dev/null +++ b/QWEN_OAUTH_HTTP_FLOW.md @@ -0,0 +1,368 @@ +# Qwen OAuth And HTTP Flow + +Этот документ фиксирует рабочий поток авторизации Qwen OAuth и последующего общения с моделью в `new-qwen` без вызова `qwen -p`. + +## Компоненты + +- `bot/app.py` принимает сообщения Telegram и ходит в `serv` по локальному HTTP API +- `serv/app.py` поднимает HTTP server, управляет OAuth, сессиями, jobs и вызовами моделей +- `serv/oauth.py` реализует Qwen OAuth Device Flow +- `serv/model_router.py` формирует рабочий OpenAI-compatible HTTP запрос в Qwen + +Текущая конфигурация: + +- `serv` слушает `127.0.0.1:8081` +- `bot` ходит в `http://127.0.0.1:8081` + +## 1. OAuth Device Flow + +Источник истины по OAuth endpoints: + +- `QWEN_OAUTH_BASE_URL = https://chat.qwen.ai` +- `device code endpoint = https://chat.qwen.ai/api/v1/oauth2/device/code` +- `token endpoint = https://chat.qwen.ai/api/v1/oauth2/token` +- `client_id = f0304373b74a44d2b584a3fb70ca9e56` +- `scope = openid profile email model.completion` +- `grant_type = urn:ietf:params:oauth:grant-type:device_code` + +### 1.1 Запуск device flow + +Telegram пользователь отправляет `/auth`. + +`bot` инициирует: + +- `POST /api/v1/auth/device/start` + +`serv` вызывает `QwenOAuthManager.start_device_flow()`, который делает: + +```http +POST https://chat.qwen.ai/api/v1/oauth2/device/code +Content-Type: application/x-www-form-urlencoded; charset=utf-8 +Accept: application/json +x-request-id: +``` + +Body: + +```x-www-form-urlencoded +client_id=f0304373b74a44d2b584a3fb70ca9e56 +scope=openid profile email model.completion +code_challenge= +code_challenge_method=S256 +``` + +Ответ содержит: + +- `device_code` +- `user_code` +- `verification_uri` +- `verification_uri_complete` +- `expires_in` + +Бот показывает пользователю ссылку вида: + +```text +https://chat.qwen.ai/authorize?user_code=ORRNFVIN&client=qwen-code +``` + +### 1.2 Polling device flow + +После подтверждения в браузере `bot` циклически вызывает: + +- `POST /api/v1/auth/device/poll` + +`serv` делает: + +```http +POST https://chat.qwen.ai/api/v1/oauth2/token +Content-Type: application/x-www-form-urlencoded; charset=utf-8 +Accept: application/json +x-request-id: +``` + +Body: + +```x-www-form-urlencoded +grant_type=urn:ietf:params:oauth:grant-type:device_code +client_id=f0304373b74a44d2b584a3fb70ca9e56 +device_code= +code_verifier= +``` + +Поведение: + +- `authorization_pending` => продолжаем polling +- `slow_down` => увеличиваем интервал polling +- success => сохраняем credentials + +### 1.3 Сохраняемые credentials + +Файл: + +- `~/.qwen/oauth_creds.json` + +Формат: + +```json +{ + "access_token": "...", + "refresh_token": "...", + "token_type": "Bearer", + "resource_url": "portal.qwen.ai", + "expiry_date": 1775689403149 +} +``` + +Поля: + +- `access_token` используется в `Authorization: Bearer ...` +- `refresh_token` нужен для refresh +- `resource_url` приходит от Qwen OAuth и используется как inference host +- `expiry_date` хранится в миллисекундах Unix epoch + +## 2. Refresh Token Flow + +Когда access token истекает, `serv/oauth.py` вызывает: + +```http +POST https://chat.qwen.ai/api/v1/oauth2/token +Content-Type: application/x-www-form-urlencoded; charset=utf-8 +Accept: application/json +``` + +Body: + +```x-www-form-urlencoded +grant_type=refresh_token +refresh_token= +client_id=f0304373b74a44d2b584a3fb70ca9e56 +``` + +На практике возможен WAF challenge на этом endpoint. Поэтому главный рабочий сценарий сейчас такой: + +- логин делается через device flow +- inference использует уже сохранённый `access_token` +- `resource_url` берётся из credentials + +## 3. Как bot ходит в serv + +Основной пользовательский запрос: + +- пользователь отправляет обычный текст в Telegram +- `bot/app.py` вызывает `POST /api/v1/chat/start` +- затем `bot` polling-ом вызывает `POST /api/v1/chat/poll` + +Если OAuth ещё не завершён: + +- бот кладёт сообщение в локальную очередь +- после успешного `/auth` автоматически отправляет queued message в `serv` + +## 4. Как serv ходит в Qwen model endpoint + +### 4.1 Базовый URL модели + +`serv/oauth.py -> get_openai_base_url()`: + +- берёт `resource_url` из `~/.qwen/oauth_creds.json` +- если нет схемы, добавляет `https://` +- если нет suffix `/v1`, добавляет `/v1` + +При текущем Qwen OAuth это обычно даёт: + +```text +https://portal.qwen.ai/v1 +``` + +### 4.2 Реальный рабочий endpoint + +`serv/model_router.py` делает: + +```http +POST https://portal.qwen.ai/v1/chat/completions +``` + +Headers: + +```http +Content-Type: application/json +Authorization: Bearer +User-Agent: QwenCode/unknown (linux; x64) +X-DashScope-CacheControl: enable +X-DashScope-UserAgent: QwenCode/unknown (linux; x64) +X-DashScope-AuthType: qwen-oauth +Accept: application/json +``` + +## 5. Критически важный формат request body + +Главная причина, почему ранние попытки ломались с: + +```json +{"error":{"code":"invalid_parameter_error","message":"bad request"}} +``` + +была в неверном формате `messages[].content`. + +Qwen Portal ожидает не строку, а массив content-blocks. + +### 5.1 Неправильный формат + +Это не работало: + +```json +{ + "model": "coder-model", + "messages": [ + { "role": "user", "content": "Ку" } + ] +} +``` + +### 5.2 Рабочий формат + +Это рабочий формат: + +```json +{ + "model": "coder-model", + "messages": [ + { + "role": "system", + "content": [ + { "type": "text", "text": "You are a helpful assistant." } + ] + }, + { + "role": "user", + "content": [ + { "type": "text", "text": "Reply with exactly pong" } + ] + } + ], + "max_tokens": 8000, + "metadata": { + "sessionId": "8ad7c4ca-184f-41dd-85d5-5ab3b3d3d4a7", + "promptId": "6d8d4a7be1c2" + }, + "vl_high_resolution_images": true +} +``` + +### 5.3 Что именно обязательно + +Для рабочего HTTP path критично оказалось: + +- `model = "coder-model"` +- `messages[*].content` в виде массива блоков +- наличие `system` и `user` сообщений +- `max_tokens` +- `metadata.sessionId` +- `metadata.promptId` +- `vl_high_resolution_images = true` +- правильные DashScope/Qwen headers + +### 5.4 Что оказалось лишним или вредным + +- строковый `content` ломал запрос +- `tool_choice = "auto"` в ранней реализации не использовался в эталонном рабочем request body +- fallback на другие DashScope endpoints не заменял правильный body format +- вызов через `qwen -p` для runtime работы больше не нужен + +## 6. Tool calling + +`serv` передаёт tools в OpenAI-compatible формате: + +```json +{ + "type": "function", + "function": { + "name": "read_file", + "description": "...", + "parameters": { + "type": "object", + "properties": { + "path": { "type": "string" } + }, + "required": ["path"] + } + } +} +``` + +Список инструментов формируется в `serv/tools.py`. + +После ответа модели: + +- `assistant.tool_calls` разбираются в `serv/llm.py` +- инструмент исполняется на стороне `serv` +- tool result добавляется обратно в историю как `role = "tool"` +- затем выполняется следующий `chat/completions` + +Внутренне `serv/model_router.py` тоже нормализует `tool` message content в content-block array. + +## 7. Диагностика, которая реально помогла + +### 7.1 Что не сработало + +- смена только `X-DashScope-AuthType` +- смена только fallback URL +- прямые запросы на `dashscope.aliyuncs.com` и `coding-intl.dashscope.aliyuncs.com` +- минимальный body со строковым `content` + +### 7.2 Что сработало + +Нужно было снять эталонный request body из реально работающего `qwen-code` и повторить его. + +Практический метод: + +1. Была сделана локальная копия `qwen` bundle +2. В `buildRequest(...)` был вставлен лог final request body +3. Инструментированная копия `qwen` была запущена локально +4. Из её лога был взят точный JSON request body +5. `serv/model_router.py` был приведён к этому формату + +Именно это включило чистый HTTP path. + +## 8. Последовательность end-to-end + +### 8.1 Авторизация + +1. Telegram user -> `/auth` +2. `bot` -> `POST /api/v1/auth/device/start` +3. `serv` -> `POST https://chat.qwen.ai/api/v1/oauth2/device/code` +4. user подтверждает login в браузере +5. `bot` -> `POST /api/v1/auth/device/poll` +6. `serv` -> `POST https://chat.qwen.ai/api/v1/oauth2/token` +7. `serv` сохраняет `~/.qwen/oauth_creds.json` + +### 8.2 Обычный chat request + +1. Telegram user -> `"Ку"` +2. `bot` -> `POST /api/v1/chat/start` +3. `serv/llm.py` собирает историю и системный prompt +4. `serv/model_router.py` нормализует messages в content-block arrays +5. `serv/model_router.py` -> `POST https://portal.qwen.ai/v1/chat/completions` +6. Qwen возвращает `choices[0].message` +7. `serv` либо завершает ответ, либо выполняет tool calls и делает следующий round +8. `bot` через `/api/v1/chat/poll` получает итог и отправляет его в Telegram + +## 9. Текущие инварианты реализации + +- inference path идёт через чистый HTTP из `serv` +- runtime path не зависит от `qwen -p` +- credentials хранятся в `~/.qwen/oauth_creds.json` +- основной inference host берётся из `resource_url` +- текущий рабочий `resource_url` для OAuth path: `portal.qwen.ai` +- `serv` слушает `127.0.0.1:8081` +- `bot` ходит в `http://127.0.0.1:8081` + +## 10. Файлы, которые надо смотреть при будущих изменениях + +- `serv/oauth.py` +- `serv/model_router.py` +- `serv/llm.py` +- `serv/app.py` +- `bot/app.py` +- `bot/config.py` + +Если Qwen снова начнёт возвращать `invalid_parameter_error`, первым делом нужно сравнивать не только headers и endpoint, а точный JSON body с рабочим `qwen-code`. diff --git a/README.md b/README.md index a8afde5..f4718a7 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@ Проект написан на Python stdlib, чтобы не зависеть от Node/npm в текущем окружении. +Подробный технический разбор Qwen OAuth и рабочего HTTP flow: + +- [QWEN_OAUTH_HTTP_FLOW.md](./QWEN_OAUTH_HTTP_FLOW.md) + ## Архитектура ```text