diff --git a/src/rotator_library/providers/qwen_auth_base.py b/src/rotator_library/providers/qwen_auth_base.py index f31ead3c..b89f5c99 100644 --- a/src/rotator_library/providers/qwen_auth_base.py +++ b/src/rotator_library/providers/qwen_auth_base.py @@ -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 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()), } async with httpx.AsyncClient() as client: request_data = { diff --git a/src/rotator_library/providers/qwen_code_provider.py b/src/rotator_library/providers/qwen_code_provider.py index fb638a03..0f8c5599 100644 --- a/src/rotator_library/providers/qwen_code_provider.py +++ b/src/rotator_library/providers/qwen_code_provider.py @@ -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, } + if is_oauth: + headers["X-DashScope-AuthType"] = "qwen-oauth" - 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)