Document Qwen OAuth HTTP flow
This commit is contained in:
parent
90f1c6ebaf
commit
e75a0316e6
|
|
@ -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`.
|
||||
Loading…
Reference in New Issue