new-qwen/QWEN_OAUTH_HTTP_FLOW.md

11 KiB
Raw Blame History

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(), который делает:

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:

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

Бот показывает пользователю ссылку вида:

https://chat.qwen.ai/authorize?user_code=ORRNFVIN&client=qwen-code

1.2 Polling device flow

После подтверждения в браузере bot циклически вызывает:

  • POST /api/v1/auth/device/poll

serv делает:

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:

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

Формат:

{
  "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 вызывает:

POST https://chat.qwen.ai/api/v1/oauth2/token
Content-Type: application/x-www-form-urlencoded; charset=utf-8
Accept: application/json

Body:

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 это обычно даёт:

https://portal.qwen.ai/v1

4.2 Реальный рабочий endpoint

serv/model_router.py делает:

POST https://portal.qwen.ai/v1/chat/completions

Headers:

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

Главная причина, почему ранние попытки ломались с:

{"error":{"code":"invalid_parameter_error","message":"bad request"}}

была в неверном формате messages[].content.

Qwen Portal ожидает не строку, а массив content-blocks.

5.1 Неправильный формат

Это не работало:

{
  "model": "coder-model",
  "messages": [
    { "role": "user", "content": "Ку" }
  ]
}

5.2 Рабочий формат

Это рабочий формат:

{
  "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 формате:

{
  "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.