feat: 新增 Auth Group 多凭证调度与管理面板
- 新增 auth_group/auth_entry 配置模型、运行态管理与 SQLite 持久化 - 支持 provider 绑定 auth_group,并在代理链路中按 least_inflight 选择凭证 - 增加 429 冷却、401/403 禁用、请求数与 token 配额统计及重置能力 - 扩展 HookContext,暴露 auth_group_name 和 auth_entry_id - 重构 Provider 管理页,新增 Auth Group 管理、运行态面板与 Auth Entries YAML 编辑 - 同步更新 README、架构文档、示例配置和相关测试
This commit is contained in:
1 parent
c632067ed7
commit
e86317aa2b
23 files changed
+4238
-79
No files matched your search
@@ -89,6 +89,32 @@
|
||||
|
||||
## Provider 配置
|
||||
|
||||
### Auth Groups
|
||||
|
||||
```yaml
|
||||
auth_groups:
|
||||
- name: openai-shared
|
||||
strategy: least_inflight
|
||||
cooldown_seconds_on_429: 60
|
||||
entries:
|
||||
- id: key-a
|
||||
headers:
|
||||
Authorization: Bearer sk-your-openai-key-a
|
||||
max_concurrency: 3
|
||||
- id: key-b
|
||||
headers:
|
||||
Authorization: Bearer sk-your-openai-key-b
|
||||
max_concurrency: 2
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- provider 可以引用 `auth_group`,也可以继续用 legacy `api_key`
|
||||
- `auth_group` 和 `api_key` 不能同时填写;也可以都不填,交给 hook 或无鉴权上游处理
|
||||
- `api_key` 仍然保留为 legacy 单 key 快捷写法
|
||||
- `auth_group` 支持并发控制、`429` 冷却、请求数配额、Token 配额
|
||||
- `401/403` 会禁用当前 entry,直到在管理页手动恢复
|
||||
|
||||
### 样例
|
||||
|
||||
见 [config.sample.yaml](/d:/001Code/008llm/003LLM_Proxy/config.sample.yaml)。
|
||||
@@ -102,7 +128,7 @@ providers:
|
||||
transport: http
|
||||
source_format: openai_chat
|
||||
target_format: openai_chat
|
||||
api_key: sk-your-openai-key
|
||||
auth_group: openai-shared
|
||||
verify_ssl: true
|
||||
model_list:
|
||||
- gpt-4.1
|
||||
@@ -120,6 +146,10 @@ providers:
|
||||
- 上游真实协议
|
||||
- `target_format`
|
||||
- 下游看到的协议
|
||||
- `auth_group`
|
||||
- 引用顶层 `auth_groups` 里的凭证池
|
||||
- `api_key`
|
||||
- legacy 单 key 快捷写法;不能和 `auth_group` 同时填写,也可以两者都留空
|
||||
- `model_list`
|
||||
- 这个 provider 暴露给下游的模型名列表
|
||||
- `hook`
|
||||
@@ -204,6 +234,8 @@ class Hook(BaseHook):
|
||||
- `provider_target_format`
|
||||
- `transport`
|
||||
- `stream`
|
||||
- `auth_group_name`
|
||||
- `auth_entry_id`
|
||||
- `last_status_code`
|
||||
- `last_error_type`
|
||||
|
||||
@@ -223,9 +255,15 @@ class Hook(BaseHook):
|
||||
|
||||
## Provider 页面
|
||||
|
||||
Provider 管理页不再展示兼容矩阵,也不再让用户选择流格式。
|
||||
Provider 管理页现在拆成两块:
|
||||
|
||||
页面上会提供 3 个帮助入口:
|
||||
- `Auth Groups`
|
||||
- 维护 auth_group / auth_entry
|
||||
- 查看运行态、冷却、禁用和配额
|
||||
- `Providers`
|
||||
- 维护 provider 与它绑定的 `auth_group`、legacy `api_key`,或留空交给 hook
|
||||
|
||||
页面上会继续提供 3 个帮助入口:
|
||||
|
||||
- `transport`
|
||||
- `source_format`
|
||||
|
||||
+23
-2
@@ -9,13 +9,34 @@ admin:
|
||||
username: admin
|
||||
password: admin
|
||||
|
||||
auth_groups:
|
||||
- name: openai-shared
|
||||
strategy: least_inflight
|
||||
cooldown_seconds_on_429: 60
|
||||
entries:
|
||||
- id: openai-key-a
|
||||
enabled: true
|
||||
headers:
|
||||
Authorization: Bearer sk-your-openai-key-a
|
||||
max_concurrency: 3
|
||||
cooldown_seconds_on_429: 120
|
||||
request_quota_per_minute: 60
|
||||
request_quota_per_day: 3000
|
||||
token_quota_per_minute: 120000
|
||||
token_quota_per_day: 5000000
|
||||
- id: openai-key-b
|
||||
enabled: true
|
||||
headers:
|
||||
Authorization: Bearer sk-your-openai-key-b
|
||||
max_concurrency: 2
|
||||
|
||||
providers:
|
||||
- name: openai-chat
|
||||
api: https://api.openai.com/v1/chat/completions
|
||||
transport: http
|
||||
source_format: openai_chat
|
||||
target_format: openai_chat
|
||||
api_key: sk-your-openai-key
|
||||
auth_group: openai-shared
|
||||
verify_ssl: true
|
||||
timeout_seconds: 1200
|
||||
max_retries: 3
|
||||
@@ -29,7 +50,7 @@ providers:
|
||||
transport: http
|
||||
source_format: openai_responses
|
||||
target_format: openai_chat
|
||||
api_key: sk-your-openai-key
|
||||
auth_group: openai-shared
|
||||
verify_ssl: true
|
||||
timeout_seconds: 1200
|
||||
max_retries: 3
|
||||
|
||||
@@ -13,3 +13,12 @@ providers:
|
||||
verify_ssl: false
|
||||
model_list:
|
||||
- kimi-k2.5
|
||||
auth_groups:
|
||||
- name: a
|
||||
strategy: least_inflight
|
||||
cooldown_seconds_on_429: 60
|
||||
entries:
|
||||
- id: a
|
||||
enabled: true
|
||||
headers:
|
||||
a: a
|
||||
@@ -52,6 +52,10 @@ downstream request
|
||||
- `ProviderManager`
|
||||
- 加载 provider 配置
|
||||
- 维护 `provider/model -> provider` 映射
|
||||
- `AuthGroupManager`
|
||||
- 加载 `auth_groups`
|
||||
- 选择 `auth_entry`
|
||||
- 持久化冷却、禁用与配额运行态
|
||||
- `ProxyService`
|
||||
- 组装整条代理链路
|
||||
- `ExecutorRegistry`
|
||||
@@ -68,6 +72,8 @@ downstream request
|
||||
Hook 组件除了 header / guard,还会收到最小重试上下文:
|
||||
|
||||
- `retry`
|
||||
- `auth_group_name`
|
||||
- `auth_entry_id`
|
||||
- `last_status_code`
|
||||
- `last_error_type`
|
||||
|
||||
@@ -116,6 +122,7 @@ Provider 公共运行时字段只有:
|
||||
- `source_format`
|
||||
- `target_format`
|
||||
- `api_key`
|
||||
- `auth_group`
|
||||
- `proxy`
|
||||
- `timeout_seconds`
|
||||
- `max_retries`
|
||||
@@ -137,6 +144,8 @@ Provider 公共运行时字段只有:
|
||||
Hook 运行时上下文还会暴露最小重试状态:
|
||||
|
||||
- `retry`
|
||||
- `auth_group_name`
|
||||
- `auth_entry_id`
|
||||
- `last_status_code`
|
||||
- `last_error_type`
|
||||
|
||||
|
||||
@@ -8,7 +8,12 @@ from pathlib import Path
|
||||
from flask import request
|
||||
|
||||
from .app_context import AppContext
|
||||
from ..config import ConfigManager, ProviderManager, build_provider_schemas
|
||||
from ..config import (
|
||||
ConfigManager,
|
||||
ProviderManager,
|
||||
build_auth_group_schemas,
|
||||
build_provider_schemas,
|
||||
)
|
||||
from ..presentation import (
|
||||
AuthenticationController,
|
||||
ProviderController,
|
||||
@@ -17,8 +22,9 @@ from ..presentation import (
|
||||
WebController,
|
||||
create_flask_app,
|
||||
)
|
||||
from ..repositories import LogRepository, UserRepository
|
||||
from ..repositories import AuthGroupRepository, LogRepository, UserRepository
|
||||
from ..services import (
|
||||
AuthGroupService,
|
||||
AuthenticationService,
|
||||
LogService,
|
||||
ModelDiscoveryService,
|
||||
@@ -147,10 +153,14 @@ class Application:
|
||||
self._db_connection_factory = create_connection_factory(db_path)
|
||||
self._user_repository = UserRepository(self._db_connection_factory)
|
||||
self._log_repository = LogRepository(self._db_connection_factory)
|
||||
self._auth_group_repository = AuthGroupRepository(self._db_connection_factory)
|
||||
|
||||
def _setup_provider_manager(self) -> None:
|
||||
"""加载 provider 配置并注册可用模型。"""
|
||||
self._provider_manager = ProviderManager(self._ctx)
|
||||
from ..config import AuthGroupManager
|
||||
|
||||
self._auth_group_manager = AuthGroupManager(self._ctx, self._auth_group_repository)
|
||||
self._provider_manager = ProviderManager(self._ctx, self._auth_group_manager)
|
||||
self.reload_providers()
|
||||
|
||||
def _setup_controllers(self) -> None:
|
||||
@@ -158,9 +168,14 @@ class Application:
|
||||
auth_service = AuthenticationService(self._ctx)
|
||||
user_service = UserService(self._ctx, self._user_repository)
|
||||
self._user_service = user_service
|
||||
proxy_service = ProxyService(self._ctx)
|
||||
proxy_service = ProxyService(self._ctx, self._auth_group_manager)
|
||||
log_service = LogService(self._ctx, self._log_repository)
|
||||
provider_service = ProviderService(self._ctx, self.reload_providers)
|
||||
auth_group_service = AuthGroupService(
|
||||
self._ctx,
|
||||
self.reload_providers,
|
||||
self._auth_group_manager,
|
||||
)
|
||||
model_discovery_service = ModelDiscoveryService(self._ctx)
|
||||
settings_service = SettingsService(self._ctx)
|
||||
|
||||
@@ -169,6 +184,7 @@ class Application:
|
||||
self._provider_controller = ProviderController(
|
||||
self._ctx,
|
||||
provider_service,
|
||||
auth_group_service,
|
||||
model_discovery_service,
|
||||
settings_service,
|
||||
auth_service,
|
||||
@@ -187,10 +203,18 @@ class Application:
|
||||
def reload_providers(self) -> None:
|
||||
self._config_manager.reload()
|
||||
config_dict = self._config_manager.get_raw_config()
|
||||
auth_groups_config = config_dict.get("auth_groups", [])
|
||||
if auth_groups_config is None:
|
||||
auth_groups_config = []
|
||||
providers_config = config_dict.get("providers", [])
|
||||
if providers_config is None:
|
||||
providers_config = []
|
||||
provider_schemas = build_provider_schemas(providers_config)
|
||||
auth_group_schemas = build_auth_group_schemas(auth_groups_config)
|
||||
provider_schemas = build_provider_schemas(
|
||||
providers_config,
|
||||
available_auth_group_names={schema.name for schema in auth_group_schemas},
|
||||
)
|
||||
self._auth_group_manager.load_auth_groups(auth_group_schemas)
|
||||
self._provider_manager.load_providers(provider_schemas)
|
||||
|
||||
def run(self) -> None:
|
||||
|
||||
@@ -4,23 +4,38 @@
|
||||
|
||||
from .config_manager import ConfigManager
|
||||
from .provider_config import (
|
||||
AuthEntrySchema,
|
||||
AuthGroupSchema,
|
||||
ProviderConfigSchema,
|
||||
ProviderRuntimeView,
|
||||
RuntimeProviderSpec,
|
||||
build_auth_group_schemas,
|
||||
build_provider_schemas,
|
||||
validate_auth_group_definitions,
|
||||
validate_auth_group_provider_definitions,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConfigManager",
|
||||
"AuthEntrySchema",
|
||||
"AuthGroupSchema",
|
||||
"ProviderConfigSchema",
|
||||
"RuntimeProviderSpec",
|
||||
"ProviderRuntimeView",
|
||||
"build_auth_group_schemas",
|
||||
"build_provider_schemas",
|
||||
"validate_auth_group_definitions",
|
||||
"validate_auth_group_provider_definitions",
|
||||
"AuthGroupManager",
|
||||
"ProviderManager",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "AuthGroupManager":
|
||||
from .auth_group_manager import AuthGroupManager
|
||||
|
||||
return AuthGroupManager
|
||||
if name == "ProviderManager":
|
||||
from .provider_manager import ProviderManager
|
||||
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Auth group runtime manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Any, Dict, Mapping, Optional, Sequence
|
||||
|
||||
from ..application.app_context import AppContext, Logger
|
||||
from ..hooks import HookErrorType
|
||||
from ..repositories import AuthGroupRepository
|
||||
from ..utils.local_time import format_local_datetime, now_local_datetime, parse_local_datetime
|
||||
from .provider_config import (
|
||||
AuthEntrySchema,
|
||||
AuthGroupSchema,
|
||||
DEFAULT_AUTH_GROUP_COOLDOWN_SECONDS_ON_429,
|
||||
DEFAULT_AUTH_GROUP_STRATEGY,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SelectedAuthEntry:
|
||||
"""Resolved auth entry selection for a single upstream attempt."""
|
||||
|
||||
auth_group_name: str
|
||||
entry_id: str
|
||||
headers: tuple[tuple[str, str], ...]
|
||||
legacy_api_key: bool = False
|
||||
|
||||
def headers_mapping(self) -> Dict[str, str]:
|
||||
return dict(self.headers)
|
||||
|
||||
|
||||
class AuthGroupSelectionError(Exception):
|
||||
"""Raised when no usable auth entry can be selected."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status_code: int = 503,
|
||||
error_type: str = "auth_group_unavailable",
|
||||
error_code: str = "auth_group_unavailable",
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.error_type = error_type
|
||||
self.error_code = error_code
|
||||
|
||||
|
||||
class AuthGroupManager:
|
||||
"""Select auth entries and persist their runtime state."""
|
||||
|
||||
def __init__(self, ctx: AppContext, repository: AuthGroupRepository):
|
||||
self._logger: Logger = ctx.logger
|
||||
self._repository = repository
|
||||
self._groups_by_name: Dict[str, AuthGroupSchema] = {}
|
||||
self._explicit_group_names: set[str] = set()
|
||||
self._inflight_counts: Dict[tuple[str, str], int] = {}
|
||||
self._rotation_cursor_by_group: Dict[str, int] = {}
|
||||
|
||||
def load_auth_groups(self, auth_groups: Sequence[AuthGroupSchema]) -> None:
|
||||
self._groups_by_name = {group.name: group for group in auth_groups}
|
||||
self._explicit_group_names = {group.name for group in auth_groups}
|
||||
self._inflight_counts.clear()
|
||||
self._rotation_cursor_by_group.clear()
|
||||
self._logger.info("Loaded auth groups: %s", list(sorted(self._groups_by_name.keys())))
|
||||
|
||||
def register_legacy_provider_group(self, provider_name: str, api_key: str) -> str:
|
||||
group_name = f"__legacy_provider__/{provider_name}"
|
||||
if group_name not in self._groups_by_name:
|
||||
entry = AuthEntrySchema(
|
||||
id="legacy",
|
||||
enabled=True,
|
||||
headers=(("Authorization", f"Bearer {api_key}"),),
|
||||
)
|
||||
self._groups_by_name[group_name] = AuthGroupSchema(
|
||||
name=group_name,
|
||||
strategy=DEFAULT_AUTH_GROUP_STRATEGY,
|
||||
cooldown_seconds_on_429=DEFAULT_AUTH_GROUP_COOLDOWN_SECONDS_ON_429,
|
||||
entries=(entry,),
|
||||
)
|
||||
return group_name
|
||||
|
||||
def acquire(self, auth_group_name: Optional[str]) -> Optional[SelectedAuthEntry]:
|
||||
normalized_group_name = str(auth_group_name or "").strip()
|
||||
if not normalized_group_name:
|
||||
return None
|
||||
|
||||
group = self._groups_by_name.get(normalized_group_name)
|
||||
if group is None:
|
||||
raise AuthGroupSelectionError(
|
||||
f"Unknown auth_group: {normalized_group_name}",
|
||||
error_code="unknown_auth_group",
|
||||
)
|
||||
|
||||
now = now_local_datetime()
|
||||
runtime_states = self._repository.list_group_runtime_states(group.name)
|
||||
usage_by_entry = self._repository.list_current_usage(group.name, (entry.id for entry in group.entries), now)
|
||||
|
||||
candidates: list[tuple[int, int, int, AuthEntrySchema]] = []
|
||||
for index, entry in enumerate(group.entries):
|
||||
runtime_state = runtime_states.get(entry.id) or self._repository.get_entry_runtime_state(group.name, entry.id)
|
||||
usage = usage_by_entry.get(entry.id) or {}
|
||||
if not self._is_entry_available(group, entry, runtime_state, usage, now):
|
||||
continue
|
||||
inflight = self._inflight_counts.get((group.name, entry.id), 0)
|
||||
candidates.append((inflight, self._rotation_distance(group.name, index, len(group.entries)), index, entry))
|
||||
|
||||
if not candidates:
|
||||
raise AuthGroupSelectionError(
|
||||
f"No available auth entries for auth_group: {group.name}",
|
||||
error_code="auth_entry_unavailable",
|
||||
)
|
||||
|
||||
_, _, selected_index, selected_entry = min(candidates, key=lambda item: (item[0], item[1], item[2]))
|
||||
key = (group.name, selected_entry.id)
|
||||
self._inflight_counts[key] = self._inflight_counts.get(key, 0) + 1
|
||||
self._rotation_cursor_by_group[group.name] = (selected_index + 1) % max(len(group.entries), 1)
|
||||
|
||||
return SelectedAuthEntry(
|
||||
auth_group_name=group.name,
|
||||
entry_id=selected_entry.id,
|
||||
headers=selected_entry.headers,
|
||||
legacy_api_key=group.name not in self._explicit_group_names,
|
||||
)
|
||||
|
||||
def mark_request_dispatched(self, selection: Optional[SelectedAuthEntry]) -> None:
|
||||
if selection is None:
|
||||
return
|
||||
self._repository.increment_request_usage(
|
||||
selection.auth_group_name,
|
||||
selection.entry_id,
|
||||
now_local_datetime(),
|
||||
)
|
||||
|
||||
def finish(
|
||||
self,
|
||||
selection: Optional[SelectedAuthEntry],
|
||||
*,
|
||||
status_code: Optional[int] = None,
|
||||
error_type: Optional[HookErrorType] = None,
|
||||
error_message: Optional[str] = None,
|
||||
response_headers: Optional[Mapping[str, Any]] = None,
|
||||
usage: Optional[Mapping[str, Any]] = None,
|
||||
) -> None:
|
||||
if selection is None:
|
||||
return
|
||||
|
||||
key = (selection.auth_group_name, selection.entry_id)
|
||||
current_inflight = self._inflight_counts.get(key, 0)
|
||||
if current_inflight <= 1:
|
||||
self._inflight_counts.pop(key, None)
|
||||
else:
|
||||
self._inflight_counts[key] = current_inflight - 1
|
||||
|
||||
group = self._groups_by_name.get(selection.auth_group_name)
|
||||
if group is None:
|
||||
return
|
||||
entry = self._find_entry(group, selection.entry_id)
|
||||
if entry is None:
|
||||
return
|
||||
|
||||
runtime_state = self._repository.get_entry_runtime_state(group.name, entry.id)
|
||||
disabled = bool(runtime_state.get("disabled"))
|
||||
disabled_reason = runtime_state.get("disabled_reason")
|
||||
cooldown_until = runtime_state.get("cooldown_until")
|
||||
last_status_code: Optional[int] = runtime_state.get("last_status_code")
|
||||
last_error_type: Optional[str] = runtime_state.get("last_error_type")
|
||||
last_error_message: Optional[str] = runtime_state.get("last_error_message")
|
||||
|
||||
if status_code is not None and 200 <= int(status_code) < 400:
|
||||
disabled = False
|
||||
disabled_reason = None
|
||||
cooldown_until = None
|
||||
last_status_code = None
|
||||
last_error_type = None
|
||||
last_error_message = None
|
||||
if usage:
|
||||
prompt_tokens = int(usage.get("prompt_tokens") or 0)
|
||||
completion_tokens = int(usage.get("completion_tokens") or 0)
|
||||
total_tokens = int(usage.get("total_tokens") or 0)
|
||||
if total_tokens > 0 or prompt_tokens > 0 or completion_tokens > 0:
|
||||
self._repository.increment_token_usage(
|
||||
group.name,
|
||||
entry.id,
|
||||
now_local_datetime(),
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
elif status_code == 429:
|
||||
disabled = False
|
||||
disabled_reason = None
|
||||
cooldown_until = format_local_datetime(
|
||||
now_local_datetime()
|
||||
+ timedelta(seconds=self._resolve_cooldown_seconds(group, entry, response_headers))
|
||||
)
|
||||
last_status_code = int(status_code)
|
||||
last_error_type = None
|
||||
last_error_message = error_message
|
||||
elif status_code in {401, 403}:
|
||||
disabled = True
|
||||
disabled_reason = f"http_{status_code}"
|
||||
cooldown_until = None
|
||||
last_status_code = int(status_code)
|
||||
last_error_type = None
|
||||
last_error_message = error_message
|
||||
elif status_code is not None and int(status_code) >= 400:
|
||||
disabled = bool(runtime_state.get("disabled"))
|
||||
disabled_reason = runtime_state.get("disabled_reason")
|
||||
last_status_code = int(status_code)
|
||||
last_error_type = None
|
||||
last_error_message = error_message
|
||||
elif error_type is not None:
|
||||
last_status_code = None
|
||||
last_error_type = error_type.value
|
||||
last_error_message = error_message
|
||||
|
||||
self._repository.save_entry_runtime_state(
|
||||
group.name,
|
||||
entry.id,
|
||||
disabled=disabled,
|
||||
disabled_reason=disabled_reason,
|
||||
cooldown_until=cooldown_until,
|
||||
last_status_code=last_status_code,
|
||||
last_error_type=last_error_type,
|
||||
last_error_message=last_error_message,
|
||||
)
|
||||
|
||||
def list_auth_group_summaries(self) -> list[Dict[str, Any]]:
|
||||
return [self._build_group_summary(group) for group in self.list_explicit_auth_groups()]
|
||||
|
||||
def get_auth_group_runtime(self, auth_group_name: str) -> Dict[str, Any]:
|
||||
group = self._groups_by_name.get(str(auth_group_name).strip())
|
||||
if group is None or group.name not in self._explicit_group_names:
|
||||
raise ValueError(f"Auth group not found: {auth_group_name}")
|
||||
|
||||
now = now_local_datetime()
|
||||
runtime_states = self._repository.list_group_runtime_states(group.name)
|
||||
usage_by_entry = self._repository.list_current_usage(group.name, (entry.id for entry in group.entries), now)
|
||||
entries = [
|
||||
self._build_entry_runtime_view(
|
||||
group,
|
||||
entry,
|
||||
runtime_states.get(entry.id) or self._repository.get_entry_runtime_state(group.name, entry.id),
|
||||
usage_by_entry.get(entry.id) or {},
|
||||
now,
|
||||
)
|
||||
for entry in group.entries
|
||||
]
|
||||
return {
|
||||
"name": group.name,
|
||||
"strategy": group.strategy,
|
||||
"cooldown_seconds_on_429": group.cooldown_seconds_on_429,
|
||||
"summary": self._summarize_entries(entries),
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
def restore_entry(self, auth_group_name: str, entry_id: str) -> None:
|
||||
group = self._groups_by_name.get(str(auth_group_name).strip())
|
||||
if group is None or group.name not in self._explicit_group_names:
|
||||
raise ValueError(f"Auth group not found: {auth_group_name}")
|
||||
if self._find_entry(group, entry_id) is None:
|
||||
raise ValueError(f"Auth entry not found: {entry_id}")
|
||||
self._repository.restore_entry(group.name, entry_id)
|
||||
|
||||
def clear_entry_cooldown(self, auth_group_name: str, entry_id: str) -> None:
|
||||
group, entry = self._resolve_explicit_entry(auth_group_name, entry_id)
|
||||
runtime_state = self._repository.get_entry_runtime_state(group.name, entry.id)
|
||||
self._repository.save_entry_runtime_state(
|
||||
group.name,
|
||||
entry.id,
|
||||
disabled=bool(runtime_state.get("disabled")),
|
||||
disabled_reason=runtime_state.get("disabled_reason"),
|
||||
cooldown_until=None,
|
||||
last_status_code=runtime_state.get("last_status_code"),
|
||||
last_error_type=runtime_state.get("last_error_type"),
|
||||
last_error_message=runtime_state.get("last_error_message"),
|
||||
)
|
||||
|
||||
def set_entry_disabled(self, auth_group_name: str, entry_id: str, *, disabled: bool) -> None:
|
||||
group, entry = self._resolve_explicit_entry(auth_group_name, entry_id)
|
||||
runtime_state = self._repository.get_entry_runtime_state(group.name, entry.id)
|
||||
self._repository.save_entry_runtime_state(
|
||||
group.name,
|
||||
entry.id,
|
||||
disabled=bool(disabled),
|
||||
disabled_reason="manual_disabled" if disabled else None,
|
||||
cooldown_until=runtime_state.get("cooldown_until"),
|
||||
last_status_code=runtime_state.get("last_status_code"),
|
||||
last_error_type=runtime_state.get("last_error_type"),
|
||||
last_error_message=runtime_state.get("last_error_message"),
|
||||
)
|
||||
|
||||
def reset_entry_minute_usage(self, auth_group_name: str, entry_id: str) -> None:
|
||||
group, entry = self._resolve_explicit_entry(auth_group_name, entry_id)
|
||||
self._repository.reset_current_minute_usage(group.name, entry.id, now_local_datetime())
|
||||
|
||||
def reset_entry_runtime(self, auth_group_name: str, entry_id: str) -> None:
|
||||
group, entry = self._resolve_explicit_entry(auth_group_name, entry_id)
|
||||
now = now_local_datetime()
|
||||
self._repository.restore_entry(group.name, entry.id)
|
||||
self._repository.reset_current_minute_usage(group.name, entry.id, now)
|
||||
self._repository.reset_current_day_usage(group.name, entry.id, now)
|
||||
|
||||
def list_explicit_auth_groups(self) -> tuple[AuthGroupSchema, ...]:
|
||||
return tuple(
|
||||
self._groups_by_name[name]
|
||||
for name in sorted(self._explicit_group_names)
|
||||
if name in self._groups_by_name
|
||||
)
|
||||
|
||||
def _build_group_summary(self, group: AuthGroupSchema) -> Dict[str, Any]:
|
||||
runtime = self.get_auth_group_runtime(group.name)
|
||||
return {
|
||||
"name": group.name,
|
||||
"strategy": group.strategy,
|
||||
"cooldown_seconds_on_429": group.cooldown_seconds_on_429,
|
||||
"entries": [entry.to_mapping() for entry in group.entries],
|
||||
"summary": runtime["summary"],
|
||||
}
|
||||
|
||||
def _build_entry_runtime_view(
|
||||
self,
|
||||
group: AuthGroupSchema,
|
||||
entry: AuthEntrySchema,
|
||||
runtime_state: Mapping[str, Any],
|
||||
usage: Mapping[str, Any],
|
||||
now: datetime,
|
||||
) -> Dict[str, Any]:
|
||||
inflight = self._inflight_counts.get((group.name, entry.id), 0)
|
||||
cooldown_until_text = runtime_state.get("cooldown_until")
|
||||
cooldown_until = parse_local_datetime(cooldown_until_text)
|
||||
is_cooling_down = cooldown_until is not None and cooldown_until > now
|
||||
is_disabled = (not entry.enabled) or bool(runtime_state.get("disabled"))
|
||||
is_request_quota_exceeded = self._is_request_quota_exceeded(entry, usage)
|
||||
is_token_quota_exceeded = self._is_token_quota_exceeded(entry, usage)
|
||||
is_saturated = entry.max_concurrency is not None and inflight >= entry.max_concurrency
|
||||
|
||||
if is_disabled:
|
||||
status = "disabled"
|
||||
elif is_cooling_down:
|
||||
status = "cooldown"
|
||||
elif is_request_quota_exceeded or is_token_quota_exceeded:
|
||||
status = "quota_exceeded"
|
||||
elif is_saturated:
|
||||
status = "saturated"
|
||||
else:
|
||||
status = "available"
|
||||
|
||||
return {
|
||||
"id": entry.id,
|
||||
"enabled": bool(entry.enabled),
|
||||
"headers_count": len(entry.headers),
|
||||
"max_concurrency": entry.max_concurrency,
|
||||
"cooldown_seconds_on_429": entry.cooldown_seconds_on_429,
|
||||
"request_quota_per_minute": entry.request_quota_per_minute,
|
||||
"request_quota_per_day": entry.request_quota_per_day,
|
||||
"token_quota_per_minute": entry.token_quota_per_minute,
|
||||
"token_quota_per_day": entry.token_quota_per_day,
|
||||
"inflight": inflight,
|
||||
"cooldown_until": cooldown_until_text,
|
||||
"disabled": is_disabled,
|
||||
"disabled_reason": runtime_state.get("disabled_reason"),
|
||||
"last_status_code": runtime_state.get("last_status_code"),
|
||||
"last_error_type": runtime_state.get("last_error_type"),
|
||||
"last_error_message": runtime_state.get("last_error_message"),
|
||||
"updated_at": runtime_state.get("updated_at"),
|
||||
"minute_request_count": int(usage.get("minute_request_count") or 0),
|
||||
"day_request_count": int(usage.get("day_request_count") or 0),
|
||||
"minute_prompt_tokens": int(usage.get("minute_prompt_tokens") or 0),
|
||||
"day_prompt_tokens": int(usage.get("day_prompt_tokens") or 0),
|
||||
"minute_completion_tokens": int(usage.get("minute_completion_tokens") or 0),
|
||||
"day_completion_tokens": int(usage.get("day_completion_tokens") or 0),
|
||||
"minute_total_tokens": int(usage.get("minute_total_tokens") or 0),
|
||||
"day_total_tokens": int(usage.get("day_total_tokens") or 0),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _summarize_entries(entries: Sequence[Mapping[str, Any]]) -> Dict[str, int]:
|
||||
summary = {
|
||||
"entry_count": len(entries),
|
||||
"available_count": 0,
|
||||
"cooldown_count": 0,
|
||||
"disabled_count": 0,
|
||||
}
|
||||
for entry in entries:
|
||||
status = str(entry.get("status") or "")
|
||||
if status == "available":
|
||||
summary["available_count"] += 1
|
||||
elif status == "cooldown":
|
||||
summary["cooldown_count"] += 1
|
||||
elif status == "disabled":
|
||||
summary["disabled_count"] += 1
|
||||
return summary
|
||||
|
||||
def _is_entry_available(
|
||||
self,
|
||||
group: AuthGroupSchema,
|
||||
entry: AuthEntrySchema,
|
||||
runtime_state: Mapping[str, Any],
|
||||
usage: Mapping[str, Any],
|
||||
now: datetime,
|
||||
) -> bool:
|
||||
if not entry.enabled:
|
||||
return False
|
||||
if bool(runtime_state.get("disabled")):
|
||||
return False
|
||||
cooldown_until = parse_local_datetime(runtime_state.get("cooldown_until"))
|
||||
if cooldown_until is not None and cooldown_until > now:
|
||||
return False
|
||||
inflight = self._inflight_counts.get((group.name, entry.id), 0)
|
||||
if entry.max_concurrency is not None and inflight >= entry.max_concurrency:
|
||||
return False
|
||||
if self._is_request_quota_exceeded(entry, usage):
|
||||
return False
|
||||
if self._is_token_quota_exceeded(entry, usage):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _is_request_quota_exceeded(entry: AuthEntrySchema, usage: Mapping[str, Any]) -> bool:
|
||||
minute_requests = int(usage.get("minute_request_count") or 0)
|
||||
day_requests = int(usage.get("day_request_count") or 0)
|
||||
if entry.request_quota_per_minute is not None and minute_requests >= entry.request_quota_per_minute:
|
||||
return True
|
||||
if entry.request_quota_per_day is not None and day_requests >= entry.request_quota_per_day:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_token_quota_exceeded(entry: AuthEntrySchema, usage: Mapping[str, Any]) -> bool:
|
||||
minute_tokens = int(usage.get("minute_total_tokens") or 0)
|
||||
day_tokens = int(usage.get("day_total_tokens") or 0)
|
||||
if entry.token_quota_per_minute is not None and minute_tokens >= entry.token_quota_per_minute:
|
||||
return True
|
||||
if entry.token_quota_per_day is not None and day_tokens >= entry.token_quota_per_day:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _resolve_cooldown_seconds(
|
||||
self,
|
||||
group: AuthGroupSchema,
|
||||
entry: AuthEntrySchema,
|
||||
response_headers: Optional[Mapping[str, Any]],
|
||||
) -> int:
|
||||
retry_after_seconds = self._parse_retry_after_seconds(response_headers)
|
||||
if retry_after_seconds is not None and retry_after_seconds > 0:
|
||||
return retry_after_seconds
|
||||
if entry.cooldown_seconds_on_429 is not None:
|
||||
return entry.cooldown_seconds_on_429
|
||||
return group.cooldown_seconds_on_429
|
||||
|
||||
@staticmethod
|
||||
def _parse_retry_after_seconds(response_headers: Optional[Mapping[str, Any]]) -> Optional[int]:
|
||||
if not response_headers:
|
||||
return None
|
||||
retry_after_value: Optional[Any] = None
|
||||
for key, value in response_headers.items():
|
||||
if str(key).lower() == "retry-after":
|
||||
retry_after_value = value
|
||||
break
|
||||
if retry_after_value in (None, ""):
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed_seconds = int(str(retry_after_value).strip())
|
||||
return parsed_seconds if parsed_seconds > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
try:
|
||||
retry_after_datetime = parsedate_to_datetime(str(retry_after_value).strip())
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return None
|
||||
if retry_after_datetime.tzinfo is None:
|
||||
retry_after_datetime = retry_after_datetime.replace(tzinfo=timezone.utc)
|
||||
delta_seconds = int((retry_after_datetime - datetime.now(timezone.utc)).total_seconds())
|
||||
return delta_seconds if delta_seconds > 0 else None
|
||||
|
||||
def _rotation_distance(self, group_name: str, index: int, size: int) -> int:
|
||||
cursor = self._rotation_cursor_by_group.get(group_name, 0)
|
||||
return (index - cursor) % max(size, 1)
|
||||
|
||||
@staticmethod
|
||||
def _find_entry(group: AuthGroupSchema, entry_id: str) -> Optional[AuthEntrySchema]:
|
||||
normalized_entry_id = str(entry_id).strip()
|
||||
for entry in group.entries:
|
||||
if entry.id == normalized_entry_id:
|
||||
return entry
|
||||
return None
|
||||
|
||||
def _resolve_explicit_entry(self, auth_group_name: str, entry_id: str) -> tuple[AuthGroupSchema, AuthEntrySchema]:
|
||||
group = self._groups_by_name.get(str(auth_group_name).strip())
|
||||
if group is None or group.name not in self._explicit_group_names:
|
||||
raise ValueError(f"Auth group not found: {auth_group_name}")
|
||||
entry = self._find_entry(group, entry_id)
|
||||
if entry is None:
|
||||
raise ValueError(f"Auth entry not found: {entry_id}")
|
||||
return group, entry
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Provider config schema, factory helpers, and validation."""
|
||||
"""Provider and auth group config schema, factory helpers, and validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from ..utils.net import normalize_proxy_url
|
||||
@@ -16,6 +16,8 @@ DEFAULT_PROVIDER_VERIFY_SSL = False
|
||||
DEFAULT_PROVIDER_TRANSPORT = "http"
|
||||
DEFAULT_PROVIDER_SOURCE_FORMAT = "openai_chat"
|
||||
DEFAULT_PROVIDER_TARGET_FORMAT = "openai_chat"
|
||||
DEFAULT_AUTH_GROUP_STRATEGY = "least_inflight"
|
||||
DEFAULT_AUTH_GROUP_COOLDOWN_SECONDS_ON_429 = 60
|
||||
SUPPORTED_PROVIDER_TRANSPORTS = {"http", "websocket"}
|
||||
SUPPORTED_PROVIDER_PROTOCOLS = {
|
||||
"openai_chat",
|
||||
@@ -24,6 +26,7 @@ SUPPORTED_PROVIDER_PROTOCOLS = {
|
||||
"codex",
|
||||
}
|
||||
SUPPORTED_PROVIDER_API_SCHEMES = {"http", "https", "ws", "wss"}
|
||||
SUPPORTED_AUTH_GROUP_STRATEGIES = {DEFAULT_AUTH_GROUP_STRATEGY}
|
||||
SUPPORTED_PROVIDER_FIELDS = {
|
||||
"name",
|
||||
"api",
|
||||
@@ -31,6 +34,7 @@ SUPPORTED_PROVIDER_FIELDS = {
|
||||
"source_format",
|
||||
"target_format",
|
||||
"api_key",
|
||||
"auth_group",
|
||||
"proxy",
|
||||
"timeout_seconds",
|
||||
"max_retries",
|
||||
@@ -38,6 +42,23 @@ SUPPORTED_PROVIDER_FIELDS = {
|
||||
"model_list",
|
||||
"hook",
|
||||
}
|
||||
SUPPORTED_AUTH_GROUP_FIELDS = {
|
||||
"name",
|
||||
"strategy",
|
||||
"cooldown_seconds_on_429",
|
||||
"entries",
|
||||
}
|
||||
SUPPORTED_AUTH_ENTRY_FIELDS = {
|
||||
"id",
|
||||
"enabled",
|
||||
"headers",
|
||||
"max_concurrency",
|
||||
"cooldown_seconds_on_429",
|
||||
"request_quota_per_minute",
|
||||
"request_quota_per_day",
|
||||
"token_quota_per_minute",
|
||||
"token_quota_per_day",
|
||||
}
|
||||
|
||||
|
||||
def clean_optional_string(value: Any) -> Optional[str]:
|
||||
@@ -117,6 +138,29 @@ def normalize_model_list(value: Any) -> List[str]:
|
||||
return models
|
||||
|
||||
|
||||
def normalize_headers(value: Any) -> Dict[str, str]:
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("headers must be an object")
|
||||
|
||||
headers: Dict[str, str] = {}
|
||||
seen_names: set[str] = set()
|
||||
for raw_key, raw_value in value.items():
|
||||
header_name = clean_optional_string(raw_key)
|
||||
header_value = clean_optional_string(raw_value)
|
||||
if header_name is None:
|
||||
raise ValueError("header name must not be empty")
|
||||
if header_value is None:
|
||||
raise ValueError(f"header value for {header_name} must not be empty")
|
||||
normalized_name = header_name.lower()
|
||||
if normalized_name in seen_names:
|
||||
raise ValueError(f"duplicate header detected: {header_name}")
|
||||
seen_names.add(normalized_name)
|
||||
headers[header_name] = header_value
|
||||
return headers
|
||||
|
||||
|
||||
def resolve_provider_transport(api: str, transport: Any = None) -> str:
|
||||
scheme = urlparse(api).scheme.lower()
|
||||
if scheme not in SUPPORTED_PROVIDER_API_SCHEMES:
|
||||
@@ -151,12 +195,172 @@ def resolve_provider_protocol(
|
||||
return resolved
|
||||
|
||||
|
||||
def resolve_auth_group_strategy(value: Any) -> str:
|
||||
resolved = (clean_optional_string(value) or DEFAULT_AUTH_GROUP_STRATEGY).lower()
|
||||
if resolved not in SUPPORTED_AUTH_GROUP_STRATEGIES:
|
||||
supported = ", ".join(sorted(SUPPORTED_AUTH_GROUP_STRATEGIES))
|
||||
raise ValueError(f"Auth group strategy must be one of: {supported}")
|
||||
return resolved
|
||||
|
||||
|
||||
def validate_provider_fields(config: Mapping[str, Any]) -> None:
|
||||
unknown_fields = sorted(str(key) for key in config.keys() if str(key) not in SUPPORTED_PROVIDER_FIELDS)
|
||||
if unknown_fields:
|
||||
raise ValueError(f"Unsupported provider field(s): {', '.join(unknown_fields)}")
|
||||
|
||||
|
||||
def validate_auth_group_fields(config: Mapping[str, Any]) -> None:
|
||||
unknown_fields = sorted(str(key) for key in config.keys() if str(key) not in SUPPORTED_AUTH_GROUP_FIELDS)
|
||||
if unknown_fields:
|
||||
raise ValueError(f"Unsupported auth_group field(s): {', '.join(unknown_fields)}")
|
||||
|
||||
|
||||
def validate_auth_entry_fields(config: Mapping[str, Any]) -> None:
|
||||
unknown_fields = sorted(str(key) for key in config.keys() if str(key) not in SUPPORTED_AUTH_ENTRY_FIELDS)
|
||||
if unknown_fields:
|
||||
raise ValueError(f"Unsupported auth_entry field(s): {', '.join(unknown_fields)}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthEntrySchema:
|
||||
"""Normalized auth entry configuration."""
|
||||
|
||||
id: str
|
||||
enabled: bool = True
|
||||
headers: tuple[tuple[str, str], ...] = ()
|
||||
max_concurrency: Optional[int] = None
|
||||
cooldown_seconds_on_429: Optional[int] = None
|
||||
request_quota_per_minute: Optional[int] = None
|
||||
request_quota_per_day: Optional[int] = None
|
||||
token_quota_per_minute: Optional[int] = None
|
||||
token_quota_per_day: Optional[int] = None
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, config: Mapping[str, Any]) -> "AuthEntrySchema":
|
||||
if not isinstance(config, Mapping):
|
||||
raise ValueError("Auth entry config must be an object")
|
||||
|
||||
validate_auth_entry_fields(config)
|
||||
entry_id = clean_optional_string(config.get("id"))
|
||||
if entry_id is None:
|
||||
raise ValueError("Auth entry id is required")
|
||||
|
||||
headers = normalize_headers(config.get("headers"))
|
||||
if not headers:
|
||||
raise ValueError(f"Auth entry '{entry_id}' must define at least one header")
|
||||
|
||||
return cls(
|
||||
id=entry_id,
|
||||
enabled=parse_optional_bool(
|
||||
config.get("enabled"),
|
||||
default=True,
|
||||
error_message="Auth entry enabled must be a boolean value",
|
||||
)
|
||||
is not False,
|
||||
headers=tuple(headers.items()),
|
||||
max_concurrency=parse_optional_positive_int(
|
||||
config.get("max_concurrency"),
|
||||
error_message="Auth entry max_concurrency must be a positive integer",
|
||||
),
|
||||
cooldown_seconds_on_429=parse_optional_positive_int(
|
||||
config.get("cooldown_seconds_on_429"),
|
||||
error_message="Auth entry cooldown_seconds_on_429 must be a positive integer",
|
||||
),
|
||||
request_quota_per_minute=parse_optional_positive_int(
|
||||
config.get("request_quota_per_minute"),
|
||||
error_message="Auth entry request_quota_per_minute must be a positive integer",
|
||||
),
|
||||
request_quota_per_day=parse_optional_positive_int(
|
||||
config.get("request_quota_per_day"),
|
||||
error_message="Auth entry request_quota_per_day must be a positive integer",
|
||||
),
|
||||
token_quota_per_minute=parse_optional_positive_int(
|
||||
config.get("token_quota_per_minute"),
|
||||
error_message="Auth entry token_quota_per_minute must be a positive integer",
|
||||
),
|
||||
token_quota_per_day=parse_optional_positive_int(
|
||||
config.get("token_quota_per_day"),
|
||||
error_message="Auth entry token_quota_per_day must be a positive integer",
|
||||
),
|
||||
)
|
||||
|
||||
def to_mapping(self) -> Dict[str, Any]:
|
||||
config: Dict[str, Any] = {
|
||||
"id": self.id,
|
||||
"enabled": bool(self.enabled),
|
||||
"headers": dict(self.headers),
|
||||
}
|
||||
if self.max_concurrency is not None:
|
||||
config["max_concurrency"] = self.max_concurrency
|
||||
if self.cooldown_seconds_on_429 is not None:
|
||||
config["cooldown_seconds_on_429"] = self.cooldown_seconds_on_429
|
||||
if self.request_quota_per_minute is not None:
|
||||
config["request_quota_per_minute"] = self.request_quota_per_minute
|
||||
if self.request_quota_per_day is not None:
|
||||
config["request_quota_per_day"] = self.request_quota_per_day
|
||||
if self.token_quota_per_minute is not None:
|
||||
config["token_quota_per_minute"] = self.token_quota_per_minute
|
||||
if self.token_quota_per_day is not None:
|
||||
config["token_quota_per_day"] = self.token_quota_per_day
|
||||
return config
|
||||
|
||||
def headers_mapping(self) -> Dict[str, str]:
|
||||
return dict(self.headers)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthGroupSchema:
|
||||
"""Normalized auth group configuration."""
|
||||
|
||||
name: str
|
||||
strategy: str = DEFAULT_AUTH_GROUP_STRATEGY
|
||||
cooldown_seconds_on_429: int = DEFAULT_AUTH_GROUP_COOLDOWN_SECONDS_ON_429
|
||||
entries: tuple[AuthEntrySchema, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, config: Mapping[str, Any]) -> "AuthGroupSchema":
|
||||
if not isinstance(config, Mapping):
|
||||
raise ValueError("Auth group config must be an object")
|
||||
|
||||
validate_auth_group_fields(config)
|
||||
name = clean_optional_string(config.get("name"))
|
||||
if name is None:
|
||||
raise ValueError("Auth group name is required")
|
||||
|
||||
raw_entries = config.get("entries")
|
||||
if not isinstance(raw_entries, list) or not raw_entries:
|
||||
raise ValueError(f"Auth group '{name}' must define a non-empty entries list")
|
||||
|
||||
seen_entry_ids: set[str] = set()
|
||||
entries: List[AuthEntrySchema] = []
|
||||
for index, raw_entry in enumerate(raw_entries):
|
||||
entry = AuthEntrySchema.from_mapping(raw_entry)
|
||||
if entry.id in seen_entry_ids:
|
||||
raise ValueError(f"Duplicate auth entry id detected in group '{name}': {entry.id}")
|
||||
seen_entry_ids.add(entry.id)
|
||||
entries.append(entry)
|
||||
|
||||
return cls(
|
||||
name=name,
|
||||
strategy=resolve_auth_group_strategy(config.get("strategy")),
|
||||
cooldown_seconds_on_429=parse_optional_positive_int(
|
||||
config.get("cooldown_seconds_on_429"),
|
||||
default=DEFAULT_AUTH_GROUP_COOLDOWN_SECONDS_ON_429,
|
||||
error_message="Auth group cooldown_seconds_on_429 must be a positive integer",
|
||||
)
|
||||
or DEFAULT_AUTH_GROUP_COOLDOWN_SECONDS_ON_429,
|
||||
entries=tuple(entries),
|
||||
)
|
||||
|
||||
def to_mapping(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"strategy": self.strategy,
|
||||
"cooldown_seconds_on_429": self.cooldown_seconds_on_429,
|
||||
"entries": [entry.to_mapping() for entry in self.entries],
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProviderConfigSchema:
|
||||
"""Normalized provider configuration schema."""
|
||||
@@ -167,6 +371,7 @@ class ProviderConfigSchema:
|
||||
source_format: str = DEFAULT_PROVIDER_SOURCE_FORMAT
|
||||
target_format: str = DEFAULT_PROVIDER_TARGET_FORMAT
|
||||
api_key: Optional[str] = None
|
||||
auth_group: Optional[str] = None
|
||||
proxy: Optional[str] = None
|
||||
timeout_seconds: Optional[int] = None
|
||||
max_retries: Optional[int] = None
|
||||
@@ -191,10 +396,14 @@ class ProviderConfigSchema:
|
||||
|
||||
name = clean_optional_string(config.get("name"))
|
||||
api = clean_optional_string(config.get("api"))
|
||||
api_key = clean_optional_string(config.get("api_key"))
|
||||
auth_group = clean_optional_string(config.get("auth_group"))
|
||||
if name is None:
|
||||
raise ValueError("Provider name is required")
|
||||
if api is None:
|
||||
raise ValueError("Provider api is required")
|
||||
if api_key and auth_group:
|
||||
raise ValueError("Provider must define either auth_group or api_key, not both")
|
||||
|
||||
return cls(
|
||||
name=name,
|
||||
@@ -210,7 +419,8 @@ class ProviderConfigSchema:
|
||||
default=DEFAULT_PROVIDER_TARGET_FORMAT,
|
||||
field_name="target_format",
|
||||
),
|
||||
api_key=clean_optional_string(config.get("api_key")),
|
||||
api_key=api_key,
|
||||
auth_group=auth_group,
|
||||
proxy=normalize_proxy_url(config.get("proxy")),
|
||||
timeout_seconds=parse_optional_positive_int(config.get("timeout_seconds")),
|
||||
max_retries=parse_optional_positive_int(config.get("max_retries")),
|
||||
@@ -230,6 +440,8 @@ class ProviderConfigSchema:
|
||||
|
||||
if self.api_key is not None:
|
||||
config["api_key"] = self.api_key
|
||||
if self.auth_group is not None:
|
||||
config["auth_group"] = self.auth_group
|
||||
if self.proxy is not None:
|
||||
config["proxy"] = self.proxy
|
||||
if self.timeout_seconds is not None:
|
||||
@@ -256,6 +468,7 @@ class RuntimeProviderSpec:
|
||||
source_format: str
|
||||
target_format: str
|
||||
api_key: str
|
||||
auth_group: Optional[str]
|
||||
model_list: tuple[str, ...]
|
||||
proxy: Optional[str]
|
||||
timeout_seconds: int
|
||||
@@ -276,6 +489,7 @@ class RuntimeProviderSpec:
|
||||
source_format=config.source_format,
|
||||
target_format=config.target_format,
|
||||
api_key=config.api_key or "",
|
||||
auth_group=config.auth_group,
|
||||
model_list=config.model_list,
|
||||
proxy=config.proxy,
|
||||
timeout_seconds=config.timeout_seconds or DEFAULT_PROVIDER_TIMEOUT_SECONDS,
|
||||
@@ -298,6 +512,8 @@ class ProviderRuntimeView:
|
||||
transport: str
|
||||
source_format: str
|
||||
target_format: str
|
||||
auth_group: Optional[str]
|
||||
legacy_api_key: bool
|
||||
model_list: tuple[str, ...]
|
||||
proxy: Optional[str]
|
||||
timeout_seconds: int
|
||||
@@ -306,13 +522,15 @@ class ProviderRuntimeView:
|
||||
hook: Optional[str]
|
||||
|
||||
@classmethod
|
||||
def from_spec(cls, spec: RuntimeProviderSpec) -> "ProviderRuntimeView":
|
||||
def from_spec(cls, spec: RuntimeProviderSpec, *, legacy_api_key: bool = False) -> "ProviderRuntimeView":
|
||||
return cls(
|
||||
name=spec.name,
|
||||
api=spec.api,
|
||||
transport=spec.transport,
|
||||
source_format=spec.source_format,
|
||||
target_format=spec.target_format,
|
||||
auth_group=spec.auth_group,
|
||||
legacy_api_key=legacy_api_key,
|
||||
model_list=spec.model_list,
|
||||
proxy=spec.proxy,
|
||||
timeout_seconds=spec.timeout_seconds,
|
||||
@@ -322,9 +540,31 @@ class ProviderRuntimeView:
|
||||
)
|
||||
|
||||
|
||||
def build_auth_group_schemas(
|
||||
auth_groups: Sequence[Mapping[str, Any]],
|
||||
) -> tuple[AuthGroupSchema, ...]:
|
||||
seen_names: set[str] = set()
|
||||
schemas: List[AuthGroupSchema] = []
|
||||
|
||||
for index, auth_group in enumerate(auth_groups):
|
||||
if not isinstance(auth_group, Mapping):
|
||||
raise ValueError(f"Auth group entry at index {index} must be an object")
|
||||
|
||||
schema = AuthGroupSchema.from_mapping(auth_group)
|
||||
if schema.name in seen_names:
|
||||
raise ValueError(f"Duplicate auth group name detected: {schema.name}")
|
||||
seen_names.add(schema.name)
|
||||
schemas.append(schema)
|
||||
|
||||
return tuple(schemas)
|
||||
|
||||
|
||||
def build_provider_schemas(
|
||||
providers: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
available_auth_group_names: Optional[Iterable[str]] = None,
|
||||
) -> tuple[ProviderConfigSchema, ...]:
|
||||
auth_group_names = set(available_auth_group_names or ())
|
||||
seen_names: set[str] = set()
|
||||
seen_model_keys: set[str] = set()
|
||||
schemas: List[ProviderConfigSchema] = []
|
||||
@@ -336,6 +576,12 @@ def build_provider_schemas(
|
||||
schema = ProviderConfigSchema.from_mapping(provider)
|
||||
if schema.name in seen_names:
|
||||
raise ValueError(f"Duplicate provider name detected: {schema.name}")
|
||||
if (
|
||||
schema.auth_group
|
||||
and available_auth_group_names is not None
|
||||
and schema.auth_group not in auth_group_names
|
||||
):
|
||||
raise ValueError(f"Provider references unknown auth_group: {schema.auth_group}")
|
||||
seen_names.add(schema.name)
|
||||
|
||||
for model in schema.model_list:
|
||||
@@ -349,5 +595,20 @@ def build_provider_schemas(
|
||||
return tuple(schemas)
|
||||
|
||||
|
||||
def validate_auth_group_definitions(auth_groups: Sequence[Mapping[str, Any]]) -> None:
|
||||
build_auth_group_schemas(auth_groups)
|
||||
|
||||
|
||||
def validate_provider_definitions(providers: Sequence[Mapping[str, Any]]) -> None:
|
||||
build_provider_schemas(providers)
|
||||
|
||||
|
||||
def validate_auth_group_provider_definitions(
|
||||
auth_groups: Sequence[Mapping[str, Any]],
|
||||
providers: Sequence[Mapping[str, Any]],
|
||||
) -> None:
|
||||
auth_group_schemas = build_auth_group_schemas(auth_groups)
|
||||
build_provider_schemas(
|
||||
providers,
|
||||
available_auth_group_names={schema.name for schema in auth_group_schemas},
|
||||
)
|
||||
@@ -6,21 +6,24 @@ import hashlib
|
||||
import importlib.util
|
||||
import inspect
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Sequence
|
||||
|
||||
from ..application.app_context import AppContext, Logger
|
||||
from ..external import LLMProvider
|
||||
from ..hooks import HookModule
|
||||
from .auth_group_manager import AuthGroupManager
|
||||
from .provider_config import ProviderConfigSchema, ProviderRuntimeView, RuntimeProviderSpec
|
||||
|
||||
|
||||
class ProviderManager:
|
||||
"""管理模型到 provider 的运行时映射关系。"""
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
def __init__(self, ctx: AppContext, auth_group_manager: AuthGroupManager):
|
||||
self._base_dir = ctx.root_path.resolve()
|
||||
self._logger: Logger = ctx.logger
|
||||
self._auth_group_manager = auth_group_manager
|
||||
self._provider_by_model: Dict[str, LLMProvider] = {}
|
||||
self._provider_by_name: Dict[str, LLMProvider] = {}
|
||||
self._provider_views_by_name: Dict[str, ProviderRuntimeView] = {}
|
||||
@@ -69,31 +72,50 @@ class ProviderManager:
|
||||
self._logger.warning("Provider '%s' skipped: model_list is empty", spec.name)
|
||||
return
|
||||
|
||||
legacy_auth_group_name: Optional[str] = None
|
||||
resolved_auth_group = spec.auth_group
|
||||
if resolved_auth_group is None and spec.api_key:
|
||||
legacy_auth_group_name = self._auth_group_manager.register_legacy_provider_group(
|
||||
spec.name,
|
||||
spec.api_key,
|
||||
)
|
||||
resolved_auth_group = legacy_auth_group_name
|
||||
runtime_spec = replace(spec, auth_group=resolved_auth_group)
|
||||
|
||||
provider = LLMProvider(
|
||||
name=spec.name,
|
||||
api=spec.api,
|
||||
transport=spec.transport,
|
||||
source_format=spec.source_format,
|
||||
target_format=spec.target_format,
|
||||
name=runtime_spec.name,
|
||||
api=runtime_spec.api,
|
||||
transport=runtime_spec.transport,
|
||||
source_format=runtime_spec.source_format,
|
||||
target_format=runtime_spec.target_format,
|
||||
api_key=spec.api_key,
|
||||
model_list=spec.model_list,
|
||||
proxy=spec.proxy,
|
||||
timeout_seconds=spec.timeout_seconds,
|
||||
max_retries=spec.max_retries,
|
||||
verify_ssl=spec.verify_ssl,
|
||||
hook=self._load_hook(spec.hook),
|
||||
auth_group=runtime_spec.auth_group,
|
||||
model_list=runtime_spec.model_list,
|
||||
proxy=runtime_spec.proxy,
|
||||
timeout_seconds=runtime_spec.timeout_seconds,
|
||||
max_retries=runtime_spec.max_retries,
|
||||
verify_ssl=runtime_spec.verify_ssl,
|
||||
hook=self._load_hook(runtime_spec.hook),
|
||||
)
|
||||
|
||||
self._provider_by_name[spec.name] = provider
|
||||
self._provider_views_by_name[spec.name] = ProviderRuntimeView.from_spec(spec)
|
||||
self._provider_by_name[runtime_spec.name] = provider
|
||||
self._provider_views_by_name[runtime_spec.name] = ProviderRuntimeView.from_spec(
|
||||
runtime_spec,
|
||||
legacy_api_key=legacy_auth_group_name is not None,
|
||||
)
|
||||
|
||||
for model in spec.model_list:
|
||||
model_key = f"{spec.name}/{model}"
|
||||
for model in runtime_spec.model_list:
|
||||
model_key = f"{runtime_spec.name}/{model}"
|
||||
if model_key in self._provider_by_model:
|
||||
raise ValueError(f"Duplicate provider model mapping detected: {model_key}")
|
||||
self._provider_by_model[model_key] = provider
|
||||
|
||||
self._logger.info("Loaded provider: %s, models: %s", spec.name, list(spec.model_list))
|
||||
self._logger.info(
|
||||
"Loaded provider: %s, models: %s, auth_group=%s",
|
||||
runtime_spec.name,
|
||||
list(runtime_spec.model_list),
|
||||
runtime_spec.auth_group or "<none>",
|
||||
)
|
||||
|
||||
def _load_hook(self, hook_path: Optional[str]) -> Optional[HookModule]:
|
||||
"""按路径加载 hook 模块,并做缓存复用。"""
|
||||
|
||||
Vendored
+1
@@ -18,6 +18,7 @@ class LLMProvider:
|
||||
source_format: str = "openai_chat"
|
||||
target_format: str = "openai_chat"
|
||||
api_key: Optional[str] = None
|
||||
auth_group: Optional[str] = None
|
||||
model_list: tuple[str, ...] = ()
|
||||
proxy: Optional[str] = None
|
||||
timeout_seconds: int = 1200
|
||||
|
||||
@@ -43,6 +43,8 @@ class HookContext:
|
||||
provider_target_format: str = "openai_chat"
|
||||
transport: str = "http"
|
||||
stream: bool = False
|
||||
auth_group_name: Optional[str] = None
|
||||
auth_entry_id: Optional[str] = None
|
||||
last_status_code: Optional[int] = None
|
||||
last_error_type: Optional[HookErrorType] = None
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from flask import Response, jsonify, request
|
||||
|
||||
from ..application.app_context import AppContext
|
||||
from ..services import (
|
||||
AuthGroupService,
|
||||
AuthenticationService,
|
||||
ModelDiscoveryService,
|
||||
ProviderService,
|
||||
@@ -21,6 +22,7 @@ class ProviderController:
|
||||
self,
|
||||
ctx: AppContext,
|
||||
provider_service: ProviderService,
|
||||
auth_group_service: AuthGroupService,
|
||||
model_discovery_service: ModelDiscoveryService,
|
||||
settings_service: SettingsService,
|
||||
auth_service: AuthenticationService,
|
||||
@@ -28,6 +30,7 @@ class ProviderController:
|
||||
self._app = ctx.flask_app
|
||||
self._logger = ctx.logger
|
||||
self._provider_service = provider_service
|
||||
self._auth_group_service = auth_group_service
|
||||
self._model_discovery_service = model_discovery_service
|
||||
self._settings_service = settings_service
|
||||
self._auth_service = auth_service
|
||||
@@ -43,6 +46,31 @@ class ProviderController:
|
||||
self._app.route('/api/providers/<string:name>', methods=['GET'])(auth(self.get_provider))
|
||||
self._app.route('/api/providers/<string:name>', methods=['PUT'])(auth(self.update_provider))
|
||||
self._app.route('/api/providers/<string:name>', methods=['DELETE'])(auth(self.delete_provider))
|
||||
self._app.route('/api/auth-groups', methods=['GET'])(auth(self.get_auth_groups))
|
||||
self._app.route('/api/auth-groups', methods=['POST'])(auth(self.create_auth_group))
|
||||
self._app.route('/api/auth-groups/import-entries', methods=['POST'])(auth(self.import_auth_group_entries))
|
||||
self._app.route('/api/auth-groups/<string:name>', methods=['GET'])(auth(self.get_auth_group))
|
||||
self._app.route('/api/auth-groups/<string:name>', methods=['PUT'])(auth(self.update_auth_group))
|
||||
self._app.route('/api/auth-groups/<string:name>', methods=['DELETE'])(auth(self.delete_auth_group))
|
||||
self._app.route('/api/auth-groups/<string:name>/runtime', methods=['GET'])(auth(self.get_auth_group_runtime))
|
||||
self._app.route('/api/auth-groups/<string:name>/entries/<string:entry_id>/clear-cooldown', methods=['POST'])(
|
||||
auth(self.clear_auth_group_entry_cooldown)
|
||||
)
|
||||
self._app.route('/api/auth-groups/<string:name>/entries/<string:entry_id>/disable', methods=['POST'])(
|
||||
auth(self.disable_auth_group_entry)
|
||||
)
|
||||
self._app.route('/api/auth-groups/<string:name>/entries/<string:entry_id>/enable', methods=['POST'])(
|
||||
auth(self.enable_auth_group_entry)
|
||||
)
|
||||
self._app.route('/api/auth-groups/<string:name>/entries/<string:entry_id>/reset-minute-usage', methods=['POST'])(
|
||||
auth(self.reset_auth_group_entry_minute_usage)
|
||||
)
|
||||
self._app.route('/api/auth-groups/<string:name>/entries/<string:entry_id>/reset', methods=['POST'])(
|
||||
auth(self.reset_auth_group_entry_runtime)
|
||||
)
|
||||
self._app.route('/api/auth-groups/<string:name>/entries/<string:entry_id>/restore', methods=['POST'])(
|
||||
auth(self.restore_auth_group_entry)
|
||||
)
|
||||
|
||||
def get_providers(self) -> Response:
|
||||
try:
|
||||
@@ -100,6 +128,160 @@ class ProviderController:
|
||||
self._logger.error('Error deleting provider: %s', exc)
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def get_auth_groups(self) -> Response:
|
||||
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:
|
||||
try:
|
||||
auth_group = self._auth_group_service.get_auth_group(name)
|
||||
if auth_group is None:
|
||||
return jsonify({'error': 'Auth group not found'}), 404
|
||||
return jsonify(auth_group)
|
||||
except Exception as exc:
|
||||
self._logger.error('Error getting auth group: %s', exc)
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def create_auth_group(self) -> Response:
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
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
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except Exception as exc:
|
||||
self._logger.error('Error creating auth group: %s', exc)
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def import_auth_group_entries(self) -> Response:
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
entries = self._auth_group_service.import_auth_entries(payload.get('yaml', ''))
|
||||
return jsonify({'entries': entries})
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except Exception as exc:
|
||||
self._logger.error('Error importing auth group entries: %s', exc)
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def update_auth_group(self, name: str) -> Response:
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
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)
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
status_code = 404 if 'not found' in message.lower() else 400
|
||||
return jsonify({'error': message}), status_code
|
||||
except Exception as exc:
|
||||
self._logger.error('Error updating auth group: %s', exc)
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def delete_auth_group(self, name: str) -> Response:
|
||||
try:
|
||||
self._auth_group_service.delete_auth_group(name)
|
||||
self._logger.info('Auth group deleted: %s', name)
|
||||
return jsonify({'message': 'Auth group deleted successfully'})
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
status_code = 404 if 'not found' in message.lower() else 400
|
||||
return jsonify({'error': message}), status_code
|
||||
except Exception as exc:
|
||||
self._logger.error('Error deleting auth group: %s', exc)
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def get_auth_group_runtime(self, name: str) -> Response:
|
||||
try:
|
||||
return jsonify(self._auth_group_service.get_auth_group_runtime(name))
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 404
|
||||
except Exception as exc:
|
||||
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:
|
||||
try:
|
||||
self._auth_group_service.clear_entry_cooldown(name, entry_id)
|
||||
self._logger.info('Auth group entry cooldown cleared: %s/%s', name, entry_id)
|
||||
return jsonify({'message': 'Auth entry cooldown cleared successfully'})
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
status_code = 404 if 'not found' in message.lower() else 400
|
||||
return jsonify({'error': message}), status_code
|
||||
except Exception as exc:
|
||||
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:
|
||||
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)
|
||||
return jsonify({'message': 'Auth entry disabled successfully'})
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
status_code = 404 if 'not found' in message.lower() else 400
|
||||
return jsonify({'error': message}), status_code
|
||||
except Exception as exc:
|
||||
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:
|
||||
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)
|
||||
return jsonify({'message': 'Auth entry enabled successfully'})
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
status_code = 404 if 'not found' in message.lower() else 400
|
||||
return jsonify({'error': message}), status_code
|
||||
except Exception as exc:
|
||||
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:
|
||||
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)
|
||||
return jsonify({'message': 'Auth entry minute usage reset successfully'})
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
status_code = 404 if 'not found' in message.lower() else 400
|
||||
return jsonify({'error': message}), status_code
|
||||
except Exception as exc:
|
||||
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:
|
||||
try:
|
||||
self._auth_group_service.reset_entry_runtime(name, entry_id)
|
||||
self._logger.info('Auth group entry runtime reset: %s/%s', name, entry_id)
|
||||
return jsonify({'message': 'Auth entry runtime reset successfully'})
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
status_code = 404 if 'not found' in message.lower() else 400
|
||||
return jsonify({'error': message}), status_code
|
||||
except Exception as exc:
|
||||
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:
|
||||
try:
|
||||
self._auth_group_service.restore_entry(name, entry_id)
|
||||
self._logger.info('Auth group entry restored: %s/%s', name, entry_id)
|
||||
return jsonify({'message': 'Auth entry restored successfully'})
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
status_code = 404 if 'not found' in message.lower() else 400
|
||||
return jsonify({'error': message}), status_code
|
||||
except Exception as exc:
|
||||
self._logger.error('Error restoring auth group entry: %s', exc)
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def fetch_models(self) -> Response:
|
||||
try:
|
||||
result = self._model_discovery_service.fetch_models_preview(
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
@@ -21,6 +22,31 @@
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.providers-page .toolbar-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.providers-page .btn-toolbar-secondary {
|
||||
min-height: 40px;
|
||||
padding: 0 16px;
|
||||
border-color: rgba(58, 127, 237, 0.24);
|
||||
background: linear-gradient(180deg, rgba(248, 251, 255, 0.98), rgba(238, 244, 252, 0.98));
|
||||
color: var(--primary-active);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.providers-page .btn-toolbar-secondary:hover,
|
||||
.providers-page .btn-toolbar-secondary:focus {
|
||||
border-color: rgba(58, 127, 237, 0.46);
|
||||
background: linear-gradient(180deg, #ffffff, rgba(232, 241, 255, 1));
|
||||
color: var(--primary-active);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.providers-page .toolbar-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -28,6 +54,45 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.providers-page .provider-tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 5px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.providers-page .provider-tab-btn {
|
||||
min-width: 104px;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition), color var(--transition), border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.providers-page .provider-tab-btn:hover,
|
||||
.providers-page .provider-tab-btn:focus-visible {
|
||||
color: var(--nav-tab-hover-text);
|
||||
background: var(--nav-tab-hover-bg);
|
||||
border-color: var(--nav-tab-hover-border);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.providers-page .provider-tab-btn.is-active {
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.providers-page .toolbar-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -62,6 +127,56 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.providers-page .section-card {
|
||||
padding: 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.providers-page .section-card-spaced {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.providers-page .section-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.providers-page .section-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.providers-page .section-title-with-help {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.providers-page .section-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.providers-page .section-subtitle,
|
||||
.providers-page .field-help-text {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.providers-page .auth-group-summary {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.providers-page .providers-table-shell {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
@@ -70,6 +185,10 @@
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.providers-page .providers-tab-panel[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .providers-table-shell {
|
||||
background: #161b22;
|
||||
border-color: #30363d;
|
||||
@@ -114,6 +233,21 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.providers-page .auth-group-runtime-shell .providers-table td,
|
||||
.providers-page .auth-group-runtime-shell .providers-table th {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.providers-page .auth-groups-table-shell .providers-table td,
|
||||
.providers-page .auth-groups-table-shell .providers-table th {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.providers-page .auth-groups-table-shell .provider-meta-line {
|
||||
margin-top: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .providers-table td {
|
||||
background: #161b22;
|
||||
border-bottom-color: rgba(139, 148, 158, 0.28);
|
||||
@@ -144,6 +278,45 @@
|
||||
background: #161b22;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .provider-tabs {
|
||||
border-color: #313b47;
|
||||
background: #0d1117;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .provider-tab-btn {
|
||||
color: #9da7b3;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .provider-tab-btn:hover,
|
||||
:root[data-theme="dark"] .providers-page .provider-tab-btn:focus-visible {
|
||||
color: var(--nav-tab-hover-text);
|
||||
background: var(--nav-tab-hover-bg);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .provider-tab-btn.is-active {
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .btn-toolbar-secondary {
|
||||
border-color: #315d8f;
|
||||
background: linear-gradient(180deg, rgba(20, 29, 42, 0.98), rgba(16, 24, 35, 0.98));
|
||||
color: #9bc7ff;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .btn-toolbar-secondary:hover,
|
||||
:root[data-theme="dark"] .providers-page .btn-toolbar-secondary:focus {
|
||||
border-color: #4a79b6;
|
||||
background: linear-gradient(180deg, rgba(24, 35, 50, 1), rgba(19, 28, 41, 1));
|
||||
color: #c7e0ff;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .section-card {
|
||||
border-color: #313b47;
|
||||
background: #161b22;
|
||||
}
|
||||
|
||||
.providers-page .providers-table tbody tr:nth-child(even) td {
|
||||
background: #fbfdff;
|
||||
}
|
||||
@@ -177,6 +350,134 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.providers-page .entry-editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-table {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.52);
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-table-header,
|
||||
.providers-page .auth-entry-summary {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(180px, 1.3fr) minmax(96px, 0.72fr) minmax(96px, 0.72fr) minmax(260px, 2fr) 88px;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-table-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(17, 24, 39, 0.04);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-table-toggle-column {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-card {
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-list .auth-entry-card:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-summary {
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition);
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-summary:hover {
|
||||
background: rgba(58, 127, 237, 0.04);
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-summary:focus-visible {
|
||||
outline: 2px solid rgba(58, 127, 237, 0.35);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-summary-cell {
|
||||
min-width: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-summary-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-summary-id {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-summary-limits {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-summary-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-toggle-btn {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
padding: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-detail {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-card[data-expanded="true"] .auth-entry-summary {
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(58, 127, 237, 0.05);
|
||||
}
|
||||
|
||||
.providers-page .entry-header-editor {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.providers-page .auth-header-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.providers-page .auth-header-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.providers-page .provider-api-text {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
@@ -285,6 +586,30 @@
|
||||
color: #f6deb1;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .auth-entry-table {
|
||||
border-color: #313b47;
|
||||
background: rgba(15, 20, 27, 0.72);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .auth-entry-table-header {
|
||||
border-bottom-color: #313b47;
|
||||
background: rgba(13, 17, 23, 0.78);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .auth-entry-card {
|
||||
border-bottom-color: #313b47;
|
||||
background: rgba(15, 20, 27, 0.72);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .auth-entry-summary:hover {
|
||||
background: rgba(47, 129, 247, 0.08);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .auth-entry-card[data-expanded="true"] .auth-entry-summary {
|
||||
border-bottom-color: #313b47;
|
||||
background: rgba(47, 129, 247, 0.08);
|
||||
}
|
||||
|
||||
.providers-page .muted-text,
|
||||
.providers-page .loading,
|
||||
.providers-page .empty-state,
|
||||
@@ -316,6 +641,18 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.providers-page .runtime-action-buttons {
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.providers-page .auth-group-runtime-shell .runtime-actions-cell,
|
||||
.providers-page .auth-group-runtime-shell .providers-table th:last-child,
|
||||
.providers-page .auth-group-runtime-shell .providers-table td:last-child {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.providers-page .switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
@@ -682,10 +1019,20 @@
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.providers-page .auth-group-base-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0 16px;
|
||||
}
|
||||
|
||||
.providers-page .modal-dialog {
|
||||
max-width: min(1120px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-yaml-modal-dialog {
|
||||
max-width: min(1180px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.providers-page .modal-content {
|
||||
border: 1px solid var(--line) !important;
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -724,6 +1071,53 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.providers-page .entry-import-textarea {
|
||||
min-height: 260px;
|
||||
font-family: "JetBrains Mono", "Consolas", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-yaml-modal-dialog .entry-import-textarea {
|
||||
height: min(58vh, 560px);
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.providers-page .field-header-description {
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.providers-page .runtime-error-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.providers-page .runtime-error-detail-item {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(17, 24, 39, 0.02);
|
||||
}
|
||||
|
||||
.providers-page .runtime-error-detail-label {
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.providers-page .runtime-error-detail-value {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.providers-page .btn-close {
|
||||
opacity: 0.72;
|
||||
}
|
||||
@@ -1169,6 +1563,11 @@
|
||||
color: #bcc8d8;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .runtime-error-detail-item {
|
||||
border-color: #313b47;
|
||||
background: rgba(13, 17, 23, 0.78);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .field-meta-badge {
|
||||
border-color: #313b47;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
@@ -1314,12 +1713,27 @@
|
||||
}
|
||||
|
||||
.providers-page .toolbar-card {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.providers-page .toolbar-meta,
|
||||
.providers-page .toolbar-summary,
|
||||
.providers-page .provider-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.providers-page .toolbar-meta,
|
||||
.providers-page .section-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.providers-page .toolbar-summary {
|
||||
width: 100%;
|
||||
.providers-page .provider-tabs {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.providers-page .provider-tab-btn {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.providers-page .toolbar-pill {
|
||||
@@ -1330,6 +1744,32 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.providers-page .auth-group-base-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-table-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-summary {
|
||||
grid-template-columns: 44px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-summary-cell,
|
||||
.providers-page .auth-entry-summary-actions {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-summary-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.providers-page .runtime-error-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.providers-page .field-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -118,7 +118,7 @@
|
||||
</div>
|
||||
<div class="modal-footer border-top-0">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveUser()">保存修改</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveUser()">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
"""仓储层导出。"""
|
||||
|
||||
from .user_repository import UserRepository
|
||||
from .auth_group_repository import AuthGroupRepository
|
||||
from .log_repository import LogRepository
|
||||
|
||||
__all__ = [
|
||||
'UserRepository',
|
||||
'AuthGroupRepository',
|
||||
'LogRepository',
|
||||
]
|
||||
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Auth group runtime state repository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterable
|
||||
|
||||
from ..utils.database import ConnectionFactory
|
||||
from ..utils.local_time import format_local_date, format_local_datetime, now_local_datetime_text
|
||||
|
||||
|
||||
class AuthGroupRepository:
|
||||
"""Persist auth entry runtime state and quota buckets."""
|
||||
|
||||
def __init__(self, get_connection: ConnectionFactory):
|
||||
self._get_connection = get_connection
|
||||
self._ensure_tables()
|
||||
|
||||
def _ensure_tables(self) -> None:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS auth_entry_runtime_state (
|
||||
auth_group_name TEXT NOT NULL,
|
||||
entry_id TEXT NOT NULL,
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
disabled_reason TEXT,
|
||||
cooldown_until TEXT,
|
||||
last_status_code INTEGER,
|
||||
last_error_type TEXT,
|
||||
last_error_message TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (auth_group_name, entry_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS auth_entry_usage_buckets (
|
||||
auth_group_name TEXT NOT NULL,
|
||||
entry_id TEXT NOT NULL,
|
||||
bucket_type TEXT NOT NULL,
|
||||
bucket_start TEXT NOT NULL,
|
||||
request_count INTEGER NOT NULL DEFAULT 0,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (auth_group_name, entry_id, bucket_type, bucket_start)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_auth_entry_runtime_group ON auth_entry_runtime_state(auth_group_name)"
|
||||
)
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_auth_entry_usage_group ON auth_entry_usage_buckets(auth_group_name, bucket_type, bucket_start)"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _default_runtime_state(auth_group_name: str, entry_id: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"auth_group_name": auth_group_name,
|
||||
"entry_id": entry_id,
|
||||
"disabled": False,
|
||||
"disabled_reason": None,
|
||||
"cooldown_until": None,
|
||||
"last_status_code": None,
|
||||
"last_error_type": None,
|
||||
"last_error_message": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
def get_entry_runtime_state(self, auth_group_name: str, entry_id: str) -> Dict[str, Any]:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT auth_group_name, entry_id, disabled, disabled_reason, cooldown_until,
|
||||
last_status_code, last_error_type, last_error_message, updated_at
|
||||
FROM auth_entry_runtime_state
|
||||
WHERE auth_group_name = ? AND entry_id = ?
|
||||
""",
|
||||
(auth_group_name, entry_id),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
return self._default_runtime_state(auth_group_name, entry_id)
|
||||
return {
|
||||
"auth_group_name": row["auth_group_name"],
|
||||
"entry_id": row["entry_id"],
|
||||
"disabled": bool(row["disabled"]),
|
||||
"disabled_reason": row["disabled_reason"],
|
||||
"cooldown_until": row["cooldown_until"],
|
||||
"last_status_code": row["last_status_code"],
|
||||
"last_error_type": row["last_error_type"],
|
||||
"last_error_message": row["last_error_message"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
def list_group_runtime_states(self, auth_group_name: str) -> Dict[str, Dict[str, Any]]:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT auth_group_name, entry_id, disabled, disabled_reason, cooldown_until,
|
||||
last_status_code, last_error_type, last_error_message, updated_at
|
||||
FROM auth_entry_runtime_state
|
||||
WHERE auth_group_name = ?
|
||||
""",
|
||||
(auth_group_name,),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
result: Dict[str, Dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
result[row["entry_id"]] = {
|
||||
"auth_group_name": row["auth_group_name"],
|
||||
"entry_id": row["entry_id"],
|
||||
"disabled": bool(row["disabled"]),
|
||||
"disabled_reason": row["disabled_reason"],
|
||||
"cooldown_until": row["cooldown_until"],
|
||||
"last_status_code": row["last_status_code"],
|
||||
"last_error_type": row["last_error_type"],
|
||||
"last_error_message": row["last_error_message"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
return result
|
||||
|
||||
def save_entry_runtime_state(
|
||||
self,
|
||||
auth_group_name: str,
|
||||
entry_id: str,
|
||||
*,
|
||||
disabled: bool,
|
||||
disabled_reason: str | None,
|
||||
cooldown_until: str | None,
|
||||
last_status_code: int | None,
|
||||
last_error_type: str | None,
|
||||
last_error_message: str | None,
|
||||
) -> None:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO auth_entry_runtime_state (
|
||||
auth_group_name, entry_id, disabled, disabled_reason, cooldown_until,
|
||||
last_status_code, last_error_type, last_error_message, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(auth_group_name, entry_id)
|
||||
DO UPDATE SET
|
||||
disabled = excluded.disabled,
|
||||
disabled_reason = excluded.disabled_reason,
|
||||
cooldown_until = excluded.cooldown_until,
|
||||
last_status_code = excluded.last_status_code,
|
||||
last_error_type = excluded.last_error_type,
|
||||
last_error_message = excluded.last_error_message,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
auth_group_name,
|
||||
entry_id,
|
||||
1 if disabled else 0,
|
||||
disabled_reason,
|
||||
cooldown_until,
|
||||
last_status_code,
|
||||
last_error_type,
|
||||
last_error_message,
|
||||
now_local_datetime_text(),
|
||||
),
|
||||
)
|
||||
|
||||
def restore_entry(self, auth_group_name: str, entry_id: str) -> None:
|
||||
self.save_entry_runtime_state(
|
||||
auth_group_name,
|
||||
entry_id,
|
||||
disabled=False,
|
||||
disabled_reason=None,
|
||||
cooldown_until=None,
|
||||
last_status_code=None,
|
||||
last_error_type=None,
|
||||
last_error_message=None,
|
||||
)
|
||||
|
||||
def reset_current_minute_usage(self, auth_group_name: str, entry_id: str, when: datetime) -> None:
|
||||
minute_bucket = self._minute_bucket_start(when)
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
DELETE FROM auth_entry_usage_buckets
|
||||
WHERE auth_group_name = ? AND entry_id = ?
|
||||
AND bucket_type = 'minute' AND bucket_start = ?
|
||||
""",
|
||||
(auth_group_name, entry_id, minute_bucket),
|
||||
)
|
||||
|
||||
def reset_current_day_usage(self, auth_group_name: str, entry_id: str, when: datetime) -> None:
|
||||
day_bucket = self._day_bucket_start(when)
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
DELETE FROM auth_entry_usage_buckets
|
||||
WHERE auth_group_name = ? AND entry_id = ?
|
||||
AND bucket_type = 'day' AND bucket_start = ?
|
||||
""",
|
||||
(auth_group_name, entry_id, day_bucket),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _minute_bucket_start(value: datetime) -> str:
|
||||
return format_local_datetime(value.replace(second=0, microsecond=0))
|
||||
|
||||
@staticmethod
|
||||
def _day_bucket_start(value: datetime) -> str:
|
||||
return format_local_date(value)
|
||||
|
||||
def _increment_usage_bucket(
|
||||
self,
|
||||
auth_group_name: str,
|
||||
entry_id: str,
|
||||
*,
|
||||
bucket_type: str,
|
||||
bucket_start: str,
|
||||
request_count: int = 0,
|
||||
prompt_tokens: int = 0,
|
||||
completion_tokens: int = 0,
|
||||
total_tokens: int = 0,
|
||||
) -> None:
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO auth_entry_usage_buckets (
|
||||
auth_group_name, entry_id, bucket_type, bucket_start,
|
||||
request_count, prompt_tokens, completion_tokens, total_tokens, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(auth_group_name, entry_id, bucket_type, bucket_start)
|
||||
DO UPDATE SET
|
||||
request_count = request_count + excluded.request_count,
|
||||
prompt_tokens = prompt_tokens + excluded.prompt_tokens,
|
||||
completion_tokens = completion_tokens + excluded.completion_tokens,
|
||||
total_tokens = total_tokens + excluded.total_tokens,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
auth_group_name,
|
||||
entry_id,
|
||||
bucket_type,
|
||||
bucket_start,
|
||||
int(request_count or 0),
|
||||
int(prompt_tokens or 0),
|
||||
int(completion_tokens or 0),
|
||||
int(total_tokens or 0),
|
||||
now_local_datetime_text(),
|
||||
),
|
||||
)
|
||||
|
||||
def increment_request_usage(self, auth_group_name: str, entry_id: str, when: datetime) -> None:
|
||||
minute_bucket = self._minute_bucket_start(when)
|
||||
day_bucket = self._day_bucket_start(when)
|
||||
self._increment_usage_bucket(
|
||||
auth_group_name,
|
||||
entry_id,
|
||||
bucket_type="minute",
|
||||
bucket_start=minute_bucket,
|
||||
request_count=1,
|
||||
)
|
||||
self._increment_usage_bucket(
|
||||
auth_group_name,
|
||||
entry_id,
|
||||
bucket_type="day",
|
||||
bucket_start=day_bucket,
|
||||
request_count=1,
|
||||
)
|
||||
|
||||
def increment_token_usage(
|
||||
self,
|
||||
auth_group_name: str,
|
||||
entry_id: str,
|
||||
when: datetime,
|
||||
*,
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
total_tokens: int,
|
||||
) -> None:
|
||||
minute_bucket = self._minute_bucket_start(when)
|
||||
day_bucket = self._day_bucket_start(when)
|
||||
for bucket_type, bucket_start in (("minute", minute_bucket), ("day", day_bucket)):
|
||||
self._increment_usage_bucket(
|
||||
auth_group_name,
|
||||
entry_id,
|
||||
bucket_type=bucket_type,
|
||||
bucket_start=bucket_start,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
|
||||
def get_current_usage(self, auth_group_name: str, entry_id: str, when: datetime) -> Dict[str, int]:
|
||||
minute_bucket = self._minute_bucket_start(when)
|
||||
day_bucket = self._day_bucket_start(when)
|
||||
|
||||
usage = {
|
||||
"minute_request_count": 0,
|
||||
"day_request_count": 0,
|
||||
"minute_prompt_tokens": 0,
|
||||
"day_prompt_tokens": 0,
|
||||
"minute_completion_tokens": 0,
|
||||
"day_completion_tokens": 0,
|
||||
"minute_total_tokens": 0,
|
||||
"day_total_tokens": 0,
|
||||
}
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT bucket_type, bucket_start, request_count, prompt_tokens, completion_tokens, total_tokens
|
||||
FROM auth_entry_usage_buckets
|
||||
WHERE auth_group_name = ? AND entry_id = ?
|
||||
AND ((bucket_type = 'minute' AND bucket_start = ?)
|
||||
OR (bucket_type = 'day' AND bucket_start = ?))
|
||||
""",
|
||||
(auth_group_name, entry_id, minute_bucket, day_bucket),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
for row in rows:
|
||||
prefix = "minute" if row["bucket_type"] == "minute" else "day"
|
||||
usage[f"{prefix}_request_count"] = int(row["request_count"] or 0)
|
||||
usage[f"{prefix}_prompt_tokens"] = int(row["prompt_tokens"] or 0)
|
||||
usage[f"{prefix}_completion_tokens"] = int(row["completion_tokens"] or 0)
|
||||
usage[f"{prefix}_total_tokens"] = int(row["total_tokens"] or 0)
|
||||
return usage
|
||||
|
||||
def list_current_usage(
|
||||
self,
|
||||
auth_group_name: str,
|
||||
entry_ids: Iterable[str],
|
||||
when: datetime,
|
||||
) -> Dict[str, Dict[str, int]]:
|
||||
usage_by_entry: Dict[str, Dict[str, int]] = {}
|
||||
for entry_id in entry_ids:
|
||||
usage_by_entry[str(entry_id)] = self.get_current_usage(auth_group_name, str(entry_id), when)
|
||||
return usage_by_entry
|
||||
@@ -3,6 +3,7 @@
|
||||
"""服务层导出。"""
|
||||
|
||||
from .authentication_service import AuthenticationService
|
||||
from .auth_group_service import AuthGroupService
|
||||
from .log_service import LogService
|
||||
from .model_discovery_service import ModelDiscoveryService
|
||||
from .provider_service import ProviderService
|
||||
@@ -12,6 +13,7 @@ from .user_service import UserService
|
||||
|
||||
__all__ = [
|
||||
'AuthenticationService',
|
||||
'AuthGroupService',
|
||||
'UserService',
|
||||
'LogService',
|
||||
'ModelDiscoveryService',
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Auth group configuration and runtime management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict, List, Mapping, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from ..application.app_context import AppContext
|
||||
from ..config import AuthGroupManager
|
||||
from ..config.provider_config import (
|
||||
AuthEntrySchema,
|
||||
AuthGroupSchema,
|
||||
validate_auth_group_provider_definitions,
|
||||
)
|
||||
|
||||
|
||||
class AuthGroupService:
|
||||
"""Manage auth group config and expose runtime state."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: AppContext,
|
||||
reload_callback: Callable[[], None],
|
||||
auth_group_manager: AuthGroupManager,
|
||||
):
|
||||
self._config_manager = ctx.config_manager
|
||||
self._logger = ctx.logger
|
||||
self._reload_callback = reload_callback
|
||||
self._auth_group_manager = auth_group_manager
|
||||
|
||||
def list_auth_groups(self) -> List[Dict[str, Any]]:
|
||||
config = self._config_manager.get_raw_config()
|
||||
auth_groups = self._extract_auth_groups(config)
|
||||
providers = self._extract_providers(config)
|
||||
summaries = {
|
||||
item["name"]: item
|
||||
for item in self._auth_group_manager.list_auth_group_summaries()
|
||||
}
|
||||
provider_counts = self._count_providers_by_auth_group(providers)
|
||||
result: List[Dict[str, Any]] = []
|
||||
for auth_group in auth_groups:
|
||||
normalized = AuthGroupSchema.from_mapping(auth_group).to_mapping()
|
||||
normalized["summary"] = summaries.get(normalized["name"], {}).get("summary", {})
|
||||
normalized["provider_count"] = provider_counts.get(normalized["name"], 0)
|
||||
result.append(normalized)
|
||||
return result
|
||||
|
||||
def get_auth_group(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
config = self._config_manager.get_raw_config()
|
||||
auth_groups = self._extract_auth_groups(config)
|
||||
providers = self._extract_providers(config)
|
||||
target = self._find_auth_group(auth_groups, name)
|
||||
if target is None:
|
||||
return None
|
||||
normalized = AuthGroupSchema.from_mapping(target).to_mapping()
|
||||
summaries = {
|
||||
item["name"]: item
|
||||
for item in self._auth_group_manager.list_auth_group_summaries()
|
||||
}
|
||||
normalized["summary"] = summaries.get(normalized["name"], {}).get("summary", {})
|
||||
normalized["provider_count"] = self._count_providers_by_auth_group(providers).get(normalized["name"], 0)
|
||||
return normalized
|
||||
|
||||
def create_auth_group(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
config = self._config_manager.get_raw_config()
|
||||
auth_groups = self._extract_auth_groups(config)
|
||||
providers = self._extract_providers(config)
|
||||
auth_group = AuthGroupSchema.from_mapping(payload)
|
||||
normalized = auth_group.to_mapping()
|
||||
|
||||
if self._find_auth_group(auth_groups, auth_group.name):
|
||||
raise ValueError(f"Auth group already exists: {auth_group.name}")
|
||||
|
||||
auth_groups.append(normalized)
|
||||
self._save_auth_groups(config, auth_groups, providers)
|
||||
return normalized
|
||||
|
||||
def update_auth_group(self, current_name: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
config = self._config_manager.get_raw_config()
|
||||
auth_groups = self._extract_auth_groups(config)
|
||||
providers = self._extract_providers(config)
|
||||
auth_group = AuthGroupSchema.from_mapping(payload)
|
||||
normalized = auth_group.to_mapping()
|
||||
|
||||
target = self._find_auth_group(auth_groups, current_name)
|
||||
if target is None:
|
||||
raise ValueError(f"Auth group not found: {current_name}")
|
||||
|
||||
duplicate = self._find_auth_group(auth_groups, auth_group.name)
|
||||
if duplicate is not None and duplicate is not target:
|
||||
raise ValueError(f"Auth group already exists: {auth_group.name}")
|
||||
|
||||
if auth_group.name != str(current_name).strip():
|
||||
for provider in providers:
|
||||
if str(provider.get("auth_group") or "").strip() == str(current_name).strip():
|
||||
provider["auth_group"] = auth_group.name
|
||||
|
||||
auth_groups[auth_groups.index(target)] = normalized
|
||||
self._save_auth_groups(config, auth_groups, providers)
|
||||
return normalized
|
||||
|
||||
def delete_auth_group(self, name: str) -> None:
|
||||
config = self._config_manager.get_raw_config()
|
||||
auth_groups = self._extract_auth_groups(config)
|
||||
providers = self._extract_providers(config)
|
||||
target = self._find_auth_group(auth_groups, name)
|
||||
if target is None:
|
||||
raise ValueError(f"Auth group not found: {name}")
|
||||
|
||||
if any(str(provider.get("auth_group") or "").strip() == str(name).strip() for provider in providers):
|
||||
raise ValueError(f"Auth group is still referenced by providers: {name}")
|
||||
|
||||
auth_groups.remove(target)
|
||||
self._save_auth_groups(config, auth_groups, providers)
|
||||
|
||||
def get_auth_group_runtime(self, name: str) -> Dict[str, Any]:
|
||||
runtime = self._auth_group_manager.get_auth_group_runtime(name)
|
||||
config = self._config_manager.get_raw_config()
|
||||
providers = self._extract_providers(config)
|
||||
runtime["provider_count"] = self._count_providers_by_auth_group(providers).get(runtime["name"], 0)
|
||||
return runtime
|
||||
|
||||
def restore_entry(self, auth_group_name: str, entry_id: str) -> None:
|
||||
self._auth_group_manager.restore_entry(auth_group_name, entry_id)
|
||||
|
||||
def clear_entry_cooldown(self, auth_group_name: str, entry_id: str) -> None:
|
||||
self._auth_group_manager.clear_entry_cooldown(auth_group_name, entry_id)
|
||||
|
||||
def set_entry_disabled(self, auth_group_name: str, entry_id: str, *, disabled: bool) -> None:
|
||||
self._auth_group_manager.set_entry_disabled(auth_group_name, entry_id, disabled=disabled)
|
||||
|
||||
def reset_entry_minute_usage(self, auth_group_name: str, entry_id: str) -> None:
|
||||
self._auth_group_manager.reset_entry_minute_usage(auth_group_name, entry_id)
|
||||
|
||||
def reset_entry_runtime(self, auth_group_name: str, entry_id: str) -> None:
|
||||
self._auth_group_manager.reset_entry_runtime(auth_group_name, entry_id)
|
||||
|
||||
def import_auth_entries(self, yaml_text: str) -> List[Dict[str, Any]]:
|
||||
raw_text = str(yaml_text or "").strip()
|
||||
if not raw_text:
|
||||
raise ValueError("YAML 内容不能为空")
|
||||
|
||||
try:
|
||||
payload = yaml.safe_load(raw_text)
|
||||
except yaml.YAMLError as exc:
|
||||
raise ValueError(f"YAML 解析失败: {exc}") from exc
|
||||
|
||||
entries_payload = self._normalize_import_payload(payload)
|
||||
if not entries_payload:
|
||||
raise ValueError("请至少导入一个 Auth Entry")
|
||||
|
||||
seen_entry_ids: set[str] = set()
|
||||
normalized_entries: List[Dict[str, Any]] = []
|
||||
for item in entries_payload:
|
||||
entry = AuthEntrySchema.from_mapping(item)
|
||||
if entry.id in seen_entry_ids:
|
||||
raise ValueError(f"检测到重复的 Auth Entry ID: {entry.id}")
|
||||
seen_entry_ids.add(entry.id)
|
||||
normalized_entries.append(entry.to_mapping())
|
||||
return normalized_entries
|
||||
|
||||
def _save_auth_groups(
|
||||
self,
|
||||
config: Dict[str, Any],
|
||||
auth_groups: List[Dict[str, Any]],
|
||||
providers: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
validate_auth_group_provider_definitions(auth_groups, providers)
|
||||
config["auth_groups"] = auth_groups
|
||||
config["providers"] = providers
|
||||
self._config_manager.write_raw_config(config)
|
||||
self._reload_callback()
|
||||
|
||||
@staticmethod
|
||||
def _extract_auth_groups(config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
auth_groups = config.get("auth_groups", [])
|
||||
if auth_groups is None:
|
||||
auth_groups = []
|
||||
if not isinstance(auth_groups, list):
|
||||
raise ValueError("Config field 'auth_groups' must be a list")
|
||||
for index, auth_group in enumerate(auth_groups):
|
||||
if not isinstance(auth_group, dict):
|
||||
raise ValueError(f"Auth group entry at index {index} must be an object")
|
||||
return list(auth_groups)
|
||||
|
||||
@staticmethod
|
||||
def _extract_providers(config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
providers = config.get("providers", [])
|
||||
if providers is None:
|
||||
providers = []
|
||||
if not isinstance(providers, list):
|
||||
raise ValueError("Config field 'providers' must be a list")
|
||||
for index, provider in enumerate(providers):
|
||||
if not isinstance(provider, dict):
|
||||
raise ValueError(f"Provider entry at index {index} must be an object")
|
||||
return list(providers)
|
||||
|
||||
@staticmethod
|
||||
def _find_auth_group(auth_groups: List[Dict[str, Any]], name: str) -> Optional[Dict[str, Any]]:
|
||||
normalized_name = str(name).strip()
|
||||
for auth_group in auth_groups:
|
||||
if str(auth_group.get("name", "")).strip() == normalized_name:
|
||||
return auth_group
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _count_providers_by_auth_group(providers: List[Dict[str, Any]]) -> Dict[str, int]:
|
||||
counts: Dict[str, int] = {}
|
||||
for provider in providers:
|
||||
auth_group = str(provider.get("auth_group") or "").strip()
|
||||
if not auth_group:
|
||||
continue
|
||||
counts[auth_group] = counts.get(auth_group, 0) + 1
|
||||
return counts
|
||||
|
||||
@staticmethod
|
||||
def _normalize_import_payload(payload: Any) -> List[Mapping[str, Any]]:
|
||||
if isinstance(payload, list):
|
||||
entries = payload
|
||||
elif isinstance(payload, Mapping):
|
||||
if "entries" in payload:
|
||||
entries = payload.get("entries")
|
||||
else:
|
||||
entries = [payload]
|
||||
else:
|
||||
raise ValueError("YAML 必须是 Auth Entry 列表,或包含 entries 字段的对象")
|
||||
|
||||
if not isinstance(entries, list):
|
||||
raise ValueError("YAML 中的 entries 必须是列表")
|
||||
|
||||
normalized_entries: List[Mapping[str, Any]] = []
|
||||
for index, item in enumerate(entries):
|
||||
if not isinstance(item, Mapping):
|
||||
raise ValueError(f"第 {index + 1} 个 Auth Entry 必须是对象")
|
||||
normalized_entries.append(item)
|
||||
return normalized_entries
|
||||
@@ -9,7 +9,7 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
from ..application.app_context import AppContext
|
||||
from ..config.provider_config import (
|
||||
ProviderConfigSchema,
|
||||
validate_provider_definitions,
|
||||
validate_auth_group_provider_definitions,
|
||||
)
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ class ProviderService:
|
||||
self._save_providers(config, providers)
|
||||
|
||||
def _save_providers(self, config: Dict[str, Any], providers: List[Dict[str, Any]]) -> None:
|
||||
self._validate_providers(providers)
|
||||
self._validate_providers(config, providers)
|
||||
config['providers'] = providers
|
||||
self._config_manager.write_raw_config(config)
|
||||
self._reload_callback()
|
||||
@@ -104,5 +104,20 @@ class ProviderService:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _validate_providers(providers: List[Dict[str, Any]]) -> None:
|
||||
validate_provider_definitions(providers)
|
||||
def _extract_auth_groups(config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
auth_groups = config.get('auth_groups', [])
|
||||
if auth_groups is None:
|
||||
auth_groups = []
|
||||
if not isinstance(auth_groups, list):
|
||||
raise ValueError("Config field 'auth_groups' must be a list")
|
||||
for index, auth_group in enumerate(auth_groups):
|
||||
if not isinstance(auth_group, dict):
|
||||
raise ValueError(f'Auth group entry at index {index} must be an object')
|
||||
return list(auth_groups)
|
||||
|
||||
@staticmethod
|
||||
def _validate_providers(config: Dict[str, Any], providers: List[Dict[str, Any]]) -> None:
|
||||
validate_auth_group_provider_definitions(
|
||||
ProviderService._extract_auth_groups(config),
|
||||
providers,
|
||||
)
|
||||
+156
-28
@@ -13,6 +13,7 @@ import websocket
|
||||
from flask import Response, stream_with_context
|
||||
|
||||
from ..application.app_context import AppContext
|
||||
from ..config.auth_group_manager import AuthGroupManager, AuthGroupSelectionError, SelectedAuthEntry
|
||||
from ..executors import OpenedUpstreamResponse, build_default_executor_registry
|
||||
from ..external import LLMProvider
|
||||
from ..hooks import HookContext, HookErrorType
|
||||
@@ -41,9 +42,10 @@ class ProxyErrorInfo:
|
||||
class ProxyService:
|
||||
"""处理上游 LLM 代理请求。"""
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
def __init__(self, ctx: AppContext, auth_group_manager: Optional[AuthGroupManager] = None):
|
||||
self._logger = ctx.logger
|
||||
self._root_path = ctx.root_path
|
||||
self._auth_group_manager = auth_group_manager
|
||||
self._executor_registry = build_default_executor_registry(self._logger)
|
||||
self._translator_registry = build_default_translator_registry()
|
||||
|
||||
@@ -71,6 +73,7 @@ class ProxyService:
|
||||
|
||||
def build_request(
|
||||
attempt: int,
|
||||
selected_auth: Optional[SelectedAuthEntry],
|
||||
) -> Tuple[Dict[str, str], Dict[str, Any], Dict[str, Any], HookContext]:
|
||||
initial_stream = bool(request_data.get("stream", False))
|
||||
request_ctx = HookContext(
|
||||
@@ -84,13 +87,21 @@ class ProxyService:
|
||||
provider_target_format=provider.target_format,
|
||||
transport=provider.transport,
|
||||
stream=initial_stream,
|
||||
auth_group_name=(
|
||||
selected_auth.auth_group_name
|
||||
if selected_auth is not None
|
||||
else provider.auth_group
|
||||
),
|
||||
auth_entry_id=(selected_auth.entry_id if selected_auth is not None else None),
|
||||
last_status_code=previous_status_code,
|
||||
last_error_type=previous_error_type,
|
||||
)
|
||||
|
||||
headers = dict(request_headers)
|
||||
headers["content-type"] = "application/json"
|
||||
if provider.api_key:
|
||||
if selected_auth is not None:
|
||||
headers.update(selected_auth.headers_mapping())
|
||||
elif provider.api_key:
|
||||
headers["authorization"] = f"Bearer {provider.api_key}"
|
||||
headers = provider.apply_header_hook(request_ctx, headers)
|
||||
|
||||
@@ -103,21 +114,54 @@ class ProxyService:
|
||||
return headers, guarded_body, translated_body, request_ctx
|
||||
|
||||
for attempt in range(max_retries):
|
||||
headers, guarded_body, translated_body, request_ctx = build_request(attempt)
|
||||
requested_stream = request_ctx.stream
|
||||
self._logger.info(
|
||||
"Proxying upstream request: provider=%s transport=%s source=%s target=%s model=%s attempt=%s/%s stream=%s",
|
||||
provider.name,
|
||||
provider.transport,
|
||||
provider.source_format,
|
||||
provider.target_format,
|
||||
translated_body.get("model"),
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
requested_stream,
|
||||
)
|
||||
selected_auth: Optional[SelectedAuthEntry] = None
|
||||
attempt_finalized = False
|
||||
|
||||
def finalize_attempt(
|
||||
*,
|
||||
status_code: Optional[int] = None,
|
||||
error_type: Optional[HookErrorType] = None,
|
||||
error_message: Optional[str] = None,
|
||||
response_headers: Optional[Dict[str, Any]] = None,
|
||||
usage: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
nonlocal attempt_finalized
|
||||
if attempt_finalized:
|
||||
return
|
||||
attempt_finalized = True
|
||||
if self._auth_group_manager is None:
|
||||
return
|
||||
self._auth_group_manager.finish(
|
||||
selected_auth,
|
||||
status_code=status_code,
|
||||
error_type=error_type,
|
||||
error_message=error_message,
|
||||
response_headers=response_headers,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
try:
|
||||
if self._auth_group_manager is not None and provider.auth_group:
|
||||
selected_auth = self._auth_group_manager.acquire(provider.auth_group)
|
||||
|
||||
headers, guarded_body, translated_body, request_ctx = build_request(attempt, selected_auth)
|
||||
requested_stream = request_ctx.stream
|
||||
self._logger.info(
|
||||
"Proxying upstream request: provider=%s transport=%s source=%s target=%s model=%s attempt=%s/%s stream=%s auth_group=%s auth_entry=%s",
|
||||
provider.name,
|
||||
provider.transport,
|
||||
provider.source_format,
|
||||
provider.target_format,
|
||||
translated_body.get("model"),
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
requested_stream,
|
||||
request_ctx.auth_group_name or "<none>",
|
||||
request_ctx.auth_entry_id or "<none>",
|
||||
)
|
||||
if self._auth_group_manager is not None:
|
||||
self._auth_group_manager.mark_request_dispatched(selected_auth)
|
||||
|
||||
opened = self._coerce_opened_response(
|
||||
self._open_upstream_response(
|
||||
provider,
|
||||
@@ -125,15 +169,22 @@ class ProxyService:
|
||||
translated_body,
|
||||
requested_stream,
|
||||
target_url,
|
||||
request_proxies,
|
||||
timeout_seconds,
|
||||
verify_ssl,
|
||||
request_proxies,
|
||||
timeout_seconds,
|
||||
verify_ssl,
|
||||
)
|
||||
)
|
||||
|
||||
if self._should_retry_status_code(opened.status_code) and attempt < max_retries - 1:
|
||||
previous_status_code = opened.status_code
|
||||
previous_error_type = None
|
||||
raw_response_headers = dict(getattr(opened.response, "headers", {}) or {})
|
||||
_, _, retry_summary = self._consume_upstream_error(provider, opened)
|
||||
finalize_attempt(
|
||||
status_code=opened.status_code,
|
||||
error_message=retry_summary,
|
||||
response_headers=raw_response_headers,
|
||||
)
|
||||
self._logger.warning(
|
||||
"Retryable upstream status (attempt %s/%s): provider=%s status=%s stream=%s, retrying",
|
||||
attempt + 1,
|
||||
@@ -142,7 +193,6 @@ class ProxyService:
|
||||
opened.status_code,
|
||||
opened.is_stream,
|
||||
)
|
||||
opened.response.close()
|
||||
continue
|
||||
|
||||
if requested_stream != opened.is_stream:
|
||||
@@ -156,11 +206,14 @@ class ProxyService:
|
||||
)
|
||||
|
||||
if opened.status_code >= 400:
|
||||
return (
|
||||
self._build_error_response(provider, opened),
|
||||
opened.status_code,
|
||||
None,
|
||||
raw_response_headers = dict(getattr(opened.response, "headers", {}) or {})
|
||||
response, error_summary = self._build_error_response(provider, opened)
|
||||
finalize_attempt(
|
||||
status_code=opened.status_code,
|
||||
error_message=error_summary,
|
||||
response_headers=raw_response_headers,
|
||||
)
|
||||
return response, opened.status_code, None
|
||||
|
||||
if opened.is_stream:
|
||||
response = self._build_stream_response(
|
||||
@@ -172,6 +225,7 @@ class ProxyService:
|
||||
opened=opened,
|
||||
on_complete=on_complete,
|
||||
forward_stream_usage=forward_stream_usage,
|
||||
finalize_attempt=finalize_attempt,
|
||||
)
|
||||
else:
|
||||
response = self._build_nonstream_response(
|
||||
@@ -182,6 +236,7 @@ class ProxyService:
|
||||
translated_request=translated_body,
|
||||
opened=opened,
|
||||
on_complete=on_complete,
|
||||
finalize_attempt=finalize_attempt,
|
||||
)
|
||||
|
||||
self._logger.info(
|
||||
@@ -194,6 +249,21 @@ class ProxyService:
|
||||
opened.is_stream,
|
||||
)
|
||||
return response, opened.status_code, None
|
||||
except AuthGroupSelectionError as exc:
|
||||
last_error = ProxyErrorInfo(
|
||||
message=exc.message,
|
||||
status_code=exc.status_code,
|
||||
error_type=exc.error_type,
|
||||
error_code=exc.error_code,
|
||||
)
|
||||
self._logger.warning(
|
||||
"Auth group unavailable: provider=%s auth_group=%s status=%s error=%s",
|
||||
provider.name,
|
||||
provider.auth_group or "<none>",
|
||||
exc.status_code,
|
||||
exc.message,
|
||||
)
|
||||
return None, exc.status_code, last_error
|
||||
except requests.exceptions.RequestException as exc:
|
||||
previous_status_code = None
|
||||
previous_error_type = self._classify_request_error(exc)
|
||||
@@ -205,6 +275,10 @@ class ProxyService:
|
||||
provider.name,
|
||||
exc,
|
||||
)
|
||||
finalize_attempt(
|
||||
error_type=previous_error_type,
|
||||
error_message=str(exc),
|
||||
)
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
except (websocket.WebSocketException, OSError) as exc:
|
||||
@@ -218,8 +292,15 @@ class ProxyService:
|
||||
provider.name,
|
||||
exc,
|
||||
)
|
||||
finalize_attempt(
|
||||
error_type=previous_error_type,
|
||||
error_message=str(exc),
|
||||
)
|
||||
if attempt < max_retries - 1:
|
||||
continue
|
||||
except Exception:
|
||||
finalize_attempt()
|
||||
raise
|
||||
|
||||
if last_error is None:
|
||||
last_error = ProxyErrorInfo(
|
||||
@@ -241,18 +322,30 @@ class ProxyService:
|
||||
opened: OpenedUpstreamResponse,
|
||||
on_complete: Optional[Callable[[Dict[str, Any]], None]],
|
||||
forward_stream_usage: bool,
|
||||
finalize_attempt: Optional[Callable[..., None]] = None,
|
||||
) -> Response:
|
||||
response = opened.response
|
||||
meta = self._create_empty_meta()
|
||||
completed = False
|
||||
terminal_sent = False
|
||||
|
||||
def safe_on_complete() -> None:
|
||||
def safe_on_complete(
|
||||
*,
|
||||
error_type: Optional[HookErrorType] = None,
|
||||
error_message: Optional[str] = None,
|
||||
) -> None:
|
||||
nonlocal completed
|
||||
if completed:
|
||||
return
|
||||
completed = True
|
||||
if on_complete:
|
||||
if finalize_attempt is not None:
|
||||
finalize_attempt(
|
||||
status_code=(opened.status_code if error_type is None else None),
|
||||
error_type=error_type,
|
||||
error_message=error_message,
|
||||
usage=(meta if error_type is None else None),
|
||||
)
|
||||
if on_complete and error_type is None:
|
||||
try:
|
||||
on_complete(meta)
|
||||
except Exception as exc:
|
||||
@@ -261,6 +354,8 @@ class ProxyService:
|
||||
def generate() -> Iterator[bytes]:
|
||||
nonlocal terminal_sent
|
||||
state: Dict[str, Any] = {}
|
||||
completion_error_type: Optional[HookErrorType] = None
|
||||
completion_error_message: Optional[str] = None
|
||||
try:
|
||||
upstream_chunks = response.iter_content(chunk_size=None)
|
||||
for event in decode_stream_events(upstream_chunks, opened.stream_format):
|
||||
@@ -302,11 +397,29 @@ class ProxyService:
|
||||
)
|
||||
if encoded_terminal:
|
||||
yield encoded_terminal
|
||||
except requests.exceptions.RequestException as exc:
|
||||
completion_error_type = self._classify_request_error(exc)
|
||||
completion_error_message = str(exc)
|
||||
self._logger.error("Streamed HTTP upstream error: provider=%s error=%s", provider.name, exc)
|
||||
raise
|
||||
except (websocket.WebSocketException, OSError) as exc:
|
||||
completion_error_type = self._classify_websocket_error(exc)
|
||||
completion_error_message = str(exc)
|
||||
self._logger.error("Streamed WebSocket upstream error: provider=%s error=%s", provider.name, exc)
|
||||
raise
|
||||
except Exception as exc:
|
||||
completion_error_type = HookErrorType.TRANSPORT_ERROR
|
||||
completion_error_message = str(exc)
|
||||
self._logger.error("Streamed upstream processing error: provider=%s error=%s", provider.name, exc)
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
response.close()
|
||||
finally:
|
||||
safe_on_complete()
|
||||
safe_on_complete(
|
||||
error_type=completion_error_type,
|
||||
error_message=completion_error_message,
|
||||
)
|
||||
|
||||
headers = self._filter_response_headers(getattr(response, "headers", {}))
|
||||
headers["Content-Type"] = "text/event-stream; charset=utf-8"
|
||||
@@ -327,6 +440,7 @@ class ProxyService:
|
||||
translated_request: Dict[str, Any],
|
||||
opened: OpenedUpstreamResponse,
|
||||
on_complete: Optional[Callable[[Dict[str, Any]], None]],
|
||||
finalize_attempt: Optional[Callable[..., None]] = None,
|
||||
) -> Response:
|
||||
response = opened.response
|
||||
try:
|
||||
@@ -349,6 +463,8 @@ class ProxyService:
|
||||
on_complete(meta)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error in on_complete callback: %s", exc)
|
||||
if finalize_attempt is not None:
|
||||
finalize_attempt(status_code=opened.status_code, usage=meta)
|
||||
|
||||
response_body = encode_downstream_response_body(body_to_send, provider.target_format)
|
||||
headers = self._filter_response_headers(getattr(response, "headers", {}))
|
||||
@@ -361,7 +477,11 @@ class ProxyService:
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
def _build_error_response(self, provider: LLMProvider, opened: OpenedUpstreamResponse) -> Response:
|
||||
def _consume_upstream_error(
|
||||
self,
|
||||
provider: LLMProvider,
|
||||
opened: OpenedUpstreamResponse,
|
||||
) -> tuple[bytes, Dict[str, str], Optional[str]]:
|
||||
response = opened.response
|
||||
try:
|
||||
body = self._read_response_body(response)
|
||||
@@ -379,10 +499,18 @@ class ProxyService:
|
||||
headers = self._filter_response_headers(getattr(response, "headers", {}))
|
||||
if opened.content_type:
|
||||
headers["Content-Type"] = opened.content_type
|
||||
return Response(body, status=opened.status_code, headers=headers)
|
||||
return body, headers, summary
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
def _build_error_response(
|
||||
self,
|
||||
provider: LLMProvider,
|
||||
opened: OpenedUpstreamResponse,
|
||||
) -> tuple[Response, Optional[str]]:
|
||||
body, headers, summary = self._consume_upstream_error(provider, opened)
|
||||
return Response(body, status=opened.status_code, headers=headers), summary
|
||||
|
||||
def _guard_stream_chunk(
|
||||
self,
|
||||
provider: LLMProvider,
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask
|
||||
import yaml
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from src.application.app_context import AppContext
|
||||
from src.config import build_auth_group_schemas, build_provider_schemas
|
||||
from src.config.auth_group_manager import AuthGroupManager, AuthGroupSelectionError
|
||||
from src.config.provider_config import AuthGroupSchema, ProviderConfigSchema
|
||||
from src.config.config_manager import ConfigManager
|
||||
from src.repositories import AuthGroupRepository
|
||||
from src.services.auth_group_service import AuthGroupService
|
||||
from src.utils.database import create_connection_factory
|
||||
from src.utils.local_time import parse_local_datetime
|
||||
|
||||
|
||||
class FakeLogger:
|
||||
def info(self, msg: str, *args) -> None:
|
||||
del msg, args
|
||||
|
||||
def warning(self, msg: str, *args) -> None:
|
||||
del msg, args
|
||||
|
||||
def error(self, msg: str, *args) -> None:
|
||||
del msg, args
|
||||
|
||||
def debug(self, msg: str, *args) -> None:
|
||||
del msg, args
|
||||
|
||||
|
||||
class AuthGroupConfigTests(unittest.TestCase):
|
||||
def test_provider_rejects_multiple_auth_bindings(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "either auth_group or api_key, not both"):
|
||||
ProviderConfigSchema.from_mapping(
|
||||
{
|
||||
"name": "demo",
|
||||
"api": "https://example.com/v1/chat/completions",
|
||||
"auth_group": "pool-a",
|
||||
"api_key": "sk-demo",
|
||||
"model_list": ["gpt-4.1"],
|
||||
}
|
||||
)
|
||||
|
||||
def test_provider_allows_empty_auth_binding_for_hook_or_public_upstream(self) -> None:
|
||||
schema = ProviderConfigSchema.from_mapping(
|
||||
{
|
||||
"name": "demo",
|
||||
"api": "https://example.com/v1/chat/completions",
|
||||
"model_list": ["gpt-4.1"],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertIsNone(schema.auth_group)
|
||||
self.assertIsNone(schema.api_key)
|
||||
|
||||
def test_build_provider_schemas_rejects_unknown_auth_group_even_when_no_groups_exist(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "unknown auth_group: pool-a"):
|
||||
build_provider_schemas(
|
||||
[
|
||||
{
|
||||
"name": "demo",
|
||||
"api": "https://example.com/v1/chat/completions",
|
||||
"auth_group": "pool-a",
|
||||
"model_list": ["gpt-4.1"],
|
||||
}
|
||||
],
|
||||
available_auth_group_names=set(),
|
||||
)
|
||||
|
||||
|
||||
class AuthGroupManagerTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
db_path = Path(self.temp_dir.name) / "auth-groups.db"
|
||||
self.ctx = AppContext(
|
||||
logger=FakeLogger(),
|
||||
config_manager=None, # type: ignore[arg-type]
|
||||
root_path=Path(self.temp_dir.name),
|
||||
flask_app=Flask(__name__),
|
||||
)
|
||||
self.repository = AuthGroupRepository(create_connection_factory(db_path))
|
||||
self.manager = AuthGroupManager(self.ctx, self.repository)
|
||||
self.group = AuthGroupSchema.from_mapping(
|
||||
{
|
||||
"name": "pool-a",
|
||||
"strategy": "least_inflight",
|
||||
"cooldown_seconds_on_429": 30,
|
||||
"entries": [
|
||||
{
|
||||
"id": "key-a",
|
||||
"headers": {"Authorization": "Bearer sk-a"},
|
||||
"max_concurrency": 2,
|
||||
"cooldown_seconds_on_429": 45,
|
||||
},
|
||||
{
|
||||
"id": "key-b",
|
||||
"headers": {"Authorization": "Bearer sk-b"},
|
||||
"max_concurrency": 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
self.manager.load_auth_groups((self.group,))
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_least_inflight_selection_uses_rotation_to_spread_equal_load(self) -> None:
|
||||
first = self.manager.acquire("pool-a")
|
||||
second = self.manager.acquire("pool-a")
|
||||
|
||||
self.assertEqual("key-a", first.entry_id)
|
||||
self.assertEqual("key-b", second.entry_id)
|
||||
|
||||
def test_finish_success_persists_request_and_token_usage(self) -> None:
|
||||
selection = self.manager.acquire("pool-a")
|
||||
self.manager.mark_request_dispatched(selection)
|
||||
self.manager.finish(
|
||||
selection,
|
||||
status_code=200,
|
||||
usage={
|
||||
"prompt_tokens": 12,
|
||||
"completion_tokens": 8,
|
||||
"total_tokens": 20,
|
||||
},
|
||||
)
|
||||
|
||||
runtime = self.manager.get_auth_group_runtime("pool-a")
|
||||
entry = next(item for item in runtime["entries"] if item["id"] == "key-a")
|
||||
|
||||
self.assertEqual("available", entry["status"])
|
||||
self.assertEqual(1, entry["minute_request_count"])
|
||||
self.assertEqual(20, entry["minute_total_tokens"])
|
||||
|
||||
def test_429_cools_down_only_current_entry_using_retry_after_priority(self) -> None:
|
||||
selection = self.manager.acquire("pool-a")
|
||||
self.manager.mark_request_dispatched(selection)
|
||||
self.manager.finish(
|
||||
selection,
|
||||
status_code=429,
|
||||
response_headers={"Retry-After": "120"},
|
||||
error_message="rate limited",
|
||||
)
|
||||
|
||||
runtime = self.manager.get_auth_group_runtime("pool-a")
|
||||
cooled_entry = next(item for item in runtime["entries"] if item["id"] == "key-a")
|
||||
other_entry = next(item for item in runtime["entries"] if item["id"] == "key-b")
|
||||
|
||||
self.assertEqual("cooldown", cooled_entry["status"])
|
||||
cooldown_until = parse_local_datetime(cooled_entry["cooldown_until"])
|
||||
self.assertIsNotNone(cooldown_until)
|
||||
self.assertEqual("available", other_entry["status"])
|
||||
|
||||
next_selection = self.manager.acquire("pool-a")
|
||||
self.assertEqual("key-b", next_selection.entry_id)
|
||||
|
||||
def test_clear_entry_cooldown_restores_entry_to_available(self) -> None:
|
||||
selection = self.manager.acquire("pool-a")
|
||||
self.manager.mark_request_dispatched(selection)
|
||||
self.manager.finish(
|
||||
selection,
|
||||
status_code=429,
|
||||
response_headers={"Retry-After": "120"},
|
||||
error_message="rate limited",
|
||||
)
|
||||
|
||||
self.manager.clear_entry_cooldown("pool-a", "key-a")
|
||||
runtime = self.manager.get_auth_group_runtime("pool-a")
|
||||
entry = next(item for item in runtime["entries"] if item["id"] == "key-a")
|
||||
|
||||
self.assertEqual("available", entry["status"])
|
||||
self.assertIsNone(entry["cooldown_until"])
|
||||
|
||||
def test_401_disables_entry_until_restore(self) -> None:
|
||||
selection = self.manager.acquire("pool-a")
|
||||
self.manager.mark_request_dispatched(selection)
|
||||
self.manager.finish(selection, status_code=401, error_message="bad key")
|
||||
|
||||
runtime = self.manager.get_auth_group_runtime("pool-a")
|
||||
disabled_entry = next(item for item in runtime["entries"] if item["id"] == "key-a")
|
||||
self.assertEqual("disabled", disabled_entry["status"])
|
||||
|
||||
next_selection = self.manager.acquire("pool-a")
|
||||
self.assertEqual("key-b", next_selection.entry_id)
|
||||
|
||||
self.manager.restore_entry("pool-a", "key-a")
|
||||
restored_runtime = self.manager.get_auth_group_runtime("pool-a")
|
||||
restored_entry = next(item for item in restored_runtime["entries"] if item["id"] == "key-a")
|
||||
self.assertEqual("available", restored_entry["status"])
|
||||
|
||||
def test_manual_disable_and_enable_entry_updates_runtime_status(self) -> None:
|
||||
self.manager.set_entry_disabled("pool-a", "key-a", disabled=True)
|
||||
disabled_runtime = self.manager.get_auth_group_runtime("pool-a")
|
||||
disabled_entry = next(item for item in disabled_runtime["entries"] if item["id"] == "key-a")
|
||||
|
||||
self.assertEqual("disabled", disabled_entry["status"])
|
||||
self.assertEqual("manual_disabled", disabled_entry["disabled_reason"])
|
||||
|
||||
self.manager.set_entry_disabled("pool-a", "key-a", disabled=False)
|
||||
enabled_runtime = self.manager.get_auth_group_runtime("pool-a")
|
||||
enabled_entry = next(item for item in enabled_runtime["entries"] if item["id"] == "key-a")
|
||||
|
||||
self.assertEqual("available", enabled_entry["status"])
|
||||
self.assertIsNone(enabled_entry["disabled_reason"])
|
||||
|
||||
def test_reset_entry_minute_usage_clears_only_current_minute_bucket(self) -> None:
|
||||
selection = self.manager.acquire("pool-a")
|
||||
self.manager.mark_request_dispatched(selection)
|
||||
self.manager.finish(
|
||||
selection,
|
||||
status_code=200,
|
||||
usage={
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 5,
|
||||
"total_tokens": 15,
|
||||
},
|
||||
)
|
||||
|
||||
before_reset = self.manager.get_auth_group_runtime("pool-a")
|
||||
before_entry = next(item for item in before_reset["entries"] if item["id"] == "key-a")
|
||||
self.assertEqual(1, before_entry["minute_request_count"])
|
||||
self.assertEqual(15, before_entry["minute_total_tokens"])
|
||||
self.assertEqual(1, before_entry["day_request_count"])
|
||||
self.assertEqual(15, before_entry["day_total_tokens"])
|
||||
|
||||
self.manager.reset_entry_minute_usage("pool-a", "key-a")
|
||||
after_reset = self.manager.get_auth_group_runtime("pool-a")
|
||||
after_entry = next(item for item in after_reset["entries"] if item["id"] == "key-a")
|
||||
|
||||
self.assertEqual(0, after_entry["minute_request_count"])
|
||||
self.assertEqual(0, after_entry["minute_total_tokens"])
|
||||
self.assertEqual(1, after_entry["day_request_count"])
|
||||
self.assertEqual(15, after_entry["day_total_tokens"])
|
||||
|
||||
def test_reset_entry_runtime_clears_runtime_flags_and_current_usage(self) -> None:
|
||||
selection = self.manager.acquire("pool-a")
|
||||
self.manager.mark_request_dispatched(selection)
|
||||
self.manager.finish(
|
||||
selection,
|
||||
status_code=429,
|
||||
response_headers={"Retry-After": "120"},
|
||||
error_message="rate limited",
|
||||
)
|
||||
self.manager.set_entry_disabled("pool-a", "key-a", disabled=True)
|
||||
|
||||
before_reset = self.manager.get_auth_group_runtime("pool-a")
|
||||
before_entry = next(item for item in before_reset["entries"] if item["id"] == "key-a")
|
||||
self.assertEqual("disabled", before_entry["status"])
|
||||
self.assertEqual("manual_disabled", before_entry["disabled_reason"])
|
||||
self.assertIsNotNone(before_entry["cooldown_until"])
|
||||
self.assertEqual(429, before_entry["last_status_code"])
|
||||
self.assertEqual("rate limited", before_entry["last_error_message"])
|
||||
self.assertEqual(1, before_entry["minute_request_count"])
|
||||
self.assertEqual(1, before_entry["day_request_count"])
|
||||
|
||||
self.manager.reset_entry_runtime("pool-a", "key-a")
|
||||
|
||||
after_reset = self.manager.get_auth_group_runtime("pool-a")
|
||||
after_entry = next(item for item in after_reset["entries"] if item["id"] == "key-a")
|
||||
self.assertEqual("available", after_entry["status"])
|
||||
self.assertFalse(after_entry["disabled"])
|
||||
self.assertIsNone(after_entry["disabled_reason"])
|
||||
self.assertIsNone(after_entry["cooldown_until"])
|
||||
self.assertIsNone(after_entry["last_status_code"])
|
||||
self.assertIsNone(after_entry["last_error_type"])
|
||||
self.assertIsNone(after_entry["last_error_message"])
|
||||
self.assertEqual(0, after_entry["minute_request_count"])
|
||||
self.assertEqual(0, after_entry["day_request_count"])
|
||||
self.assertEqual(0, after_entry["minute_total_tokens"])
|
||||
self.assertEqual(0, after_entry["day_total_tokens"])
|
||||
|
||||
|
||||
class AuthGroupServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.root_path = Path(self.temp_dir.name)
|
||||
self.config_path = self.root_path / "config.yaml"
|
||||
self.db_path = self.root_path / "requests.db"
|
||||
self._write_config({"auth_groups": [], "providers": []})
|
||||
self.config_manager = ConfigManager(self.config_path, self.root_path)
|
||||
self.ctx = AppContext(
|
||||
logger=FakeLogger(),
|
||||
config_manager=self.config_manager,
|
||||
root_path=self.root_path,
|
||||
flask_app=Flask(__name__),
|
||||
)
|
||||
self.repository = AuthGroupRepository(create_connection_factory(self.db_path))
|
||||
self.manager = AuthGroupManager(self.ctx, self.repository)
|
||||
self.reload_count = 0
|
||||
|
||||
def reload_callback() -> None:
|
||||
self.reload_count += 1
|
||||
self.config_manager.reload()
|
||||
raw = self.config_manager.get_raw_config()
|
||||
self.manager.load_auth_groups(build_auth_group_schemas(raw.get("auth_groups", []) or []))
|
||||
|
||||
self.reload_callback = reload_callback
|
||||
self.service = AuthGroupService(self.ctx, self.reload_callback, self.manager)
|
||||
self.reload_callback()
|
||||
self.reload_count = 0
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def _write_config(self, payload) -> None:
|
||||
with open(self.config_path, "w", encoding="utf-8") as handle:
|
||||
yaml.safe_dump(payload, handle, allow_unicode=True, sort_keys=False)
|
||||
|
||||
def test_create_auth_group_persists_and_lists_summary(self) -> None:
|
||||
created = self.service.create_auth_group(
|
||||
{
|
||||
"name": "pool-a",
|
||||
"strategy": "least_inflight",
|
||||
"cooldown_seconds_on_429": 60,
|
||||
"entries": [
|
||||
{
|
||||
"id": "key-a",
|
||||
"headers": {"Authorization": "Bearer sk-a"},
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("pool-a", created["name"])
|
||||
self.assertEqual(1, self.reload_count)
|
||||
listed = self.service.list_auth_groups()
|
||||
self.assertEqual(["pool-a"], [item["name"] for item in listed])
|
||||
|
||||
def test_import_auth_entries_accepts_yaml_list(self) -> None:
|
||||
entries = self.service.import_auth_entries(
|
||||
"""
|
||||
- id: key-a
|
||||
headers:
|
||||
Authorization: Bearer sk-a
|
||||
max_concurrency: 3
|
||||
- id: key-b
|
||||
headers:
|
||||
x-api-key: abc
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(2, len(entries))
|
||||
self.assertEqual("key-a", entries[0]["id"])
|
||||
self.assertEqual("Bearer sk-a", entries[0]["headers"]["Authorization"])
|
||||
self.assertEqual("key-b", entries[1]["id"])
|
||||
|
||||
def test_import_auth_entries_accepts_entries_wrapper(self) -> None:
|
||||
entries = self.service.import_auth_entries(
|
||||
"""
|
||||
entries:
|
||||
- id: key-a
|
||||
headers:
|
||||
Authorization: Bearer sk-a
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(entries))
|
||||
self.assertEqual("key-a", entries[0]["id"])
|
||||
|
||||
def test_import_auth_entries_rejects_invalid_yaml_shape(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "entries 必须是列表"):
|
||||
self.service.import_auth_entries(
|
||||
"""
|
||||
entries:
|
||||
id: key-a
|
||||
headers:
|
||||
Authorization: Bearer sk-a
|
||||
"""
|
||||
)
|
||||
|
||||
def test_import_auth_entries_rejects_duplicate_entry_ids(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "检测到重复的 Auth Entry ID: key-a"):
|
||||
self.service.import_auth_entries(
|
||||
"""
|
||||
entries:
|
||||
- id: key-a
|
||||
headers:
|
||||
Authorization: Bearer sk-a
|
||||
- id: key-a
|
||||
headers:
|
||||
Authorization: Bearer sk-b
|
||||
"""
|
||||
)
|
||||
|
||||
def test_update_auth_group_renames_provider_reference(self) -> None:
|
||||
self._write_config(
|
||||
{
|
||||
"auth_groups": [
|
||||
{
|
||||
"name": "pool-a",
|
||||
"strategy": "least_inflight",
|
||||
"entries": [{"id": "key-a", "headers": {"Authorization": "Bearer sk-a"}}],
|
||||
}
|
||||
],
|
||||
"providers": [
|
||||
{
|
||||
"name": "demo",
|
||||
"api": "https://example.com/v1/chat/completions",
|
||||
"auth_group": "pool-a",
|
||||
"model_list": ["gpt-4.1"],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
self.reload_callback()
|
||||
|
||||
updated = self.service.update_auth_group(
|
||||
"pool-a",
|
||||
{
|
||||
"name": "pool-b",
|
||||
"strategy": "least_inflight",
|
||||
"entries": [{"id": "key-a", "headers": {"Authorization": "Bearer sk-a"}}],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual("pool-b", updated["name"])
|
||||
current_config = self.config_manager.get_raw_config()
|
||||
self.assertEqual("pool-b", current_config["providers"][0]["auth_group"])
|
||||
|
||||
def test_delete_referenced_auth_group_is_rejected(self) -> None:
|
||||
self._write_config(
|
||||
{
|
||||
"auth_groups": [
|
||||
{
|
||||
"name": "pool-a",
|
||||
"strategy": "least_inflight",
|
||||
"entries": [{"id": "key-a", "headers": {"Authorization": "Bearer sk-a"}}],
|
||||
}
|
||||
],
|
||||
"providers": [
|
||||
{
|
||||
"name": "demo",
|
||||
"api": "https://example.com/v1/chat/completions",
|
||||
"auth_group": "pool-a",
|
||||
"model_list": ["gpt-4.1"],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
self.reload_callback()
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "still referenced"):
|
||||
self.service.delete_auth_group("pool-a")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -43,6 +43,7 @@ class ProviderTransportTests(unittest.TestCase):
|
||||
{
|
||||
"name": "codex",
|
||||
"api": "wss://example.com/v1/chat/completions",
|
||||
"api_key": "demo-key",
|
||||
"model_list": ["gpt-4.1"],
|
||||
}
|
||||
)
|
||||
@@ -54,6 +55,7 @@ class ProviderTransportTests(unittest.TestCase):
|
||||
{
|
||||
"name": "codex",
|
||||
"api": "https://example.com/v1/chat/completions",
|
||||
"api_key": "demo-key",
|
||||
"model_list": ["gpt-4.1"],
|
||||
}
|
||||
)
|
||||
@@ -67,6 +69,7 @@ class ProviderTransportTests(unittest.TestCase):
|
||||
{
|
||||
"name": "codex",
|
||||
"api": "https://example.com/v1/chat/completions",
|
||||
"api_key": "demo-key",
|
||||
"transport": "websocket",
|
||||
"model_list": ["gpt-4.1"],
|
||||
}
|
||||
@@ -80,6 +83,7 @@ class ProviderTransportTests(unittest.TestCase):
|
||||
{
|
||||
"name": "bad-provider",
|
||||
"api": "wss://example.com/v1/chat/completions",
|
||||
"api_key": "demo-key",
|
||||
"transport": "http",
|
||||
"model_list": ["demo"],
|
||||
}
|
||||
@@ -90,6 +94,7 @@ class ProviderTransportTests(unittest.TestCase):
|
||||
{
|
||||
"name": "demo",
|
||||
"api": "https://example.com/v1/chat/completions",
|
||||
"api_key": "demo-key",
|
||||
"model_list": ["gpt-4.1"],
|
||||
}
|
||||
)
|
||||
@@ -106,6 +111,7 @@ class ProviderTransportTests(unittest.TestCase):
|
||||
{
|
||||
"name": "demo",
|
||||
"api": "https://example.com/v1/chat/completions",
|
||||
"api_key": "demo-key",
|
||||
"format": "openai_chat",
|
||||
"model_list": ["gpt-4.1"],
|
||||
}
|
||||
@@ -116,6 +122,7 @@ class ProviderTransportTests(unittest.TestCase):
|
||||
{
|
||||
"name": "demo",
|
||||
"api": "https://example.com/v1/chat/completions",
|
||||
"api_key": "demo-key",
|
||||
"stream_format": "sse_json",
|
||||
"model_list": ["gpt-4.1"],
|
||||
}
|
||||
@@ -256,6 +263,12 @@ class ProviderTemplateTransportTests(unittest.TestCase):
|
||||
self.assertIn('id="providerTransport"', html)
|
||||
self.assertIn('id="providerSourceFormat"', html)
|
||||
self.assertIn('id="providerTargetFormat"', html)
|
||||
self.assertIn('id="providerAuthMode"', html)
|
||||
self.assertIn('id="providerAuthGroup"', html)
|
||||
self.assertIn('id="authGroupsContainer"', html)
|
||||
self.assertIn('id="authGroupModal"', html)
|
||||
self.assertIn('id="authEntryImportModal"', html)
|
||||
self.assertIn('id="authGroupRuntimeModal"', html)
|
||||
self.assertNotIn('id="providerFormat"', html)
|
||||
self.assertNotIn('id="providerStreamFormat"', html)
|
||||
|
||||
@@ -267,10 +280,31 @@ class ProviderTemplateTransportTests(unittest.TestCase):
|
||||
self.assertIn('data-provider-help-topic="transport"', html)
|
||||
self.assertIn('data-provider-help-topic="source_format"', html)
|
||||
self.assertIn('data-provider-help-topic="target_format"', html)
|
||||
self.assertIn('data-provider-help-topic="auth_group_field"', html)
|
||||
self.assertIn('data-provider-help-topic="auth_groups_overview"', html)
|
||||
self.assertIn('data-provider-help-topic="auth_group_strategy"', html)
|
||||
self.assertIn('data-provider-help-topic="auth_entries_editor"', html)
|
||||
self.assertIn('data-provider-help-topic="fetch_models"', html)
|
||||
self.assertIn('id="providerHelpPopover"', html)
|
||||
self.assertIn("function toggleProviderHelp(", html)
|
||||
self.assertIn("function syncProviderHelpPopover()", html)
|
||||
self.assertIn("function renderAuthGroups()", html)
|
||||
self.assertIn("function saveAuthGroup()", html)
|
||||
self.assertIn("function saveAuthEntriesFromYaml()", html)
|
||||
self.assertIn("function disableAuthGroupEntry(", html)
|
||||
self.assertIn("function enableAuthGroupEntry(", html)
|
||||
self.assertIn("function resetAuthGroupEntryRuntime(", html)
|
||||
self.assertIn("function openAuthEntryErrorModal(", html)
|
||||
self.assertIn("function updateProviderAuthModeFields()", html)
|
||||
self.assertIn("/api/auth-groups", html)
|
||||
self.assertIn("/api/auth-groups/import-entries", html)
|
||||
self.assertIn("'disable'", html)
|
||||
self.assertIn("'enable'", html)
|
||||
self.assertIn("'reset'", html)
|
||||
self.assertIn("setupCustomSelect('providerAuthMode');", html)
|
||||
self.assertIn("setupCustomSelect('providerAuthGroup');", html)
|
||||
self.assertIn("renderCustomSelectOptions('providerAuthGroup');", html)
|
||||
self.assertIn("setupCustomSelect('authGroupStrategy');", html)
|
||||
self.assertIn("setupCustomSelect('providerTransport');", html)
|
||||
self.assertIn("setupCustomSelect('providerSourceFormat');", html)
|
||||
self.assertIn("setupCustomSelect('providerTargetFormat');", html)
|
||||
@@ -285,6 +319,33 @@ class ProviderTemplateTransportTests(unittest.TestCase):
|
||||
self.assertIn('toggleFilteredFetchedModels(this.checked)', html)
|
||||
self.assertIn('class="provider-model-cell"', html)
|
||||
self.assertIn('class="provider-meta-line"', html)
|
||||
self.assertIn('class="providers-table-shell auth-groups-table-shell"', html)
|
||||
self.assertIn('<th>Entry 数</th>', html)
|
||||
self.assertIn('placeholder="例如 openai-shared,供 Provider 绑定引用"', html)
|
||||
self.assertIn('placeholder="例如 60,表示该组默认遇到 429 冷却 60 秒"', html)
|
||||
self.assertIn('YAML 编辑', html)
|
||||
self.assertIn('id="authEntryImportYaml"', html)
|
||||
self.assertIn('这里编辑的是当前 Auth Entries 的完整 YAML', html)
|
||||
self.assertIn('插入 Entry 模板', html)
|
||||
self.assertIn('必填:Entry 唯一 ID', html)
|
||||
self.assertIn('可选:每分钟请求数上限', html)
|
||||
self.assertIn('新增 Entry', html)
|
||||
self.assertNotIn("新 Entry", html)
|
||||
self.assertIn('<span>Header 数</span>', html)
|
||||
self.assertIn('<span>限制概览</span>', html)
|
||||
self.assertIn('class="auth-entry-table-toggle-column"', html)
|
||||
self.assertIn("function toggleAuthEntryCard(", html)
|
||||
self.assertIn('class="btn-action btn-edit auth-entry-toggle-btn"', html)
|
||||
self.assertIn("function handleAuthEntrySummaryKeydown(", html)
|
||||
self.assertIn('onclick="toggleAuthEntryCard(this)"', html)
|
||||
self.assertNotIn('id="authEntryImportTemplate"', html)
|
||||
self.assertNotIn('复制模板', html)
|
||||
self.assertNotIn('填入模板', html)
|
||||
self.assertIn("function buildAuthEntriesYamlText(", html)
|
||||
self.assertIn("function insertAuthEntryYamlTemplate(", html)
|
||||
self.assertIn("function getSingleAuthEntryYamlTemplate(", html)
|
||||
self.assertIn("function saveAuthEntriesFromYaml(", html)
|
||||
self.assertIn('id="authEntryErrorModal"', html)
|
||||
self.assertIn("showActionError('保存 Provider'", html)
|
||||
self.assertIn("showActionError('删除 Provider'", html)
|
||||
self.assertIn("showActionError('拉取模型'", html)
|
||||
@@ -324,6 +385,8 @@ const sandbox = {{
|
||||
providerModelCount: {{ textContent: "" }},
|
||||
providerName: {{ value: " demo " }},
|
||||
providerApi: {{ value: " https://example.com/v1/chat/completions " }},
|
||||
providerAuthMode: {{ value: "auth_group" }},
|
||||
providerAuthGroup: {{ value: " shared-pool " }},
|
||||
providerTransport: {{ value: "http" }},
|
||||
providerSourceFormat: {{ value: "openai_chat" }},
|
||||
providerTargetFormat: {{ value: "openai_chat" }},
|
||||
@@ -348,8 +411,12 @@ const collectedAfter = sandbox.collectFormData();
|
||||
|
||||
process.stdout.write(JSON.stringify({{
|
||||
beforeModelList: collectedBefore.model_list,
|
||||
beforeAuthGroup: collectedBefore.auth_group,
|
||||
beforeApiKey: collectedBefore.api_key,
|
||||
afterTextarea: sandbox.document.elements.providerModelList.value,
|
||||
afterModelList: collectedAfter.model_list,
|
||||
afterAuthGroup: collectedAfter.auth_group,
|
||||
afterApiKey: collectedAfter.api_key,
|
||||
countText: sandbox.document.elements.providerModelCount.textContent,
|
||||
message: sandbox.messages[0]?.message || "",
|
||||
}}));
|
||||
@@ -363,8 +430,12 @@ process.stdout.write(JSON.stringify({{
|
||||
payload = json.loads(completed.stdout.decode("utf-8"))
|
||||
|
||||
self.assertEqual("beta\nAlpha\nalpha\nBeta", payload["beforeModelList"])
|
||||
self.assertEqual("shared-pool", payload["beforeAuthGroup"])
|
||||
self.assertEqual("", payload["beforeApiKey"])
|
||||
self.assertEqual("Alpha\nBeta\nalpha\nbeta", payload["afterTextarea"])
|
||||
self.assertEqual("Alpha\nBeta\nalpha\nbeta", payload["afterModelList"])
|
||||
self.assertEqual("shared-pool", payload["afterAuthGroup"])
|
||||
self.assertEqual("", payload["afterApiKey"])
|
||||
self.assertIn("4", payload["countText"])
|
||||
self.assertTrue(payload["message"])
|
||||
|
||||
|
||||
Reference in new issue
Block a user