new-qwen/QWEN_OAUTH_HTTP_FLOW.md

369 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`.