369 lines
11 KiB
Markdown
369 lines
11 KiB
Markdown
# 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`.
|