Merge pull request #1 from mirivlad/проблемы-с-qwen-oauth-имплементацией-d4a13
Update from task 72dd9581-013a-4bf0-8b6a-8d7207ad4a13
This commit is contained in:
commit
f80b8f5cb8
|
|
@ -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
|
||||
*~
|
||||
```
|
||||
|
|
@ -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}",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
# 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",
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||
"Accept": "application/json",
|
||||
"x-request-id": str(uuid.uuid4()),
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
}
|
||||
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")
|
||||
conn.request("POST", path, body=body, headers=headers)
|
||||
response = conn.getresponse()
|
||||
raw = response.read()
|
||||
if response.status >= 400:
|
||||
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:
|
||||
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
|
||||
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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue