feat(test,export,headers): 模型测试复用统一运行时、实时进度展示、导出增强与请求头大小写保留

- 模型测试 failover 从手动 FailoverEngine 改为 TaskService.execute_sync_candidates 统一运行时
- 前端新增实时 trace 轮询进度展示(候选状态、测试账号、进度条)
- 用户导出/导入支持明文 Key 优先(版本升至 1.2),新增 email_verified 字段
- SENSITIVE_CREDENTIAL_FIELDS 统一到 provider_ops/types.py,补充 refresh_token
- 请求头大小写保留机制(resolve_header_name_case + HeaderBuilder.add 语义修改)
- Codex envelope 移除合成头部,保留客户端原始请求头
- endpoint_checker 支持自定义超时透传
- 新增 x-forwarded-scheme 到上游丢弃头部列表
This commit is contained in:
fawney19
2026-03-06 21:06:45 +08:00
parent 7950ba7dc5
commit 90760da499
28 changed files with 1485 additions and 429 deletions

View File

@@ -193,8 +193,8 @@ class CandidateResolver:
self,
all_candidates: list[ProviderCandidate],
request_id: str | None,
user_id: str,
user_api_key: ApiKey,
user_id: str | None,
user_api_key: ApiKey | None,
required_capabilities: dict[str, bool] | None = None,
*,
expand_retries: bool = True,

View File

@@ -50,7 +50,8 @@ class RequestDispatcher:
candidate_index: int,
retry_index: int,
candidate_record_id: str,
user_api_key: ApiKey,
user_api_key: ApiKey | None,
user_id: str | None,
request_func: Callable[..., Any],
request_id: str | None,
api_format: str,
@@ -112,6 +113,7 @@ class RequestDispatcher:
candidate_id=candidate_record_id,
candidate_index=candidate_index,
user_api_key=user_api_key,
user_id=user_id,
request_func=request_func,
request_id=request_id,
api_format=api_format,

View File

@@ -14,10 +14,8 @@ to extra_headers().
from __future__ import annotations
import uuid
from typing import Any
from src.config.settings import config
from src.services.provider.adapters.codex.context import (
CodexRequestContext,
get_codex_request_context,
@@ -30,36 +28,12 @@ class CodexOAuthEnvelope:
"""Provider envelope hooks for Codex OAuth upstream."""
name = "codex:oauth"
_CODEX_VERSION = "0.101.0"
_CODEX_ORIGINATOR = "codex_cli_rs"
def extra_headers(self) -> dict[str, str] | None:
# Keep these headers provider-scoped to avoid leaking to other upstreams.
headers: dict[str, str] = {
# Codex upstream is strict about Content-Type; variants like
# "application/json; charset=utf-8" are rejected.
"Content-Type": "application/json",
"Version": self._CODEX_VERSION,
"Session_id": str(uuid.uuid4()),
"Connection": "Keep-Alive",
"Originator": self._CODEX_ORIGINATOR,
}
# Compact endpoint is non-stream; normal responses endpoint expects SSE.
ctx = get_codex_request_context()
is_compact = bool(ctx.is_compact) if ctx else False
headers["Accept"] = "application/json" if is_compact else "text/event-stream"
ua = str(getattr(config, "internal_user_agent_openai_cli", "") or "").strip()
if ua:
headers["User-Agent"] = ua
# Add chatgpt-account-id from context (set by wrap_request).
# Context is NOT cleared here — build_codex_url reads is_compact from it later.
if ctx and ctx.account_id:
headers["Chatgpt-Account-Id"] = ctx.account_id
return headers
# Codex desktop clients already send the protocol-specific headers they need.
# Preserve the original request headers as much as possible and avoid injecting
# synthetic CLI identity headers here.
return None
def wrap_request(
self,

View File

@@ -24,6 +24,7 @@ from src.models.database import Provider
from src.services.provider_ops.architectures import ProviderConnector
from src.services.provider_ops.registry import get_registry
from src.services.provider_ops.types import (
SENSITIVE_CREDENTIAL_FIELDS,
ActionResult,
ActionStatus,
BalanceInfo,
@@ -94,17 +95,7 @@ class ProviderOpsService:
"""
# 凭据中需要加密的字段
SENSITIVE_FIELDS = {
"api_key",
"password",
"refresh_token",
"session_token",
"session_cookie",
"token_cookie",
"auth_cookie",
"cookie_string",
"cookie",
}
SENSITIVE_FIELDS = SENSITIVE_CREDENTIAL_FIELDS
def __init__(self, db: Session):
self.db = db

View File

@@ -9,6 +9,20 @@ from datetime import datetime, timezone
from enum import Enum
from typing import Any
SENSITIVE_CREDENTIAL_FIELDS = frozenset(
{
"api_key",
"password",
"refresh_token",
"session_token",
"session_cookie",
"token_cookie",
"auth_cookie",
"cookie_string",
"cookie",
}
)
class ConnectorAuthType(str, Enum):
"""连接器认证类型"""

View File

@@ -69,7 +69,8 @@ class RequestExecutor:
candidate: Any,
candidate_id: str,
candidate_index: int,
user_api_key: Any,
user_api_key: Any | None,
user_id: str | None = None,
request_func: Callable[..., Any],
request_id: str | None,
api_format: str,
@@ -93,8 +94,8 @@ class RequestExecutor:
provider_id=provider.id,
endpoint_id=endpoint.id,
key_id=key.id,
user_id=user_api_key.user_id,
api_key_id=user_api_key.id,
user_id=user_id if user_id is not None else getattr(user_api_key, "user_id", None),
api_key_id=getattr(user_api_key, "id", None),
is_cached_user=is_cached_user,
)

View File

@@ -187,6 +187,329 @@ class TaskService:
request_body=request_body,
)
async def execute_sync_candidates(
self,
*,
api_format: str,
model_name: str,
candidates: list[Any],
request_func: Callable[..., Any],
request_id: str | None = None,
current_user: User | None = None,
user_api_key: ApiKey | None = None,
is_stream: bool = False,
capability_requirements: dict[str, bool] | None = None,
request_body_ref: dict[str, Any] | None = None,
request_headers: dict[str, Any] | None = None,
request_body: dict[str, Any] | None = None,
affinity_key: str | None = None,
create_pending_usage: bool = False,
enable_cache_affinity: bool = False,
) -> ExecutionResult:
"""Execute a pre-built candidate set through the unified SYNC runtime."""
from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager
from src.services.rate_limit.concurrency_manager import get_concurrency_manager
from src.services.request.executor import RequestExecutor
if not request_id:
request_id = str(uuid4())
api_format_norm = normalize_endpoint_signature(api_format)
priority_mode = SystemConfigService.get_config(
self.db,
"provider_priority_mode",
CacheAwareScheduler.PRIORITY_MODE_PROVIDER,
)
scheduling_mode = SystemConfigService.get_config(
self.db,
"scheduling_mode",
CacheAwareScheduler.SCHEDULING_MODE_CACHE_AFFINITY,
)
cache_scheduler = await get_cache_aware_scheduler(
self.redis,
priority_mode=priority_mode,
scheduling_mode=scheduling_mode,
)
await cache_scheduler._ensure_initialized()
concurrency_manager = await get_concurrency_manager()
adaptive_manager = get_adaptive_rpm_manager()
request_executor = RequestExecutor(
db=self.db,
concurrency_manager=concurrency_manager,
adaptive_manager=adaptive_manager,
)
candidate_resolver = CandidateResolver(
db=self.db,
cache_scheduler=cache_scheduler,
)
error_classifier = ErrorClassifier(
db=self.db,
cache_scheduler=cache_scheduler,
adaptive_manager=adaptive_manager,
)
request_dispatcher = RequestDispatcher(
db=self.db,
request_executor=request_executor,
cache_scheduler=cache_scheduler if enable_cache_affinity else None,
)
resolved_user = current_user
if resolved_user is None and user_api_key is not None:
try:
resolved_user = user_api_key.user if hasattr(user_api_key, "user") else None
except Exception:
resolved_user = None
if resolved_user is None and getattr(user_api_key, "user_id", None):
resolved_user = self.db.query(User).filter(User.id == user_api_key.user_id).first()
user_id: str | None = None
if resolved_user is not None and getattr(resolved_user, "id", None):
user_id = str(resolved_user.id)
elif user_api_key is not None and getattr(user_api_key, "user_id", None):
user_id = str(user_api_key.user_id)
resolved_affinity_key = affinity_key
if not resolved_affinity_key:
api_key_id = getattr(user_api_key, "id", None) if user_api_key is not None else None
resolved_affinity_key = str(api_key_id) if api_key_id else f"internal-test:{request_id}"
if create_pending_usage:
try:
UsageService.create_pending_usage(
db=self.db,
request_id=request_id,
user=resolved_user,
api_key=user_api_key,
model=model_name,
is_stream=is_stream,
api_format=api_format_norm,
request_headers=request_headers,
request_body=request_body,
)
except Exception as exc:
logger.warning("创建 pending 使用记录失败: {}", str(exc))
all_candidates = list(candidates)
all_candidates, pool_traces = await self._apply_pool_reorder(
all_candidates, request_body=request_body
)
candidate_record_map = candidate_resolver.create_candidate_records(
all_candidates=all_candidates,
request_id=request_id,
user_id=user_id,
user_api_key=user_api_key,
required_capabilities=capability_requirements,
)
max_attempts = candidate_resolver.count_total_attempts(all_candidates)
last_error: Exception | None = None
last_candidate: Any | None = all_candidates[-1] if all_candidates else None
async def _attempt(candidate: Any) -> AttemptResult:
nonlocal last_candidate
last_candidate = candidate
candidate_index = int(getattr(candidate, "_utf_candidate_index", -1))
retry_index = int(getattr(candidate, "_utf_retry_index", 0))
candidate_record_id = str(getattr(candidate, "_utf_candidate_record_id", "") or "")
attempt_counter = int(getattr(candidate, "_utf_attempt_count", 0))
max_attempts_local = int(getattr(candidate, "_utf_max_attempts", max_attempts))
if not candidate_record_id:
from src.services.scheduling.schemas import PoolCandidate
pool_extra = (
getattr(candidate.key, "_pool_extra_data", None)
if isinstance(getattr(candidate.key, "_pool_extra_data", None), dict)
else {}
)
extra_data: dict[str, Any] = {
"needs_conversion": bool(getattr(candidate, "needs_conversion", False)),
"provider_api_format": getattr(candidate, "provider_api_format", None) or None,
"mapping_matched_model": getattr(candidate, "mapping_matched_model", None)
or None,
**pool_extra,
}
if isinstance(candidate, PoolCandidate):
extra_data["pool_group_id"] = str(candidate.provider.id)
extra_data["pool_key_index"] = int(
getattr(candidate, "_pool_key_index", 0) or 0
)
candidate_record = RequestCandidateService.create_candidate(
db=self.db,
request_id=request_id,
candidate_index=candidate_index,
retry_index=retry_index,
user_id=user_id,
api_key_id=(getattr(user_api_key, "id", None) if user_api_key else None),
provider_id=str(candidate.provider.id),
endpoint_id=str(candidate.endpoint.id),
key_id=str(candidate.key.id),
status="available",
is_cached=bool(getattr(candidate, "is_cached", False)),
extra_data=extra_data,
)
self.db.flush()
candidate_record_id = str(candidate_record.id)
candidate_record_map[(candidate_index, retry_index)] = candidate_record_id
setattr(candidate, "_utf_candidate_record_id", candidate_record_id)
(
response,
_provider_name,
attempt_id,
_provider_id,
_endpoint_id,
_key_id,
_first_byte_time_ms,
) = await request_dispatcher.dispatch(
candidate=candidate,
candidate_index=candidate_index,
retry_index=retry_index,
candidate_record_id=candidate_record_id,
user_api_key=user_api_key,
user_id=user_id,
request_func=request_func,
request_id=request_id,
api_format=api_format_norm,
model_name=model_name,
affinity_key=resolved_affinity_key,
global_model_id=model_name,
attempt_counter=attempt_counter,
max_attempts=max_attempts_local,
is_stream=is_stream,
)
_ = (attempt_id, _provider_name, _provider_id, _endpoint_id, _key_id)
await self._pool_on_success(
candidate,
request_body,
ttfb_ms=_first_byte_time_ms,
)
if is_stream:
return AttemptResult(
kind=AttemptKind.STREAM,
http_status=200,
http_headers={},
stream_iterator=response,
)
return AttemptResult(
kind=AttemptKind.SYNC_RESPONSE,
http_status=200,
http_headers={},
response_body=response,
)
async def _handle_exec_err(
*,
exec_err: Any,
candidate: Any,
candidate_index: int,
retry_index: int,
max_retries_for_candidate: int,
record_id: str | None,
attempt_count: int,
max_attempts: int | None,
) -> tuple[Any, int | None]:
nonlocal last_error, last_candidate
last_candidate = candidate
last_error = getattr(exec_err, "cause", None)
candidate_record_id = str(record_id or "") or str(
candidate_record_map.get((candidate_index, 0), "")
)
action = await self._handle_candidate_error(
exec_err=exec_err,
candidate=candidate,
candidate_record_id=candidate_record_id,
retry_index=retry_index,
max_retries_for_candidate=max_retries_for_candidate,
affinity_key=resolved_affinity_key,
api_format=api_format_norm,
global_model_id=model_name,
request_id=request_id,
attempt=attempt_count,
max_attempts=int(max_attempts or 0),
request_body_ref=request_body_ref,
error_classifier=error_classifier,
)
if action == "continue":
new_max = None
if request_body_ref and request_body_ref.get("_rectified_this_turn", False):
request_body_ref["_rectified_this_turn"] = False
new_max = max(max_retries_for_candidate, retry_index + 2)
return ("retry", new_max)
if action == "break":
return ("continue", None)
if action == "raise":
if last_error is not None:
self._attach_metadata_to_error(
last_error, last_candidate, model_name, api_format_norm
)
raise last_error
raise
return ("continue", None)
engine = FailoverEngine(
self.db,
error_classifier=error_classifier,
recorder=self._recorder,
)
result = await engine.execute(
candidates=all_candidates,
attempt_func=_attempt,
retry_policy=RetryPolicy.for_sync_task(),
skip_policy=SkipPolicy(),
request_id=request_id,
user_id=user_id,
api_key_id=(
str(user_api_key.id) if user_api_key and getattr(user_api_key, "id", None) else None
),
candidate_record_map=candidate_record_map,
max_attempts=max_attempts,
execution_error_handler=_handle_exec_err,
)
if result.success:
if pool_traces and result.key_id:
try:
attempted_key_ids: set[str] = set()
for ck in result.candidate_keys or []:
status = str(getattr(ck, "status", "") or "").strip().lower()
if status in {"", "available", "pending", "skipped", "unused"}:
continue
kid = getattr(ck, "key_id", None)
if isinstance(kid, str) and kid:
attempted_key_ids.add(kid)
if not attempted_key_ids:
attempted_key_ids.add(str(result.key_id))
for pt in pool_traces:
summary = pt.build_summary(
result.key_id,
attempted_key_ids=attempted_key_ids,
)
if summary:
result.pool_summary = summary
break
except Exception:
pass
return result
self._raise_all_failed_exception(
request_id, max_attempts, last_candidate, model_name, api_format_norm, last_error
)
@staticmethod
def _extract_session_uuid(
provider_type: str, request_body: dict[str, Any] | None
@@ -587,6 +910,7 @@ class TaskService:
retry_index=retry_index,
candidate_record_id=candidate_record_id,
user_api_key=user_api_key,
user_id=user_id,
request_func=request_func,
request_id=request_id,
api_format=api_format_norm,