Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 91 additions & 17 deletions src/rotator_library/providers/qwen_auth_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()


Expand Down Expand Up @@ -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]
Comment on lines +354 to 365
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.py Lines 230-242). This branch only exempts env://, so refreshing one of those cached env-backed credentials now raises on the nonexistent file before the refresh even starts.

Suggested fix
-            if not path.startswith("env://"):
+            loaded_from_env = cached_creds and cached_creds.get(
+                "_proxy_metadata", {}
+            ).get("loaded_from_env")
+            if not path.startswith("env://") and not loaded_from_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)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/rotator_library/providers/qwen_auth_base.py` around lines 354 - 365, The
current refresh logic unconditionally re-reads from disk for any path not
starting with "env://", which breaks cached credentials that were originally
loaded from env vars via _load_credentials(); change the condition in the
refresh block so _read_creds_from_file(path) is only invoked when the path
actually points to an existing file (e.g., check file existence before calling
_read_creds_from_file), otherwise use the cached entry in
self._credentials_cache[path]; update the condition around the call to
_read_creds_from_file in the refresh flow that references path,
_read_creds_from_file, and _credentials_cache so env-backed cached entries
aren’t forced to hit disk.


lib_logger.debug(f"Refreshing Qwen OAuth token for '{Path(path).name}'...")
Expand All @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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
Comment on lines +572 to 598
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This classifier bypasses the documented missing-file env fallback.

Only env:// and existing files are treated as OAuth here. That makes the _load_credentials() fallback for nonexistent file paths (src/rotator_library/providers/qwen_auth_base.py Lines 230-242) unreachable from get_api_details(), so a stateless deployment that still passes oauth_creds/...json will now send the literal path as the bearer token.

🧰 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
Verify each finding against the current code and only fix it if needed.

In `@src/rotator_library/providers/qwen_auth_base.py` around lines 572 - 598,
get_api_details() currently only treats strings starting with "env://" or
existing files as OAuth, so passing a non-existent credential path (e.g.
"oauth_creds/whatever.json") falls through to the direct-API-key branch; change
the detection so credential paths that look like file-based credentials are
treated as OAuth even if the file is missing so _load_credentials() can perform
its env-file fallback. Replace the is_oauth calculation (the block using
os.path.isfile) to mark OAuth when credential_identifier.startswith("env://") OR
credential_identifier.endswith(".json") OR "/" in credential_identifier (i.e.,
appears to be a path), then continue to call
self._load_credentials(credential_identifier), self._is_token_expired(creds) and
self._refresh_token(credential_identifier) as before.


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.
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Regenerate x-request-id per outbound request.

x-request-id is created once and then reused for the device-code request plus every polling call, so the telemetry is per-flow, not per-request.

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
Verify each finding against the current code and only fix it if needed.

In `@src/rotator_library/providers/qwen_auth_base.py` around lines 870 - 875, The
current headers dict (containing "x-request-id": str(uuid.uuid4())) is generated
once and reused across the device-code request and subsequent polling; change
the code so a fresh x-request-id is created for every outbound HTTP call by
generating the header value at request time rather than once at module/flow init
— update the places that build/send requests (the headers construction
referenced as headers in qwen_auth_base.py around the device-code request and
the polling loop at the later block) to re-create the headers dict per call
(keeping QWEN_USER_AGENT and Content-Type unchanged) so each request gets a
unique x-request-id.

async with httpx.AsyncClient() as client:
request_data = {
Expand Down
24 changes: 18 additions & 6 deletions src/rotator_library/providers/qwen_code_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}"}
Expand Down Expand Up @@ -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,
}
if is_oauth:
headers["X-DashScope-AuthType"] = "qwen-oauth"
Comment on lines +610 to +624
Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 env:///file-path heuristic as QwenAuthBase.get_api_details() in src/rotator_library/providers/qwen_auth_base.py Lines 572-579. The token source and X-DashScope-AuthType need to stay in lockstep, so keeping two copies will drift again the next time credential classification changes.

🧰 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
Verify each finding against the current code and only fix it if needed.

In `@src/rotator_library/providers/qwen_code_provider.py` around lines 610 - 624,
The duplicate env:// / file-path credential detection in QwenCodeProvider should
be replaced with the shared helper in QwenAuthBase to keep token-source logic
centralized: remove the try/except block that computes is_oauth from
credential_path and instead call the existing QwenAuthBase.get_api_details (or
extract a small is_oauth helper from it) to determine whether the token is
OAuth, then set headers["X-DashScope-AuthType"] = "qwen-oauth" when that helper
indicates OAuth; update QwenCodeProvider to reference the QwenAuthBase method
(e.g., via self.auth_base.get_api_details or the extracted helper) so the token
classification and header logic remain in lockstep.


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)
Expand Down