Fix Qwen OAuth: use http.client, correct endpoints, remove API key mode

This commit is contained in:
qwen.ai[bot] 2026-04-08 12:52:18 +00:00
parent ba466b6b03
commit 225d01fae4
5 changed files with 115 additions and 29 deletions

48
.gitignore vendored
View File

@ -1,5 +1,49 @@
```
# Python
__pycache__/ __pycache__/
*.pyc *.pyc
.env *.pyo
.new-qwen/ *.pyd
.Python
env/
venv/
.venv/
.ENV
.venv.bak/
pip-log.txt
pip-delete-this-directory.txt
# Local development settings
.env
.env.local
.env.dev
.env.test
.env.prod
# IDE
.vscode/
.idea/
*.swp
*.swo
# Logs
*.log
# OS
.DS_Store
Thumbs.db
# Coverage
.coverage
coverage/
htmlcov/
# Build
dist/
build/
*.egg-info/
# Temporary files
*.tmp
*~
```

View File

@ -564,8 +564,10 @@ def handle_message(api: TelegramAPI, config: BotConfig, state: dict[str, Any], m
if text == "/status": if text == "/status":
status = get_json(f"{config.server_url}/api/v1/auth/status") status = get_json(f"{config.server_url}/api/v1/auth/status")
models_status = get_json(f"{config.server_url}/api/v1/models")
queue_size = len(state.setdefault("chat_queues", {}).get(str(chat_id), [])) queue_size = len(state.setdefault("chat_queues", {}).get(str(chat_id), []))
active_job = state.setdefault("chat_active_jobs", {}).get(str(chat_id)) active_job = state.setdefault("chat_active_jobs", {}).get(str(chat_id))
models_info = "\n".join([f" - {m['id']}: {m['name']} ({m['description']})" for m in models_status.get("models", [])]) or " Нет доступных моделей"
send_text_chunks( send_text_chunks(
api, api,
chat_id, chat_id,
@ -578,7 +580,7 @@ def handle_message(api: TelegramAPI, config: BotConfig, state: dict[str, Any], m
f"resource_url: {status.get('resource_url')}\n" f"resource_url: {status.get('resource_url')}\n"
f"expires_at: {status.get('expires_at')}\n" f"expires_at: {status.get('expires_at')}\n"
f"tool_policy: {status.get('tool_policy')}\n" f"tool_policy: {status.get('tool_policy')}\n"
f"pending_approvals: {status.get('pending_approvals')}\n" f"Доступные Qwen OAuth модели:\n{models_info}\n"
f"active_job: {active_job}\n" f"active_job: {active_job}\n"
f"queued_messages: {queue_size}", f"queued_messages: {queue_size}",
) )

View File

@ -109,6 +109,8 @@ class AppState:
for item in providers for item in providers
if item.get("available") if item.get("available")
] ]
# Get available Qwen OAuth models
qwen_models = self.oauth.get_available_models()
if not creds: if not creds:
return { return {
"authenticated": False, "authenticated": False,
@ -117,6 +119,7 @@ class AppState:
"default_provider": self.config.default_provider, "default_provider": self.config.default_provider,
"fallback_providers": self.config.fallback_providers, "fallback_providers": self.config.fallback_providers,
"providers": providers, "providers": providers,
"qwen_models": qwen_models,
"tool_policy": self.config.tool_policy, "tool_policy": self.config.tool_policy,
"pending_flows": len(self.pending_device_flows), "pending_flows": len(self.pending_device_flows),
"pending_approvals": len(self.approvals.list_pending()), "pending_approvals": len(self.approvals.list_pending()),
@ -128,6 +131,7 @@ class AppState:
"default_provider": self.config.default_provider, "default_provider": self.config.default_provider,
"fallback_providers": self.config.fallback_providers, "fallback_providers": self.config.fallback_providers,
"providers": providers, "providers": providers,
"qwen_models": qwen_models,
"resource_url": creds.get("resource_url"), "resource_url": creds.get("resource_url"),
"expires_at": creds.get("expiry_date"), "expires_at": creds.get("expiry_date"),
"tool_policy": self.config.tool_policy, "tool_policy": self.config.tool_policy,
@ -171,6 +175,11 @@ class RequestHandler(BaseHTTPRequestHandler):
if self.path == "/api/v1/approvals": if self.path == "/api/v1/approvals":
self._send(HTTPStatus.OK, {"approvals": self.app.approvals.list_pending()}) self._send(HTTPStatus.OK, {"approvals": self.app.approvals.list_pending()})
return return
if self.path == "/api/v1/models":
# Return available Qwen OAuth models
models = self.app.oauth.get_available_models()
self._send(HTTPStatus.OK, {"models": models})
return
self._send(HTTPStatus.NOT_FOUND, {"error": "Not found"}) self._send(HTTPStatus.NOT_FOUND, {"error": "Not found"})
def _run_chat_job( def _run_chat_job(

View File

@ -8,7 +8,7 @@ from urllib import error, request
from config import ServerConfig from config import ServerConfig
from gigachat import GigaChatAuthManager, GigaChatError from gigachat import GigaChatAuthManager, GigaChatError
from oauth import OAuthError, QwenOAuthManager from oauth import OAuthError, QwenOAuthManager, QWEN_OAUTH_ALLOWED_MODELS
class ModelProviderError(RuntimeError): class ModelProviderError(RuntimeError):
@ -101,6 +101,9 @@ class QwenModelProvider(BaseModelProvider):
def __init__(self, config: ServerConfig, oauth: QwenOAuthManager) -> None: def __init__(self, config: ServerConfig, oauth: QwenOAuthManager) -> None:
super().__init__(config) super().__init__(config)
self.oauth = oauth self.oauth = oauth
# Resolve model ID to actual model name
self._model_id = config.model if config.model in QWEN_OAUTH_ALLOWED_MODELS else "coder-model"
self._model_name = oauth.get_model_name_for_id(self._model_id)
def is_available(self) -> bool: def is_available(self) -> bool:
creds = self.oauth.load_credentials() creds = self.oauth.load_credentials()
@ -112,7 +115,7 @@ class QwenModelProvider(BaseModelProvider):
return "Qwen OAuth is not configured" return "Qwen OAuth is not configured"
def model_name(self) -> str: def model_name(self) -> str:
return self.config.model return self._model_name
def complete(self, completion_request: CompletionRequest) -> dict[str, Any]: def complete(self, completion_request: CompletionRequest) -> dict[str, Any]:
creds = self.oauth.get_valid_credentials() creds = self.oauth.get_valid_credentials()

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import base64 import base64
import hashlib import hashlib
import http.client
import json import json
import secrets import secrets
import time import time
@ -10,7 +11,7 @@ import webbrowser
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib import error, parse, request from urllib import parse
QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai" QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai"
@ -19,6 +20,13 @@ QWEN_TOKEN_ENDPOINT = f"{QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token"
QWEN_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56" QWEN_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"
QWEN_SCOPE = "openid profile email model.completion" QWEN_SCOPE = "openid profile email model.completion"
QWEN_DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code" QWEN_DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"
QWEN_DEFAULT_RESOURCE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
# Hard-coded Qwen OAuth models (single source of truth)
QWEN_OAUTH_MODELS = [
{"id": "coder-model", "name": "coder-model", "description": "Qwen 3.6 Plus — efficient hybrid model with leading coding performance"},
]
QWEN_OAUTH_ALLOWED_MODELS = [model["id"] for model in QWEN_OAUTH_MODELS]
class OAuthError(RuntimeError): class OAuthError(RuntimeError):
@ -42,28 +50,35 @@ class QwenOAuthManager:
self.creds_path.parent.mkdir(parents=True, exist_ok=True) self.creds_path.parent.mkdir(parents=True, exist_ok=True)
def _post_form(self, url: str, payload: dict[str, str]) -> dict[str, Any]: def _post_form(self, url: str, payload: dict[str, str]) -> dict[str, Any]:
data = parse.urlencode(payload).encode("utf-8") # Use http.client instead of urllib.request to avoid WAF blocking
req = request.Request( # Parse the URL to extract host and path
url, parsed = parse.urlparse(url)
data=data, host = parsed.netloc
path = parsed.path or "/"
# Build the form data (standard urlencode with + for spaces)
body = parse.urlencode(payload)
conn = http.client.HTTPSConnection(host, timeout=60)
headers = { headers = {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"Accept": "application/json", "Accept": "application/json",
"x-request-id": str(uuid.uuid4()), "x-request-id": str(uuid.uuid4()),
}, }
method="POST",
)
try: try:
with request.urlopen(req, timeout=60) as response: conn.request("POST", path, body=body, headers=headers)
return json.loads(response.read().decode("utf-8")) response = conn.getresponse()
except error.HTTPError as exc: raw = response.read()
body = exc.read().decode("utf-8", errors="replace") if response.status >= 400:
try: try:
payload = json.loads(body) error_payload = json.loads(raw.decode("utf-8"))
message = error_payload.get("error_description") or error_payload.get("error") or raw.decode("utf-8")
except json.JSONDecodeError: except json.JSONDecodeError:
raise OAuthError(f"HTTP {exc.code}: {body}") from exc message = raw.decode("utf-8", errors="replace")
message = payload.get("error_description") or payload.get("error") or body raise OAuthError(f"HTTP {response.status}: {message}")
raise OAuthError(message) from exc return json.loads(raw.decode("utf-8"))
finally:
conn.close()
def load_credentials(self) -> dict[str, Any] | None: def load_credentials(self) -> dict[str, Any] | None:
if not self.creds_path.exists(): if not self.creds_path.exists():
@ -127,7 +142,8 @@ class QwenOAuthManager:
) )
except OAuthError as exc: except OAuthError as exc:
text = str(exc) text = str(exc)
if "authorization_pending" in text: # Check for pending authorization (continue polling)
if "authorization_pending" in text or "not yet approved" in text.lower():
return None return None
if "slow_down" in text: if "slow_down" in text:
state.interval_seconds = min(state.interval_seconds * 1.5, 10.0) state.interval_seconds = min(state.interval_seconds * 1.5, 10.0)
@ -176,9 +192,21 @@ class QwenOAuthManager:
return self.refresh_credentials(creds) return self.refresh_credentials(creds)
def get_openai_base_url(self, creds: dict[str, Any]) -> str: def get_openai_base_url(self, creds: dict[str, Any]) -> str:
resource_url = creds.get("resource_url") or "https://dashscope.aliyuncs.com/compatible-mode" resource_url = creds.get("resource_url") or QWEN_DEFAULT_RESOURCE_URL
if not resource_url.startswith("http"): if not resource_url.startswith("http"):
resource_url = f"https://{resource_url}" resource_url = f"https://{resource_url}"
if resource_url.endswith("/v1"): if resource_url.endswith("/v1"):
return resource_url return resource_url
return resource_url.rstrip("/") + "/v1" return resource_url.rstrip("/") + "/v1"
def get_available_models(self) -> list[dict[str, str]]:
"""Return the list of available Qwen OAuth models (hard-coded, single source of truth)."""
return QWEN_OAUTH_MODELS.copy()
def get_model_name_for_id(self, model_id: str) -> str:
"""Get the actual model name for a given model ID. Returns default if not found."""
for model in QWEN_OAUTH_MODELS:
if model["id"] == model_id:
return model["name"]
# Default to coder-model if unknown
return "coder-model"