Document Qwen OAuth HTTP flow

This commit is contained in:
mirivlad 2026-04-09 01:29:00 +08:00
parent 90f1c6ebaf
commit e75a0316e6
2 changed files with 372 additions and 0 deletions

368
QWEN_OAUTH_HTTP_FLOW.md Normal file
View File

@ -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: <uuid>
```
Body:
```x-www-form-urlencoded
client_id=f0304373b74a44d2b584a3fb70ca9e56
scope=openid profile email model.completion
code_challenge=<pkce_sha256>
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: <uuid>
```
Body:
```x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:device_code
client_id=f0304373b74a44d2b584a3fb70ca9e56
device_code=<device_code>
code_verifier=<pkce_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=<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 <access_token>
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`.

View File

@ -7,6 +7,10 @@
Проект написан на Python stdlib, чтобы не зависеть от Node/npm в текущем окружении.
Подробный технический разбор Qwen OAuth и рабочего HTTP flow:
- [QWEN_OAUTH_HTTP_FLOW.md](./QWEN_OAUTH_HTTP_FLOW.md)
## Архитектура
```text