diff --git a/.gitignore b/.gitignore index 2009a16..f74ec75 100644 --- a/.gitignore +++ b/.gitignore @@ -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 +*~ +``` \ No newline at end of file diff --git a/bot/app.py b/bot/app.py index f073b56..2744741 100644 --- a/bot/app.py +++ b/bot/app.py @@ -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}", ) diff --git a/serv/app.py b/serv/app.py index eebf2c9..50015a6 100644 --- a/serv/app.py +++ b/serv/app.py @@ -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( diff --git a/serv/model_router.py b/serv/model_router.py index 9e5c282..ba1769e 100644 --- a/serv/model_router.py +++ b/serv/model_router.py @@ -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() diff --git a/serv/oauth.py b/serv/oauth.py index fee0eb0..7fac8a8 100644 --- a/serv/oauth.py +++ b/serv/oauth.py @@ -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"