-
-
Notifications
You must be signed in to change notification settings - Fork 86
fix(qwen-code): OAuth headers, WAF detection, and upstream alignment #154
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,7 @@ | |
| import webbrowser | ||
| import os | ||
| import re | ||
| import uuid | ||
| from dataclasses import dataclass, field | ||
| from pathlib import Path | ||
| from glob import glob | ||
|
|
@@ -39,6 +40,14 @@ | |
| TOKEN_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/token" | ||
| REFRESH_EXPIRY_BUFFER_SECONDS = 3 * 60 * 60 # 3 hours buffer before expiry | ||
|
|
||
| # Default DashScope base URL — used when resource_url is absent from OAuth credentials. | ||
| # Defined here (auth layer) because get_api_details() is the sole consumer. | ||
| # qwen_code_provider.py re-exports this for any callers that need it from that module. | ||
| DEFAULT_DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" | ||
|
|
||
| # User-Agent sent to Qwen/Alibaba endpoints. Single source of truth — update here only. | ||
| QWEN_USER_AGENT = "QwenCode/1.0.0 (linux; x64)" | ||
|
|
||
| console = Console() | ||
|
|
||
|
|
||
|
|
@@ -342,11 +351,17 @@ async def _refresh_token(self, path: str, force: bool = False) -> Dict[str, Any] | |
| if not force and cached_creds and not self._is_token_expired(cached_creds): | ||
| return cached_creds | ||
|
|
||
| # [ROTATING TOKEN FIX] Always read fresh from disk before refresh. | ||
| # [ROTATING TOKEN FIX] Read fresh credentials before refresh. | ||
| # Qwen uses rotating refresh tokens - each refresh invalidates the previous token. | ||
| # If we use a stale cached token, refresh will fail with HTTP 400. | ||
| # Reading fresh from disk ensures we have the latest token. | ||
| await self._read_creds_from_file(path) | ||
| if not path.startswith("env://"): | ||
| # For file paths, read fresh from disk to pick up tokens that may have | ||
| # been updated by another process or a previous refresh cycle. | ||
| await self._read_creds_from_file(path) | ||
| # For env:// paths, the in-memory cache is the single source of truth. | ||
| # _save_credentials updates the cache after each refresh, so the cache | ||
| # always holds the latest rotating tokens. Re-reading from static env vars | ||
| # would discard the rotated refresh_token and break subsequent refreshes. | ||
| creds_from_file = self._credentials_cache[path] | ||
|
|
||
| lib_logger.debug(f"Refreshing Qwen OAuth token for '{Path(path).name}'...") | ||
|
|
@@ -363,7 +378,7 @@ async def _refresh_token(self, path: str, force: bool = False) -> Dict[str, Any] | |
| headers = { | ||
| "Content-Type": "application/x-www-form-urlencoded", | ||
| "Accept": "application/json", | ||
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", | ||
| "User-Agent": QWEN_USER_AGENT, | ||
| } | ||
|
|
||
| async with httpx.AsyncClient() as client: | ||
|
|
@@ -380,6 +395,25 @@ async def _refresh_token(self, path: str, force: bool = False) -> Dict[str, Any] | |
| timeout=30.0, | ||
| ) | ||
| response.raise_for_status() | ||
|
|
||
| # [WAF DETECTION] Alibaba Cloud WAF may return HTTP 200 | ||
| # with HTML content instead of JSON. Detect and retry. | ||
| content_type = response.headers.get("content-type", "") | ||
| if "json" not in content_type.lower(): | ||
| last_error = ValueError( | ||
| f"Token refresh likely blocked by WAF after {max_retries} attempts " | ||
| f"(content-type: {content_type})" | ||
| ) | ||
| lib_logger.warning( | ||
| f"Token refresh for '{Path(path).name}' returned non-JSON " | ||
| f"(content-type: {content_type}), likely WAF block. " | ||
| f"Attempt {attempt + 1}/{max_retries}." | ||
| ) | ||
| if attempt < max_retries - 1: | ||
| await asyncio.sleep(2 ** attempt) | ||
| continue | ||
| break | ||
|
|
||
| new_token_data = response.json() | ||
| break # Success | ||
|
|
||
|
|
@@ -524,33 +558,72 @@ async def get_api_details(self, credential_identifier: str) -> Tuple[str, str]: | |
| """ | ||
| Returns the API base URL and access token. | ||
|
|
||
| Supports both credential types: | ||
| - OAuth: credential_identifier is a file path to JSON credentials | ||
| - API Key: credential_identifier is the API key string itself | ||
| Supports three credential types: | ||
| - OAuth file: credential_identifier is a file path to JSON credentials | ||
| - env:// virtual path: credential_identifier is "env://provider/index" | ||
| - Direct API key: credential_identifier is the API key string itself | ||
|
|
||
| URL normalization follows upstream qwen-code getCurrentEndpoint() logic: | ||
| - Adds https:// prefix if missing | ||
| - Ensures /v1 suffix is present | ||
| - Defaults to DashScope URL when resource_url is not set | ||
| """ | ||
| # Detect credential type | ||
| if os.path.isfile(credential_identifier): | ||
| # OAuth credential: file path to JSON | ||
|
|
||
| try: | ||
| is_oauth = credential_identifier.startswith("env://") or os.path.isfile( | ||
| credential_identifier | ||
| ) | ||
| except (OSError, ValueError): | ||
| # os.path.isfile can raise on invalid path strings (e.g. very long API keys) | ||
| is_oauth = False | ||
|
|
||
| if is_oauth: | ||
| lib_logger.debug( | ||
| f"Using OAuth credentials from file: {credential_identifier}" | ||
| f"Using OAuth credentials from: {credential_identifier}" | ||
| ) | ||
| creds = await self._load_credentials(credential_identifier) | ||
|
|
||
| if self._is_token_expired(creds): | ||
| creds = await self._refresh_token(credential_identifier) | ||
|
|
||
| base_url = creds.get("resource_url", "https://portal.qwen.ai/v1") | ||
| if not base_url.startswith("http"): | ||
| base_url = f"https://{base_url}" | ||
| resource_url = creds.get("resource_url") | ||
| if resource_url == "https://portal.qwen.ai/v1": | ||
| resource_url = None | ||
| base_url = self._normalize_api_base_url(resource_url, DEFAULT_DASHSCOPE_BASE_URL) | ||
| access_token = creds["access_token"] | ||
| else: | ||
| # Direct API key: use as-is | ||
| # Direct API key: use as-is with DashScope default | ||
| lib_logger.debug("Using direct API key for Qwen Code") | ||
| base_url = "https://portal.qwen.ai/v1" | ||
| base_url = DEFAULT_DASHSCOPE_BASE_URL | ||
| access_token = credential_identifier | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+572
to
598
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This classifier bypasses the documented missing-file env fallback. Only 🧰 Tools🪛 Ruff (0.15.9)[warning] 573-573: Async functions should not use os.path methods, use trio.Path or anyio.path (ASYNC240) [warning] 582-582: Logging statement uses f-string (G004) 🤖 Prompt for AI Agents |
||
|
|
||
| return base_url, access_token | ||
|
|
||
| @staticmethod | ||
| def _normalize_api_base_url(resource_url: Optional[str], default_url: str) -> str: | ||
| """ | ||
| Normalize a resource_url from Qwen OAuth credentials into a full API base URL. | ||
|
|
||
| Mirrors upstream qwen-code getCurrentEndpoint() logic: | ||
| - If resource_url is None/empty, use the default DashScope URL | ||
| - Add https:// prefix if missing | ||
| - Ensure /v1 suffix is present | ||
|
|
||
| The returned URL should be used directly — callers append /chat/completions. | ||
| """ | ||
| if not resource_url: | ||
| return default_url | ||
|
|
||
| # Add protocol if missing | ||
| url = resource_url if resource_url.startswith("http") else f"https://{resource_url}" | ||
| url = url.rstrip("/") | ||
|
|
||
| # Ensure /v1 suffix (upstream getCurrentEndpoint behavior) | ||
| if not url.endswith("/v1"): | ||
| url = f"{url}/v1" | ||
|
|
||
| return url | ||
|
|
||
| async def proactively_refresh(self, credential_identifier: str): | ||
| """ | ||
| Proactively refreshes tokens if they're close to expiry. | ||
|
|
@@ -795,9 +868,10 @@ async def _perform_interactive_oauth( | |
| ) | ||
|
|
||
| headers = { | ||
| "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", | ||
| "User-Agent": QWEN_USER_AGENT, | ||
| "Content-Type": "application/x-www-form-urlencoded", | ||
| "Accept": "application/json", | ||
| "x-request-id": str(uuid.uuid4()), | ||
| } | ||
|
Comment on lines
870
to
875
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Regenerate
Suggested fix- headers = {
+ base_headers = {
"User-Agent": QWEN_USER_AGENT,
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
- "x-request-id": str(uuid.uuid4()),
}
async with httpx.AsyncClient() as client:
request_data = {
"client_id": CLIENT_ID,
"scope": SCOPE,
@@
dev_response = await client.post(
"https://chat.qwen.ai/api/v1/oauth2/device/code",
- headers=headers,
+ headers={**base_headers, "x-request-id": str(uuid.uuid4())},
data=request_data,
)
@@
poll_response = await client.post(
TOKEN_ENDPOINT,
- headers=headers,
+ headers={**base_headers, "x-request-id": str(uuid.uuid4())},
data={
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": dev_data["device_code"],
"client_id": CLIENT_ID,
"code_verifier": code_verifier,Also applies to: 947-956 🤖 Prompt for AI Agents |
||
| async with httpx.AsyncClient() as client: | ||
| request_data = { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,7 +11,7 @@ | |
| import logging | ||
| from typing import Union, AsyncGenerator, List, Dict, Any, Optional | ||
| from .provider_interface import ProviderInterface | ||
| from .qwen_auth_base import QwenAuthBase | ||
| from .qwen_auth_base import QwenAuthBase, DEFAULT_DASHSCOPE_BASE_URL, QWEN_USER_AGENT | ||
| from ..model_definitions import ModelDefinitions | ||
| from ..timeout_config import TimeoutConfig | ||
| from ..transaction_logger import ProviderLogger | ||
|
|
@@ -46,8 +46,13 @@ | |
| "stop", | ||
| "seed", | ||
| "response_format", | ||
| "metadata", | ||
| } | ||
|
|
||
| # Default DashScope base URL — re-exported from qwen_auth_base (source of truth). | ||
| # Kept here so existing callers importing from this module do not break. | ||
| # Do NOT define the string here; update it in qwen_auth_base.py only. | ||
|
|
||
|
|
||
| class QwenCodeProvider(QwenAuthBase, ProviderInterface): | ||
| skip_cost_calculation = True | ||
|
|
@@ -118,7 +123,7 @@ def extract_model_id(item) -> str: | |
| await self.initialize_token(credential) | ||
|
|
||
| api_base, access_token = await self.get_api_details(credential) | ||
| models_url = f"{api_base.rstrip('/')}/v1/models" | ||
| models_url = f"{api_base.rstrip('/')}/models" | ||
|
|
||
| response = await client.get( | ||
| models_url, headers={"Authorization": f"Bearer {access_token}"} | ||
|
|
@@ -602,16 +607,23 @@ async def make_request(): | |
| # Build clean payload with only supported parameters | ||
| payload = self._build_request_payload(**kwargs_with_stripped_model) | ||
|
|
||
| try: | ||
| is_oauth = credential_path.startswith("env://") or os.path.isfile(credential_path) | ||
| except (OSError, ValueError): | ||
| is_oauth = False | ||
|
|
||
| headers = { | ||
| "Authorization": f"Bearer {access_token}", | ||
| "Content-Type": "application/json", | ||
| "Accept": "text/event-stream", | ||
| "User-Agent": "google-api-nodejs-client/9.15.1", | ||
| "X-Goog-Api-Client": "gl-node/22.17.0", | ||
| "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI", | ||
| "User-Agent": QWEN_USER_AGENT, | ||
| "X-DashScope-CacheControl": "enable", | ||
| "X-DashScope-UserAgent": QWEN_USER_AGENT, | ||
| } | ||
greptile-apps[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if is_oauth: | ||
| headers["X-DashScope-AuthType"] = "qwen-oauth" | ||
|
Comment on lines
+610
to
+624
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Extract OAuth credential detection into one shared helper. This block reimplements the same 🧰 Tools🪛 Ruff (0.15.9)[warning] 611-611: Async functions should not use os.path methods, use trio.Path or anyio.path (ASYNC240) 🤖 Prompt for AI Agents |
||
|
|
||
| url = f"{api_base.rstrip('/')}/v1/chat/completions" | ||
| url = f"{api_base.rstrip('/')}/chat/completions" | ||
|
|
||
| # Log request to dedicated file | ||
| file_logger.log_request(payload) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't force a disk reread for cached env-backed credentials.
_load_credentials()still supports the backwards-compatible path where a missing file falls back to env vars and caches the result (src/rotator_library/providers/qwen_auth_base.pyLines 230-242). This branch only exemptsenv://, so refreshing one of those cached env-backed credentials now raises on the nonexistent file before the refresh even starts.Suggested fix
🤖 Prompt for AI Agents