fix pylance problems

This commit is contained in:
Von committed 2026-04-06 13:00:31 +08:00
1 parent bd7552f23b
commit c744bc0bc3
26 files changed
+405 -251

No files matched your search

+8 -4
View File
@@ -3,18 +3,20 @@
import argparse
from pathlib import Path
from typing import TYPE_CHECKING
from gevent import monkey
monkey.patch_all()
import urllib3
from src.application import Application
import urllib3 # noqa: E402
# 禁用 HTTPS 证书告警
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
if TYPE_CHECKING:
from src.application import Application
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="LLM Proxy Service")
@@ -24,7 +26,9 @@ def parse_args() -> argparse.Namespace:
return parser.parse_args()
def app() -> Application:
def app() -> "Application":
from src.application import Application
args = parse_args()
config_path = args.config
project_root = Path(__file__).resolve().parent
+5
View File
@@ -2,8 +2,13 @@
# -*- coding: utf-8 -*-
"""应用层导出。"""
from typing import TYPE_CHECKING
from .app_context import AppContext, Logger
if TYPE_CHECKING:
from .application import Application
__all__ = ["Application", "AppContext", "Logger"]
+36 -4
View File
@@ -14,13 +14,45 @@ if TYPE_CHECKING:
class Logger(Protocol):
def info(self, msg: str) -> None: ...
def info(
self,
msg: object,
*args: object,
exc_info: object = None,
stack_info: bool = False,
stacklevel: int = 1,
extra: object | None = None,
) -> None: ...
def error(self, msg: str) -> None: ...
def error(
self,
msg: object,
*args: object,
exc_info: object = None,
stack_info: bool = False,
stacklevel: int = 1,
extra: object | None = None,
) -> None: ...
def warning(self, msg: str) -> None: ...
def warning(
self,
msg: object,
*args: object,
exc_info: object = None,
stack_info: bool = False,
stacklevel: int = 1,
extra: object | None = None,
) -> None: ...
def debug(self, msg: str) -> None: ...
def debug(
self,
msg: object,
*args: object,
exc_info: object = None,
stack_info: bool = False,
stacklevel: int = 1,
extra: object | None = None,
) -> None: ...
@dataclass(frozen=True)
+6 -5
View File
@@ -4,10 +4,11 @@
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any, cast
from flask import request
from .app_context import AppContext
from .app_context import AppContext, Logger
from ..config import (
ConfigManager,
ProviderManager,
@@ -110,8 +111,8 @@ class Application:
access_logger.setLevel(level)
self._logger = logger
self._flask_app.logger = logger
self._flask_app.access_logger = access_logger
self._access_logger = access_logger
cast(Any, self._flask_app).logger = logger
def _setup_request_access_logging(self) -> None:
"""注册请求访问日志钩子,仅记录 IP、URL 与模型。"""
@@ -136,12 +137,12 @@ class Application:
log_message = f'{log_message} username={username}'
if model is not None:
log_message = f'{log_message} model={model}'
self._flask_app.access_logger.info(log_message)
self._access_logger.info(log_message)
def _setup_context(self) -> None:
"""创建全局运行上下文。"""
self._ctx = AppContext(
logger=self._logger,
logger=cast(Logger, self._logger),
config_manager=self._config_manager,
root_path=self._root_path,
flask_app=self._flask_app,
+6
View File
@@ -2,6 +2,8 @@
# -*- coding: utf-8 -*-
"""配置层导出。"""
from typing import TYPE_CHECKING
from .config_manager import ConfigManager
from .provider_config import (
AuthEntrySchema,
@@ -15,6 +17,10 @@ from .provider_config import (
validate_auth_group_provider_definitions,
)
if TYPE_CHECKING:
from .auth_group_manager import AuthGroupManager
from .provider_manager import ProviderManager
__all__ = [
"ConfigManager",
"AuthEntrySchema",
+2 -2
View File
@@ -142,9 +142,9 @@ class ProviderManager:
self._hook_cache[cache_key] = None
return None
hook_dir = str(hook_file.parent)
path_inserted = False
try:
hook_dir = str(hook_file.parent)
path_inserted = False
if hook_dir not in sys.path:
sys.path.insert(0, hook_dir)
path_inserted = True
+2 -1
View File
@@ -24,7 +24,8 @@ class OpenedUpstreamResponse:
class Executor(Protocol):
"""Transport-specific executor that opens an upstream response."""
transport: str
@property
def transport(self) -> str: ...
def execute(
self,
+4 -4
View File
@@ -157,12 +157,12 @@ class WebSocketExecutor:
connection.send(json.dumps(body, ensure_ascii=False))
if requested_stream:
response = WebSocketUpstreamResponse(connection)
stream_response = WebSocketUpstreamResponse(connection)
stream_format = resolve_stream_format(None, "", provider.transport)
return OpenedUpstreamResponse(
response=response,
status_code=response.status_code,
content_type=(response.headers.get("Content-Type") or "").lower(),
response=stream_response,
status_code=stream_response.status_code,
content_type=(stream_response.headers.get("Content-Type") or "").lower(),
is_stream=True,
stream_format=stream_format,
)
+6 -3
View File
@@ -4,7 +4,7 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from typing import Any, Callable, Dict, Optional, cast
from ..config.provider_config import (
DEFAULT_PROVIDER_TARGET_FORMAT,
@@ -66,7 +66,10 @@ class LLMProvider:
if not self.hook:
return body
guard = getattr(self.hook, "request_guard", None)
guard = cast(
Optional[Callable[[HookContext, Dict[str, Any]], Optional[Dict[str, Any]]]],
getattr(self.hook, "request_guard", None),
)
if callable(guard):
guarded = guard(ctx, body)
return body if guarded is None else guarded
@@ -76,7 +79,7 @@ class LLMProvider:
if not self.hook:
return body
guard = getattr(self.hook, "response_guard", None)
guard = cast(Optional[Callable[[HookContext, Any], Any]], getattr(self.hook, "response_guard", None))
if callable(guard):
guarded = guard(ctx, body)
return body if guarded is None else guarded
+2 -2
View File
@@ -53,7 +53,7 @@ class HookModule(Protocol):
def header_hook(self, ctx: HookContext, headers: Dict[str, str]) -> Dict[str, str]:
...
def request_guard(self, ctx: HookContext, body: Dict[str, Any]) -> Dict[str, Any]:
def request_guard(self, ctx: HookContext, body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
...
def response_guard(self, ctx: HookContext, body: Any) -> Any:
@@ -66,7 +66,7 @@ class BaseHook:
def header_hook(self, ctx: HookContext, headers: Dict[str, str]) -> Dict[str, str]:
return headers
def request_guard(self, ctx: HookContext, body: Dict[str, Any]) -> Dict[str, Any]:
def request_guard(self, ctx: HookContext, body: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return body
def response_guard(self, ctx: HookContext, body: Any) -> Any:
+23 -17
View File
@@ -2,7 +2,11 @@
# -*- coding: utf-8 -*-
"""认证控制器。"""
from flask import Response, jsonify, make_response, redirect, render_template, request
from typing import Any
from flask import jsonify, make_response, redirect, render_template, request
from flask.typing import ResponseReturnValue
from ..application.app_context import AppContext
from ..services import AuthenticationService
@@ -17,15 +21,20 @@ class AuthenticationController:
self._auth_service = auth_service
self._register_routes()
@staticmethod
def _get_request_payload() -> dict[str, Any]:
payload = request.get_json(silent=True)
if isinstance(payload, dict):
return dict(payload)
return {}
def _register_routes(self) -> None:
"""注册认证相关路由。"""
self._app.route('/login')(self.login_page)
self._app.route('/api/login', methods=['POST'])(self.api_login)
self._app.route('/logout')(self.logout)
self._app.route('/api/logout', methods=['POST'])(self.api_logout)
def login_page(self) -> Response:
"""返回登录页,已登录用户会被重定向到首页。"""
def login_page(self) -> ResponseReturnValue:
if not self._auth_service.is_auth_enabled():
return redirect('/')
@@ -35,47 +44,44 @@ class AuthenticationController:
return render_template('login.html')
def api_login(self) -> Response:
"""处理登录请求并写入 session cookie。"""
def api_login(self) -> ResponseReturnValue:
if not self._auth_service.is_auth_enabled():
return jsonify({'message': 'Authentication not enabled'}), 200
data = request.get_json(silent=True) or {}
data = self._get_request_payload()
username = data.get('username')
password = data.get('password')
if not username or not password:
self._logger.warning("Login rejected: missing username or password")
if not isinstance(username, str) or not username or not isinstance(password, str) or not password:
self._logger.warning('Login rejected: missing username or password')
return jsonify({'error': 'Username and password are required'}), 400
if not self._auth_service.authenticate(username, password):
self._logger.warning(f"Login failed: invalid credentials for username={username!r}")
self._logger.warning('Login failed: invalid credentials for username=%r', username)
return jsonify({'error': 'Invalid username or password'}), 401
session_token = self._auth_service.create_session(username)
self._logger.info(f"Login succeeded: username={username!r}")
self._logger.info('Login succeeded: username=%r', username)
response = make_response(jsonify({'message': 'Login successful', 'username': username}))
cookie_settings = self._auth_service.get_cookie_settings()
response.set_cookie('session_token', session_token, **cookie_settings)
return response
def logout(self) -> Response:
"""处理页面登出并清理 cookie。"""
def logout(self) -> ResponseReturnValue:
session_token = request.cookies.get('session_token')
if session_token:
self._auth_service.destroy_session(session_token)
self._logger.info("Logout succeeded via page route")
self._logger.info('Logout succeeded via page route')
response = make_response(redirect('/login'))
response.delete_cookie('session_token')
return response
def api_logout(self) -> Response:
"""处理 API 登出并清理 cookie。"""
def api_logout(self) -> ResponseReturnValue:
session_token = request.cookies.get('session_token')
if session_token:
self._auth_service.destroy_session(session_token)
self._logger.info("Logout succeeded via API route")
self._logger.info('Logout succeeded via API route')
response = make_response(jsonify({'message': 'Logout successful'}))
response.delete_cookie('session_token')
+9 -4
View File
@@ -3,19 +3,24 @@
"""认证装饰器。"""
from functools import wraps
from typing import Callable
from typing import Callable, ParamSpec
from flask import jsonify, redirect, request
from flask.typing import ResponseReturnValue
from ..services import AuthenticationService
P = ParamSpec("P")
def require_authentication(auth_service: AuthenticationService):
def require_authentication(
auth_service: AuthenticationService,
) -> Callable[[Callable[P, ResponseReturnValue]], Callable[P, ResponseReturnValue]]:
"""生成认证校验装饰器。"""
def decorator(func: Callable) -> Callable:
def decorator(func: Callable[P, ResponseReturnValue]) -> Callable[P, ResponseReturnValue]:
@wraps(func)
def decorated_function(*args, **kwargs):
def decorated_function(*args: P.args, **kwargs: P.kwargs) -> ResponseReturnValue:
if not auth_service.is_auth_enabled():
return func(*args, **kwargs)
+51 -34
View File
@@ -2,7 +2,10 @@
# -*- coding: utf-8 -*-
"""Provider 管理 API。"""
from flask import Response, jsonify, request
from typing import Any
from flask import jsonify, request
from flask.typing import ResponseReturnValue
from ..application.app_context import AppContext
from ..services import (
@@ -16,7 +19,7 @@ from .decorators import require_authentication
class ProviderController:
"""处理 provider 配置的增删改查、模型拉取与配置开关更新。"""
"""处理 provider 配置、认证分组与相关管理 API。"""
def __init__(
self,
@@ -36,6 +39,19 @@ class ProviderController:
self._auth_service = auth_service
self._register_routes()
@staticmethod
def _get_request_payload() -> dict[str, Any]:
payload = request.get_json(silent=True)
if isinstance(payload, dict):
return dict(payload)
return {}
@staticmethod
def _coerce_name_list(value: Any) -> list[str]:
if not isinstance(value, list):
raise ValueError('Provider names must be a non-empty list')
return [str(item) for item in value]
def _register_routes(self) -> None:
auth = require_authentication(self._auth_service)
@@ -75,14 +91,14 @@ class ProviderController:
auth(self.restore_auth_group_entry)
)
def get_providers(self) -> Response:
def get_providers(self) -> ResponseReturnValue:
try:
return jsonify(self._provider_service.list_providers())
except Exception as exc:
self._logger.error('Error getting providers: %s', exc)
return jsonify({'error': str(exc)}), 500
def get_provider(self, name: str) -> Response:
def get_provider(self, name: str) -> ResponseReturnValue:
try:
provider = self._provider_service.get_provider(name)
if provider is None:
@@ -92,9 +108,9 @@ class ProviderController:
self._logger.error('Error getting provider: %s', exc)
return jsonify({'error': str(exc)}), 500
def create_provider(self) -> Response:
def create_provider(self) -> ResponseReturnValue:
try:
payload = request.get_json(silent=True) or {}
payload = self._get_request_payload()
provider = self._provider_service.create_provider(payload)
self._logger.info('Provider created: %s', provider.get('name'))
return jsonify(provider), 201
@@ -104,9 +120,9 @@ class ProviderController:
self._logger.error('Error creating provider: %s', exc)
return jsonify({'error': str(exc)}), 500
def update_provider(self, name: str) -> Response:
def update_provider(self, name: str) -> ResponseReturnValue:
try:
payload = request.get_json(silent=True) or {}
payload = self._get_request_payload()
provider = self._provider_service.update_provider(name, payload)
self._logger.info('Provider updated: %s -> %s', name, provider.get('name'))
return jsonify(provider)
@@ -118,7 +134,7 @@ class ProviderController:
self._logger.error('Error updating provider: %s', exc)
return jsonify({'error': str(exc)}), 500
def delete_provider(self, name: str) -> Response:
def delete_provider(self, name: str) -> ResponseReturnValue:
try:
self._provider_service.delete_provider(name)
self._logger.info('Provider deleted: %s', name)
@@ -131,7 +147,7 @@ class ProviderController:
self._logger.error('Error deleting provider: %s', exc)
return jsonify({'error': str(exc)}), 500
def disable_provider(self, name: str) -> Response:
def disable_provider(self, name: str) -> ResponseReturnValue:
try:
provider = self._provider_service.set_provider_enabled(name, enabled=False)
self._logger.info('Provider disabled: %s', name)
@@ -144,7 +160,7 @@ class ProviderController:
self._logger.error('Error disabling provider: %s', exc)
return jsonify({'error': str(exc)}), 500
def enable_provider(self, name: str) -> Response:
def enable_provider(self, name: str) -> ResponseReturnValue:
try:
provider = self._provider_service.set_provider_enabled(name, enabled=True)
self._logger.info('Provider enabled: %s', name)
@@ -157,11 +173,11 @@ class ProviderController:
self._logger.error('Error enabling provider: %s', exc)
return jsonify({'error': str(exc)}), 500
def batch_providers(self) -> Response:
def batch_providers(self) -> ResponseReturnValue:
try:
payload = request.get_json(silent=True) or {}
payload = self._get_request_payload()
action = str(payload.get('action') or '').strip().lower()
names = payload.get('names')
names = self._coerce_name_list(payload.get('names'))
if action == 'enable':
result = self._provider_service.batch_set_provider_enabled(names, enabled=True)
elif action == 'disable':
@@ -181,14 +197,14 @@ class ProviderController:
self._logger.error('Error applying provider batch action: %s', exc)
return jsonify({'error': str(exc)}), 500
def get_auth_groups(self) -> Response:
def get_auth_groups(self) -> ResponseReturnValue:
try:
return jsonify(self._auth_group_service.list_auth_groups())
except Exception as exc:
self._logger.error('Error getting auth groups: %s', exc)
return jsonify({'error': str(exc)}), 500
def get_auth_group(self, name: str) -> Response:
def get_auth_group(self, name: str) -> ResponseReturnValue:
try:
auth_group = self._auth_group_service.get_auth_group(name)
if auth_group is None:
@@ -198,9 +214,9 @@ class ProviderController:
self._logger.error('Error getting auth group: %s', exc)
return jsonify({'error': str(exc)}), 500
def create_auth_group(self) -> Response:
def create_auth_group(self) -> ResponseReturnValue:
try:
payload = request.get_json(silent=True) or {}
payload = self._get_request_payload()
auth_group = self._auth_group_service.create_auth_group(payload)
self._logger.info('Auth group created: %s', auth_group.get('name'))
return jsonify(auth_group), 201
@@ -210,10 +226,11 @@ class ProviderController:
self._logger.error('Error creating auth group: %s', exc)
return jsonify({'error': str(exc)}), 500
def import_auth_group_entries(self) -> Response:
def import_auth_group_entries(self) -> ResponseReturnValue:
try:
payload = request.get_json(silent=True) or {}
entries = self._auth_group_service.import_auth_entries(payload.get('yaml', ''))
payload = self._get_request_payload()
yaml_text = str(payload.get('yaml', '') or '')
entries = self._auth_group_service.import_auth_entries(yaml_text)
return jsonify({'entries': entries})
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
@@ -221,9 +238,9 @@ class ProviderController:
self._logger.error('Error importing auth group entries: %s', exc)
return jsonify({'error': str(exc)}), 500
def update_auth_group(self, name: str) -> Response:
def update_auth_group(self, name: str) -> ResponseReturnValue:
try:
payload = request.get_json(silent=True) or {}
payload = self._get_request_payload()
auth_group = self._auth_group_service.update_auth_group(name, payload)
self._logger.info('Auth group updated: %s -> %s', name, auth_group.get('name'))
return jsonify(auth_group)
@@ -235,7 +252,7 @@ class ProviderController:
self._logger.error('Error updating auth group: %s', exc)
return jsonify({'error': str(exc)}), 500
def delete_auth_group(self, name: str) -> Response:
def delete_auth_group(self, name: str) -> ResponseReturnValue:
try:
self._auth_group_service.delete_auth_group(name)
self._logger.info('Auth group deleted: %s', name)
@@ -248,7 +265,7 @@ class ProviderController:
self._logger.error('Error deleting auth group: %s', exc)
return jsonify({'error': str(exc)}), 500
def get_auth_group_runtime(self, name: str) -> Response:
def get_auth_group_runtime(self, name: str) -> ResponseReturnValue:
try:
return jsonify(self._auth_group_service.get_auth_group_runtime(name))
except ValueError as exc:
@@ -257,7 +274,7 @@ class ProviderController:
self._logger.error('Error getting auth group runtime: %s', exc)
return jsonify({'error': str(exc)}), 500
def clear_auth_group_entry_cooldown(self, name: str, entry_id: str) -> Response:
def clear_auth_group_entry_cooldown(self, name: str, entry_id: str) -> ResponseReturnValue:
try:
self._auth_group_service.clear_entry_cooldown(name, entry_id)
self._logger.info('Auth group entry cooldown cleared: %s/%s', name, entry_id)
@@ -270,7 +287,7 @@ class ProviderController:
self._logger.error('Error clearing auth group entry cooldown: %s', exc)
return jsonify({'error': str(exc)}), 500
def disable_auth_group_entry(self, name: str, entry_id: str) -> Response:
def disable_auth_group_entry(self, name: str, entry_id: str) -> ResponseReturnValue:
try:
self._auth_group_service.set_entry_disabled(name, entry_id, disabled=True)
self._logger.info('Auth group entry disabled: %s/%s', name, entry_id)
@@ -283,7 +300,7 @@ class ProviderController:
self._logger.error('Error disabling auth group entry: %s', exc)
return jsonify({'error': str(exc)}), 500
def enable_auth_group_entry(self, name: str, entry_id: str) -> Response:
def enable_auth_group_entry(self, name: str, entry_id: str) -> ResponseReturnValue:
try:
self._auth_group_service.set_entry_disabled(name, entry_id, disabled=False)
self._logger.info('Auth group entry enabled: %s/%s', name, entry_id)
@@ -296,7 +313,7 @@ class ProviderController:
self._logger.error('Error enabling auth group entry: %s', exc)
return jsonify({'error': str(exc)}), 500
def reset_auth_group_entry_minute_usage(self, name: str, entry_id: str) -> Response:
def reset_auth_group_entry_minute_usage(self, name: str, entry_id: str) -> ResponseReturnValue:
try:
self._auth_group_service.reset_entry_minute_usage(name, entry_id)
self._logger.info('Auth group entry minute usage reset: %s/%s', name, entry_id)
@@ -309,7 +326,7 @@ class ProviderController:
self._logger.error('Error resetting auth group entry minute usage: %s', exc)
return jsonify({'error': str(exc)}), 500
def reset_auth_group_entry_runtime(self, name: str, entry_id: str) -> Response:
def reset_auth_group_entry_runtime(self, name: str, entry_id: str) -> ResponseReturnValue:
try:
self._auth_group_service.reset_entry_runtime(name, entry_id)
self._logger.info('Auth group entry runtime reset: %s/%s', name, entry_id)
@@ -322,7 +339,7 @@ class ProviderController:
self._logger.error('Error resetting auth group entry runtime: %s', exc)
return jsonify({'error': str(exc)}), 500
def restore_auth_group_entry(self, name: str, entry_id: str) -> Response:
def restore_auth_group_entry(self, name: str, entry_id: str) -> ResponseReturnValue:
try:
self._auth_group_service.restore_entry(name, entry_id)
self._logger.info('Auth group entry restored: %s/%s', name, entry_id)
@@ -335,7 +352,7 @@ class ProviderController:
self._logger.error('Error restoring auth group entry: %s', exc)
return jsonify({'error': str(exc)}), 500
def fetch_models(self) -> Response:
def fetch_models(self) -> ResponseReturnValue:
try:
result = self._model_discovery_service.fetch_models_preview(
api=request.args.get('api', ''),
@@ -356,9 +373,9 @@ class ProviderController:
self._logger.error('Error fetching provider models: %s', exc)
return jsonify({'error': str(exc)}), 500
def update_chat_whitelist(self) -> Response:
def update_chat_whitelist(self) -> ResponseReturnValue:
try:
payload = request.get_json(silent=True) or {}
payload = self._get_request_payload()
enabled = self._settings_service.update_chat_whitelist_enabled(payload.get('enabled'))
self._logger.info('Chat whitelist updated: enabled=%s', enabled)
return jsonify({'enabled': enabled})
+19 -13
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
from typing import Any, Dict, Iterable, Optional
from flask import Response, jsonify, request
from flask.typing import ResponseReturnValue
from ..application.app_context import AppContext
from ..config import ConfigManager, ProviderManager
@@ -56,7 +57,7 @@ class ProxyController:
*,
error_format: str,
) -> tuple[Optional[Dict[str, Any]], Optional[tuple[Response, int]]]:
"""在开启白名单时解析当前请求对应用户。"""
"""在启用白名单时解析当前请求对应的用户。"""
if not self._is_whitelist_required():
return None, None
@@ -151,21 +152,21 @@ class ProxyController:
return normalized[0]
return ", ".join(normalized)
def chat_completions(self) -> Response:
def chat_completions(self) -> ResponseReturnValue:
return self._proxy_completion_request(
route_name="chat_completions",
expected_target_formats=("openai_chat",),
inspect_stream_usage=True,
)
def responses(self) -> Response:
def responses(self) -> ResponseReturnValue:
return self._proxy_completion_request(
route_name="responses",
expected_target_formats=("openai_responses", "codex"),
inspect_stream_usage=False,
)
def messages(self) -> Response:
def messages(self) -> ResponseReturnValue:
return self._proxy_completion_request(
route_name="messages",
expected_target_formats=("claude_chat",),
@@ -179,7 +180,7 @@ class ProxyController:
expected_target_formats: Iterable[str],
inspect_stream_usage: bool,
error_format: Optional[str] = None,
) -> Response:
) -> ResponseReturnValue:
normalized_expected_target_formats = tuple(
str(item or "").strip().lower() for item in expected_target_formats if str(item or "").strip()
)
@@ -196,10 +197,10 @@ class ProxyController:
if denial_response is not None:
return denial_response
request_data = request.get_json(silent=True)
if request_data is None:
request_data = {}
if not isinstance(request_data, dict):
raw_request_data = request.get_json(silent=True)
if raw_request_data is None:
request_data: Dict[str, Any] = {}
elif not isinstance(raw_request_data, dict):
self._logger.warning("Proxy rejected: request body is not a JSON object route=%s", route_name)
return self._error_response(
"Request body must be a JSON object",
@@ -208,9 +209,11 @@ class ProxyController:
code="invalid_request_body",
error_format=resolved_error_format,
)
else:
request_data = dict(raw_request_data)
request_data = dict(request_data)
if "model" not in request_data:
model_name_value = request_data.get("model")
if not isinstance(model_name_value, str) or not model_name_value.strip():
self._logger.warning("Proxy rejected: missing model in request body route=%s", route_name)
return self._error_response(
"Missing 'model' in request body",
@@ -220,7 +223,7 @@ class ProxyController:
error_format=resolved_error_format,
)
model_name = request_data["model"]
model_name = model_name_value.strip()
provider = self._provider_manager.get_provider_for_model(model_name)
if not provider:
self._logger.warning("Proxy rejected: unknown model=%r route=%s", model_name, route_name)
@@ -390,7 +393,7 @@ class ProxyController:
error_format=resolved_error_format,
)
def list_models(self) -> Response:
def list_models(self) -> ResponseReturnValue:
try:
client_ip = normalize_ip(request.remote_addr)
user, denial_response = self._get_authorized_user_for_request(
@@ -453,3 +456,6 @@ class ProxyController:
"upgrade",
}
return {k: v for k, v in headers.items() if k.lower() not in excluded}
+73 -55
View File
@@ -2,7 +2,10 @@
# -*- coding: utf-8 -*-
"""用户控制器。"""
from flask import Response, jsonify, request
from typing import Any
from flask import jsonify, request
from flask.typing import ResponseReturnValue
from ..application.app_context import AppContext
from ..services import AuthenticationService, UserService
@@ -22,8 +25,14 @@ class UserController:
self._auth_service = auth_service
self._register_routes()
@staticmethod
def _get_request_payload() -> dict[str, Any]:
payload = request.get_json(silent=True)
if isinstance(payload, dict):
return dict(payload)
return {}
def _register_routes(self) -> None:
"""注册用户管理路由。"""
auth = require_authentication(self._auth_service)
self._app.route('/api/users', methods=['GET'])(auth(self.get_users))
@@ -34,14 +43,13 @@ class UserController:
self._app.route('/api/users/<int:user_id>', methods=['DELETE'])(auth(self.delete_user))
self._app.route('/api/users/<int:user_id>/toggle', methods=['POST'])(auth(self.toggle_user))
def get_users(self) -> Response:
"""分页查询用户列表。"""
def get_users(self) -> ResponseReturnValue:
try:
page = request.args.get('page', 1, type=int)
page_size = request.args.get('page_size', 50, type=int)
keyword = (request.args.get('keyword', '', type=str) or '').strip()
self._logger.debug(
"List users requested: page=%s, page_size=%s, keyword=%r",
'List users requested: page=%s, page_size=%s, keyword=%r',
page,
page_size,
keyword,
@@ -50,71 +58,84 @@ class UserController:
users = self._user_service.get_users(page=page, page_size=page_size, keyword=keyword)
total = self._user_service.get_total_users_count(keyword=keyword)
return jsonify({
'users': users,
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
'keyword': keyword,
'available_models': self._user_service.get_available_models(),
})
return jsonify(
{
'users': users,
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
'keyword': keyword,
'available_models': self._user_service.get_available_models(),
}
)
except Exception as exc:
self._logger.error(f'Error getting users: {exc}')
self._logger.error('Error getting users: %s', exc)
return jsonify({'error': str(exc)}), 500
def get_user(self, user_id: int) -> Response:
"""查询单个用户。"""
def get_user(self, user_id: int) -> ResponseReturnValue:
try:
user = self._user_service.get_user_by_id(user_id)
if not user:
self._logger.warning(f"Get user failed: user_id={user_id} not found")
self._logger.warning('Get user failed: user_id=%s not found', user_id)
return jsonify({'error': 'User not found'}), 404
return jsonify(user)
except Exception as exc:
self._logger.error(f'Error getting user: {exc}')
self._logger.error('Error getting user: %s', exc)
return jsonify({'error': str(exc)}), 500
def create_user(self) -> Response:
"""创建用户。"""
def create_user(self) -> ResponseReturnValue:
try:
data = request.get_json(silent=True) or {}
data = self._get_request_payload()
username = data.get('username')
ip_address = data.get('ip_address')
if not username:
self._logger.warning("Create user rejected: username is empty")
if not isinstance(username, str) or not username.strip():
self._logger.warning('Create user rejected: username is empty')
return jsonify({'error': 'Username is required'}), 400
if not ip_address:
self._logger.warning("Create user rejected: ip_address is empty")
if not isinstance(ip_address, str) or not ip_address.strip():
self._logger.warning('Create user rejected: ip_address is empty')
return jsonify({'error': 'IP address is required'}), 400
normalized_username = username.strip()
normalized_ip = normalize_ip(ip_address)
if not is_valid_ip(normalized_ip):
self._logger.warning(f"Create user rejected: invalid ip_address={ip_address!r}")
self._logger.warning('Create user rejected: invalid ip_address=%r', ip_address)
return jsonify({'error': 'Invalid IP address'}), 400
user_id = self._user_service.create_user(username, normalized_ip)
user_id = self._user_service.create_user(normalized_username, normalized_ip)
if not user_id:
self._logger.warning(f"Create user failed: username={username!r}, ip={normalized_ip}")
self._logger.warning('Create user failed: username=%r, ip=%s', normalized_username, normalized_ip)
return jsonify({'error': 'Failed to create user or IP already exists'}), 400
self._logger.info(f"Create user succeeded: user_id={user_id}, username={username!r}, ip={normalized_ip}")
self._logger.info(
'Create user succeeded: user_id=%s, username=%r, ip=%s',
user_id,
normalized_username,
normalized_ip,
)
return jsonify({'id': user_id, 'message': 'User created successfully'}), 201
except Exception as exc:
self._logger.error(f'Error creating user: {exc}')
self._logger.error('Error creating user: %s', exc)
return jsonify({'error': str(exc)}), 500
def update_user(self, user_id: int) -> Response:
"""更新用户信息。"""
def update_user(self, user_id: int) -> ResponseReturnValue:
try:
data = request.get_json(silent=True)
data = self._get_request_payload()
if not data:
self._logger.warning(f"Update user rejected: no payload, user_id={user_id}")
self._logger.warning('Update user rejected: no payload, user_id=%s', user_id)
return jsonify({'error': 'No data provided'}), 400
username = data.get('username')
ip_address = data.get('ip_address')
username_value = data.get('username')
if username_value is not None and not isinstance(username_value, str):
return jsonify({'error': 'Username must be a string'}), 400
username = username_value
ip_address_value = data.get('ip_address')
if ip_address_value is not None and not isinstance(ip_address_value, str):
return jsonify({'error': 'IP address must be a string'}), 400
ip_address = ip_address_value
whitelist_access_enabled = data.get('whitelist_access_enabled')
model_permissions_provided = 'model_permissions' in data
model_permissions = data.get('model_permissions')
@@ -123,7 +144,7 @@ class UserController:
if ip_address is not None:
normalized_ip = normalize_ip(ip_address)
if not is_valid_ip(normalized_ip):
self._logger.warning(f"Update user rejected: invalid ip={ip_address!r}, user_id={user_id}")
self._logger.warning('Update user rejected: invalid ip=%r, user_id=%s', ip_address, user_id)
return jsonify({'error': 'Invalid IP address'}), 400
success = self._user_service.update_user(
@@ -135,21 +156,20 @@ class UserController:
model_permissions=model_permissions,
)
if not success:
self._logger.warning(f"Update user failed: user_id={user_id}")
self._logger.warning('Update user failed: user_id=%s', user_id)
return jsonify({'error': 'Failed to update user'}), 400
self._logger.info(f"Update user succeeded: user_id={user_id}")
self._logger.info('Update user succeeded: user_id=%s', user_id)
return jsonify({'message': 'User updated successfully'})
except ValueError as exc:
return jsonify({'error': str(exc)}), 400
except Exception as exc:
self._logger.error(f'Error updating user: {exc}')
self._logger.error('Error updating user: %s', exc)
return jsonify({'error': str(exc)}), 500
def batch_users(self) -> Response:
"""执行用户批量操作。"""
def batch_users(self) -> ResponseReturnValue:
try:
payload = request.get_json(silent=True) or {}
payload = self._get_request_payload()
action = str(payload.get('action') or '').strip().lower()
if action != 'set_model_permissions':
raise ValueError(f'Unsupported user batch action: {action or "<empty>"}')
@@ -169,31 +189,29 @@ class UserController:
status_code = 404 if 'not found' in message.lower() else 400
return jsonify({'error': message}), status_code
except Exception as exc:
self._logger.error(f'Error applying user batch action: {exc}')
self._logger.error('Error applying user batch action: %s', exc)
return jsonify({'error': str(exc)}), 500
def delete_user(self, user_id: int) -> Response:
"""删除用户。"""
def delete_user(self, user_id: int) -> ResponseReturnValue:
try:
if not self._user_service.delete_user(user_id):
self._logger.warning(f"Delete user failed: user_id={user_id}")
self._logger.warning('Delete user failed: user_id=%s', user_id)
return jsonify({'error': 'Failed to delete user'}), 400
self._logger.info(f"Delete user succeeded: user_id={user_id}")
self._logger.info('Delete user succeeded: user_id=%s', user_id)
return jsonify({'message': 'User deleted successfully'})
except Exception as exc:
self._logger.error(f'Error deleting user: {exc}')
self._logger.error('Error deleting user: %s', exc)
return jsonify({'error': str(exc)}), 500
def toggle_user(self, user_id: int) -> Response:
"""切换用户白名单状态。"""
def toggle_user(self, user_id: int) -> ResponseReturnValue:
try:
if not self._config_manager.is_chat_whitelist_enabled():
return jsonify({'error': 'Whitelist control is disabled'}), 400
if not self._user_service.toggle_user_status(user_id):
self._logger.warning(f"Toggle user whitelist failed: user_id={user_id}")
self._logger.warning('Toggle user whitelist failed: user_id=%s', user_id)
return jsonify({'error': 'Failed to toggle user status'}), 400
self._logger.info(f"Toggle user whitelist succeeded: user_id={user_id}")
self._logger.info('Toggle user whitelist succeeded: user_id=%s', user_id)
return jsonify({'message': 'User status toggled successfully'})
except Exception as exc:
self._logger.error(f'Error toggling user status: {exc}')
self._logger.error('Error toggling user status: %s', exc)
return jsonify({'error': str(exc)}), 500
+49 -48
View File
@@ -2,7 +2,8 @@
# -*- coding: utf-8 -*-
"""页面与统计控制器。"""
from flask import Response, jsonify, render_template, request
from flask import jsonify, render_template, request
from flask.typing import ResponseReturnValue
from ..application.app_context import AppContext
from ..services import AuthenticationService, LogService
@@ -24,27 +25,27 @@ class WebController:
def _register_routes(self) -> None:
auth = require_authentication(self._auth_service)
self._app.route("/")(auth(self.index))
self._app.route("/users")(auth(self.users_page))
self._app.route("/providers")(auth(self.providers_page))
self._app.route('/')(auth(self.index))
self._app.route('/users')(auth(self.users_page))
self._app.route('/providers')(auth(self.providers_page))
self._app.route("/api/statistics", methods=["GET"])(auth(self.get_statistics))
self._app.route("/api/request-logs", methods=["GET"])(auth(self.get_request_logs))
self._app.route("/api/usernames", methods=["GET"])(auth(self.get_usernames))
self._app.route("/api/request-models", methods=["GET"])(auth(self.get_request_models))
self._app.route('/api/statistics', methods=['GET'])(auth(self.get_statistics))
self._app.route('/api/request-logs', methods=['GET'])(auth(self.get_request_logs))
self._app.route('/api/usernames', methods=['GET'])(auth(self.get_usernames))
self._app.route('/api/request-models', methods=['GET'])(auth(self.get_request_models))
def index(self) -> str:
return render_template(
"index.html",
active_page="index",
'index.html',
active_page='index',
current_username=self._get_current_username(),
auth_enabled=self._auth_service.is_auth_enabled(),
)
def users_page(self) -> str:
return render_template(
"users.html",
active_page="users",
'users.html',
active_page='users',
chat_whitelist_enabled=self._config_manager.is_chat_whitelist_enabled(),
current_username=self._get_current_username(),
auth_enabled=self._auth_service.is_auth_enabled(),
@@ -52,8 +53,8 @@ class WebController:
def providers_page(self) -> str:
return render_template(
"providers.html",
active_page="providers",
'providers.html',
active_page='providers',
chat_whitelist_enabled=self._config_manager.is_chat_whitelist_enabled(),
current_username=self._get_current_username(),
auth_enabled=self._auth_service.is_auth_enabled(),
@@ -61,66 +62,66 @@ class WebController:
def _get_current_username(self) -> str:
if not self._auth_service.is_auth_enabled():
return ""
return ''
session_token = request.cookies.get("session_token")
return self._auth_service.get_session_username(session_token) or ""
session_token = request.cookies.get('session_token')
return self._auth_service.get_session_username(session_token) or ''
def get_statistics(self) -> Response:
def get_statistics(self) -> ResponseReturnValue:
try:
self._logger.debug(
"Statistics queried: start_date=%s end_date=%s username=%s request_model=%s",
request.args.get("start_date"),
request.args.get("end_date"),
request.args.get("username"),
request.args.get("request_model"),
'Statistics queried: start_date=%s end_date=%s username=%s request_model=%s',
request.args.get('start_date'),
request.args.get('end_date'),
request.args.get('username'),
request.args.get('request_model'),
)
stats = self._log_service.get_statistics(
request.args.get("start_date"),
request.args.get("end_date"),
request.args.get("username"),
request.args.get("request_model"),
request.args.get('start_date'),
request.args.get('end_date'),
request.args.get('username'),
request.args.get('request_model'),
)
return jsonify(stats)
except Exception as exc:
self._logger.error(f"Error getting statistics: {exc}")
return jsonify({"error": str(exc)}), 500
self._logger.error('Error getting statistics: %s', exc)
return jsonify({'error': str(exc)}), 500
def get_request_logs(self) -> Response:
def get_request_logs(self) -> ResponseReturnValue:
try:
page = max(int(request.args.get("page", 1)), 1)
page_size = min(max(int(request.args.get("page_size", 50)), 1), 200)
self._logger.debug(f"Request logs queried: page={page}, page_size={page_size}")
page = max(int(request.args.get('page', 1)), 1)
page_size = min(max(int(request.args.get('page_size', 50)), 1), 200)
self._logger.debug('Request logs queried: page=%s, page_size=%s', page, page_size)
logs = self._log_service.get_request_logs(
page,
page_size,
request.args.get("start_date"),
request.args.get("end_date"),
request.args.get("username"),
request.args.get("request_model"),
request.args.get('start_date'),
request.args.get('end_date'),
request.args.get('username'),
request.args.get('request_model'),
)
return jsonify(logs)
except ValueError:
return jsonify({"error": "page and page_size must be integers"}), 400
return jsonify({'error': 'page and page_size must be integers'}), 400
except Exception as exc:
self._logger.error(f"Error getting request logs: {exc}")
return jsonify({"error": str(exc)}), 500
self._logger.error('Error getting request logs: %s', exc)
return jsonify({'error': str(exc)}), 500
def get_usernames(self) -> Response:
def get_usernames(self) -> ResponseReturnValue:
try:
usernames = self._log_service.get_unique_usernames()
self._logger.debug(f"Usernames queried: count={len(usernames)}")
self._logger.debug('Usernames queried: count=%s', len(usernames))
return jsonify(usernames)
except Exception as exc:
self._logger.error(f"Error getting usernames: {exc}")
return jsonify({"error": str(exc)}), 500
self._logger.error('Error getting usernames: %s', exc)
return jsonify({'error': str(exc)}), 500
def get_request_models(self) -> Response:
def get_request_models(self) -> ResponseReturnValue:
try:
models = self._log_service.get_unique_request_models()
self._logger.debug(f"Request models queried: count={len(models)}")
self._logger.debug('Request models queried: count=%s', len(models))
return jsonify(models)
except Exception as exc:
self._logger.error(f"Error getting request models: {exc}")
return jsonify({"error": str(exc)}), 500
self._logger.error('Error getting request models: %s', exc)
return jsonify({'error': str(exc)}), 500
+2 -2
View File
@@ -158,8 +158,8 @@ class UserRepository:
model_permissions: Optional[str] = None,
) -> bool:
"""更新用户记录。"""
updates = []
params = []
updates: list[str] = []
params: list[Any] = []
if username is not None:
updates.append("username = ?")
+1
View File
@@ -218,6 +218,7 @@ class AuthGroupService:
@staticmethod
def _normalize_import_payload(payload: Any) -> List[Mapping[str, Any]]:
entries: Any
if isinstance(payload, list):
entries = payload
elif isinstance(payload, Mapping):
+2 -1
View File
@@ -231,10 +231,11 @@ class UserService:
try:
available_models = self._get_available_model_names()
users = self._repository.get(page=page, page_size=page_size, keyword=keyword)
return [
decorated_users = [
self._decorate_user(user, available_models=available_models)
for user in users
]
return [user for user in decorated_users if user is not None]
except Exception as exc:
self._logger.error(f"Failed to get users: {exc}")
return []
+4 -3
View File
@@ -324,7 +324,7 @@ def convert_openai_chat_response_to_claude(
if not isinstance(payload, dict):
return payload
message = {}
message: Dict[str, Any] = {}
finish_reason = None
for choice in payload.get("choices") or []:
if not isinstance(choice, dict):
@@ -350,13 +350,14 @@ def convert_openai_chat_response_to_claude(
"stop_sequence": None,
}
if isinstance(payload.get("usage"), dict):
response["usage"] = {
usage_payload: Dict[str, Any] = {
"input_tokens": int(payload["usage"].get("prompt_tokens") or 0),
"output_tokens": int(payload["usage"].get("completion_tokens") or 0),
}
response["usage"] = usage_payload
cached_tokens = int(((payload["usage"].get("prompt_tokens_details") or {}).get("cached_tokens")) or 0)
if cached_tokens > 0:
response["usage"]["cache_read_input_tokens"] = cached_tokens
usage_payload["cache_read_input_tokens"] = cached_tokens
return response
+12 -7
View File
@@ -26,8 +26,11 @@ from .responses_bridge import (
class Translator(Protocol):
source_format: str
target_format: str
@property
def source_format(self) -> str: ...
@property
def target_format(self) -> str: ...
def translate_request(self, model_name: str, body: Dict[str, Any], stream: bool) -> Dict[str, Any]:
...
@@ -1047,7 +1050,7 @@ def build_default_translator_registry() -> TranslatorRegistry:
codex_passthrough = CodexPassthroughTranslator()
openai_chat_codex = OpenAIChatCodexTranslator()
for translator in (
builtin_translators: tuple[Translator, ...] = (
openai_chat,
openai_responses,
openai_responses_passthrough,
@@ -1058,17 +1061,19 @@ def build_default_translator_registry() -> TranslatorRegistry:
codex_chat,
codex_passthrough,
openai_chat_codex,
):
)
for translator in builtin_translators:
registry.register(translator)
for translator in (
composed_translators: tuple[Translator, ...] = (
ComposedTranslator("openai_responses", "claude_chat", openai_responses, openai_chat_claude),
ComposedTranslator("openai_responses", "codex", openai_responses, openai_chat_codex),
ComposedTranslator("claude_chat", "openai_responses", claude_chat, openai_chat_responses),
ComposedTranslator("claude_chat", "codex", claude_chat, openai_chat_codex),
ComposedTranslator("codex", "openai_responses", codex_chat, openai_chat_responses),
ComposedTranslator("codex", "claude_chat", codex_chat, openai_chat_claude),
):
)
for translator in composed_translators:
registry.register(translator)
return registry
@@ -1486,7 +1491,7 @@ def _extract_claude_usage(payload: Any) -> Dict[str, Any]:
return {}
prompt_tokens = int(payload.get("input_tokens") or 0)
completion_tokens = int(payload.get("output_tokens") or 0)
usage = {
usage: Dict[str, Any] = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
+1 -1
View File
@@ -625,7 +625,7 @@ def convert_openai_chat_response_to_responses(
if not isinstance(payload, dict):
return payload
message = {}
message: Dict[str, Any] = {}
finish_reason = None
for choice in payload.get("choices") or []:
if not isinstance(choice, dict):
+39 -26
View File
@@ -1,46 +1,59 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Compatibility helpers for supported Python runtimes."""
"""兼容不同 Python 运行时的辅助工具。"""
from __future__ import annotations
import sys
from dataclasses import dataclass as _dataclass
from enum import Enum
from typing import Any
from typing import TYPE_CHECKING, Any, Callable, TypeVar, overload
try:
if TYPE_CHECKING:
from enum import StrEnum
except ImportError:
class StrEnum(str, Enum):
"""Fallback StrEnum for Python versions before 3.11."""
pass
try:
from typing import Literal
except ImportError:
from typing import Literal, Protocol
from typing import dataclass_transform as _dataclass_transform
else:
try:
from typing_extensions import Literal # type: ignore
from typing import Literal, Protocol
except ImportError:
class _LiteralCompat(object):
def __getitem__(self, _item):
return Any
from typing_extensions import Literal, Protocol # type: ignore
Literal = _LiteralCompat()
try:
from typing import Protocol
except ImportError:
try:
from typing_extensions import Protocol # type: ignore
from typing import dataclass_transform as _dataclass_transform
except ImportError:
class Protocol(object):
"""Fallback Protocol base when typing_extensions is unavailable."""
try:
from typing_extensions import dataclass_transform as _dataclass_transform # type: ignore
except ImportError:
def _dataclass_transform(*_args: Any, **_kwargs: Any) -> Callable[[Any], Any]:
def decorator(func: Any) -> Any:
return func
return decorator
try:
from enum import StrEnum
except ImportError:
class StrEnum(str, Enum):
"""Python 3.11 之前的 StrEnum 兼容实现。"""
pass
def dataclass(_cls=None, **kwargs):
"""Mirror dataclasses.dataclass while skipping unsupported keywords."""
T = TypeVar("T")
@_dataclass_transform()
@overload
def dataclass(_cls: type[T], **kwargs: Any) -> type[T]: ...
@overload
def dataclass(_cls: None = None, **kwargs: Any) -> Callable[[type[T]], type[T]]: ...
def dataclass(_cls: type[T] | None = None, **kwargs: Any) -> Any:
"""兼容旧版本参数的 dataclass 包装器。"""
if sys.version_info < (3, 10):
kwargs.pop("slots", None)
+11 -10
View File
@@ -1,6 +1,7 @@
import sys
import unittest
from pathlib import Path
from typing import Any, cast
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
@@ -9,17 +10,17 @@ from src.hooks import BaseHook, HookContext
class FakeLogger:
def info(self, msg: str, *args) -> None:
del msg, args
def info(self, msg: object, *args: object, **kwargs: object) -> None:
del msg, args, kwargs
def warning(self, msg: str, *args) -> None:
del msg, args
def warning(self, msg: object, *args: object, **kwargs: object) -> None:
del msg, args, kwargs
def error(self, msg: str, *args) -> None:
del msg, args
def error(self, msg: object, *args: object, **kwargs: object) -> None:
del msg, args, kwargs
def debug(self, msg: str, *args) -> None:
del msg, args
def debug(self, msg: object, *args: object, **kwargs: object) -> None:
del msg, args, kwargs
class LegacyOnlyHook:
@@ -83,7 +84,7 @@ class HookContractsTests(unittest.TestCase):
self.assertEqual(response_body, hook.response_guard(ctx, response_body))
def test_provider_does_not_call_legacy_hook_methods(self) -> None:
provider = LLMProvider(name="demo", api="https://example.com", hook=LegacyOnlyHook())
provider = LLMProvider(name="demo", api="https://example.com", hook=cast(Any, LegacyOnlyHook()))
ctx = self._ctx()
request_body = {"messages": [{"role": "user", "content": "hello"}]}
response_body = {"message": "ok"}
@@ -101,7 +102,7 @@ class HookContractsTests(unittest.TestCase):
def test_none_from_guard_keeps_original_body(self) -> None:
provider = LLMProvider(name="demo", api="https://example.com", hook=NoneReturningGuard())
ctx = self._ctx()
request_body = {"messages": []}
request_body: dict[str, Any] = {"messages": []}
response_body = {"message": "ok"}
self.assertEqual(request_body, provider.apply_request_guard(ctx, request_body))
+4 -2
View File
@@ -127,8 +127,8 @@ class StubProxyService:
class RecordingProxyService:
def __init__(self, proxy_result) -> None:
self._proxy_result = proxy_result
self.last_args = None
self.last_kwargs = None
self.last_args: tuple[object, ...] | None = None
self.last_kwargs: dict[str, object] | None = None
def proxy_request(self, *args, **kwargs):
self.last_args = args
@@ -455,6 +455,8 @@ class ProxyControllerErrorFormatTests(unittest.TestCase):
)
self.assertEqual(200, response.status_code)
self.assertIsNotNone(proxy_service.last_kwargs)
assert proxy_service.last_kwargs is not None
self.assertEqual("openai_chat", proxy_service.last_kwargs["resolved_target_format"])
def test_responses_route_allows_codex_target(self) -> None:
+28 -3
View File
@@ -3,10 +3,13 @@ import tempfile
import unittest
from copy import deepcopy
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.application.app_context import AppContext
from src.repositories import UserRepository
from src.services import UserService
from src.utils.database import create_connection_factory
@@ -49,9 +52,11 @@ class UserModelPermissionTests(unittest.TestCase):
}
]
}
self._ctx = SimpleNamespace(
self._ctx = AppContext(
logger=DummyLogger(),
config_manager=FakeConfigManager(self._config),
config_manager=cast(Any, FakeConfigManager(self._config)),
root_path=Path(self._tempdir.name),
flask_app=Flask(__name__),
)
self._repository = UserRepository(self._get_connection)
self._service = UserService(self._ctx, self._repository)
@@ -61,13 +66,19 @@ class UserModelPermissionTests(unittest.TestCase):
def test_user_repository_defaults_model_permissions_to_wildcard(self) -> None:
user_id = self._repository.create("alice", "127.0.0.1")
self.assertIsNotNone(user_id)
assert user_id is not None
user = self._repository.get_by_id(user_id)
self.assertIsNotNone(user)
assert user is not None
self.assertEqual("*", user["model_permissions"])
def test_update_user_model_permissions_supports_explicit_models(self) -> None:
user_id = self._repository.create("alice", "127.0.0.1")
self.assertIsNotNone(user_id)
assert user_id is not None
updated = self._service.update_user(
user_id,
@@ -77,12 +88,16 @@ class UserModelPermissionTests(unittest.TestCase):
user = self._service.get_user_by_id(user_id)
self.assertTrue(updated)
self.assertIsNotNone(user)
assert user is not None
self.assertEqual(["demo/gpt-4.1-mini"], user["model_permissions"])
self.assertFalse(self._service.can_user_access_model(user, "demo/gpt-4.1"))
self.assertTrue(self._service.can_user_access_model(user, "demo/gpt-4.1-mini"))
def test_sync_model_permissions_prunes_deleted_models_from_explicit_users(self) -> None:
user_id = self._repository.create("alice", "127.0.0.1")
self.assertIsNotNone(user_id)
assert user_id is not None
self._service.update_user(
user_id,
model_permissions_provided=True,
@@ -94,11 +109,17 @@ class UserModelPermissionTests(unittest.TestCase):
user = self._service.get_user_by_id(user_id)
self.assertEqual(1, updated_count)
self.assertIsNotNone(user)
assert user is not None
self.assertEqual(["demo/gpt-4.1"], user["model_permissions"])
def test_batch_update_model_permissions_updates_multiple_users(self) -> None:
alice_id = self._repository.create("alice", "127.0.0.1")
bob_id = self._repository.create("bob", "127.0.0.2")
self.assertIsNotNone(alice_id)
self.assertIsNotNone(bob_id)
assert alice_id is not None
assert bob_id is not None
result = self._service.batch_update_model_permissions(
[alice_id, bob_id],
@@ -107,6 +128,10 @@ class UserModelPermissionTests(unittest.TestCase):
alice = self._service.get_user_by_id(alice_id)
bob = self._service.get_user_by_id(bob_id)
self.assertIsNotNone(alice)
self.assertIsNotNone(bob)
assert alice is not None
assert bob is not None
self.assertEqual(2, result["count"])
self.assertEqual(["demo/gpt-4.1-mini"], result["model_permissions"])
self.assertEqual(["demo/gpt-4.1-mini"], alice["model_permissions"])