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__/
*.pyc
.env
.new-qwen/
*.pyo
*.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":
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), []))
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(
api,
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"expires_at: {status.get('expires_at')}\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"queued_messages: {queue_size}",
)

View File

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

View File

@ -8,7 +8,7 @@ from urllib import error, request
from config import ServerConfig
from gigachat import GigaChatAuthManager, GigaChatError
from oauth import OAuthError, QwenOAuthManager
from oauth import OAuthError, QwenOAuthManager, QWEN_OAUTH_ALLOWED_MODELS
class ModelProviderError(RuntimeError):
@ -101,6 +101,9 @@ class QwenModelProvider(BaseModelProvider):
def __init__(self, config: ServerConfig, oauth: QwenOAuthManager) -> None:
super().__init__(config)
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:
creds = self.oauth.load_credentials()
@ -112,7 +115,7 @@ class QwenModelProvider(BaseModelProvider):
return "Qwen OAuth is not configured"
def model_name(self) -> str:
return self.config.model
return self._model_name
def complete(self, completion_request: CompletionRequest) -> dict[str, Any]:
creds = self.oauth.get_valid_credentials()

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import base64
import hashlib
import http.client
import json
import secrets
import time
@ -10,7 +11,7 @@ import webbrowser
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib import error, parse, request
from urllib import parse
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_SCOPE = "openid profile email model.completion"
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):
@ -42,28 +50,35 @@ class QwenOAuthManager:
self.creds_path.parent.mkdir(parents=True, exist_ok=True)
def _post_form(self, url: str, payload: dict[str, str]) -> dict[str, Any]:
data = parse.urlencode(payload).encode("utf-8")
req = request.Request(
url,
data=data,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
"x-request-id": str(uuid.uuid4()),
},
method="POST",
)
# Use http.client instead of urllib.request to avoid WAF blocking
# Parse the URL to extract host and path
parsed = parse.urlparse(url)
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 = {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"Accept": "application/json",
"x-request-id": str(uuid.uuid4()),
}
try:
with request.urlopen(req, timeout=60) as response:
return json.loads(response.read().decode("utf-8"))
except error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
try:
payload = json.loads(body)
except json.JSONDecodeError:
raise OAuthError(f"HTTP {exc.code}: {body}") from exc
message = payload.get("error_description") or payload.get("error") or body
raise OAuthError(message) from exc
conn.request("POST", path, body=body, headers=headers)
response = conn.getresponse()
raw = response.read()
if response.status >= 400:
try:
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:
message = raw.decode("utf-8", errors="replace")
raise OAuthError(f"HTTP {response.status}: {message}")
return json.loads(raw.decode("utf-8"))
finally:
conn.close()
def load_credentials(self) -> dict[str, Any] | None:
if not self.creds_path.exists():
@ -127,7 +142,8 @@ class QwenOAuthManager:
)
except OAuthError as 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
if "slow_down" in text:
state.interval_seconds = min(state.interval_seconds * 1.5, 10.0)
@ -176,9 +192,21 @@ class QwenOAuthManager:
return self.refresh_credentials(creds)
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"):
resource_url = f"https://{resource_url}"
if resource_url.endswith("/v1"):
return resource_url
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"