Fix usage replay model remapping

This commit is contained in:
RWDai
2026-03-17 17:56:06 +08:00
committed by fawney19
parent 1d72a8f9c1
commit a4505b1281
3 changed files with 233 additions and 9 deletions

View File

@@ -238,6 +238,18 @@ export interface ReplayResponse {
response_headers: Record<string, string> response_headers: Record<string, string>
response_body: Record<string, unknown> response_body: Record<string, unknown>
response_time_ms: number response_time_ms: number
mapping?: {
source_model: string
original_target_model?: string | null
resolved_model: string
target_provider_id: string
target_provider: string
target_endpoint_id: string
target_api_format: string
replay_mode: string
mapping_applied: boolean
mapping_source: string
}
} }
export interface ModelBreakdown { export interface ModelBreakdown {

View File

@@ -239,6 +239,35 @@
<!-- 响应结果 --> <!-- 响应结果 -->
<template v-if="replayResult"> <template v-if="replayResult">
<div
v-if="replayResult.mapping"
class="border-b"
>
<div class="px-4 py-2 text-[11px] text-muted-foreground flex flex-col gap-1">
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono truncate">{{ replayResult.mapping.source_model }}</span>
<span class="text-muted-foreground/50"></span>
<span class="font-mono truncate">{{ replayResult.mapping.resolved_model }}</span>
<Badge
variant="outline"
class="text-[10px] px-1.5 py-0 h-4"
>
{{ formatReplayMode(replayResult.mapping.replay_mode) }}
</Badge>
</div>
<div class="flex flex-wrap gap-2 text-muted-foreground/60">
<span>Provider: {{ replayResult.mapping.target_provider }}</span>
<span>Endpoint: {{ replayResult.mapping.target_endpoint_id }}</span>
<span>Format: {{ replayResult.mapping.target_api_format || '-' }}</span>
</div>
<div
v-if="replayResult.mapping.mapping_source && replayResult.mapping.mapping_source !== 'none'"
class="text-muted-foreground/50"
>
{{ formatMappingSource(replayResult.mapping.mapping_source) }}
</div>
</div>
</div>
<!-- 响应头可折叠 --> <!-- 响应头可折叠 -->
<div class="border-b"> <div class="border-b">
<button <button
@@ -376,6 +405,32 @@ const responseHeaderCount = computed(() => {
return Object.keys(replayResult.value.response_headers).length return Object.keys(replayResult.value.response_headers).length
}) })
function formatReplayMode(mode?: string) {
switch (mode) {
case 'same_endpoint_reuse':
return '同端点复用'
case 'same_provider_remap':
return '同 Provider 重映射'
case 'cross_provider_remap':
return '跨 Provider 重映射'
default:
return mode || '-'
}
}
function formatMappingSource(source?: string) {
switch (source) {
case 'original_target_model':
return '映射来源: 复用原目标模型'
case 'model_mapping':
return '映射来源: 模型映射'
case 'none':
return '映射来源: 未映射'
default:
return source ? `映射来源: ${source}` : ''
}
}
// ---- 生命周期 ---- // ---- 生命周期 ----
watch(() => props.isOpen, async (isOpen) => { watch(() => props.isOpen, async (isOpen) => {

View File

@@ -1908,6 +1908,77 @@ class AdminUsageCurlAdapter(AdminApiAdapter):
} }
def _resolve_replay_mode(same_provider: bool, same_endpoint: bool) -> str:
if same_provider and same_endpoint:
return "same_endpoint_reuse"
if same_provider:
return "same_provider_remap"
return "cross_provider_remap"
async def _resolve_replay_model_name(
db: Session,
*,
source_model: str,
original_target_model: str | None,
target_provider: Provider,
target_endpoint: ProviderEndpoint,
target_api_key: ProviderAPIKey | None,
same_provider: bool,
same_endpoint: bool,
force_remap: bool,
) -> tuple[str, str]:
"""解析 replay 的最终模型名,并返回 mapping_source。"""
from src.services.model.mapper import ModelMapperMiddleware
target_api_format = (getattr(target_endpoint, "api_format", "") or "").strip().lower()
if same_provider and same_endpoint and original_target_model and not force_remap:
return original_target_model, "original_target_model"
mapper = ModelMapperMiddleware(db)
mapping = await mapper.get_mapping(source_model, str(target_provider.id))
if mapping and mapping.model:
affinity_key = target_api_key.id if target_api_key else None
mapped_name = mapping.model.select_provider_model_name(
affinity_key, api_format=target_api_format
)
return mapped_name, "model_mapping"
if same_provider and same_endpoint:
return source_model, "none"
provider_name = target_provider.name or str(target_provider.id)
endpoint_id = str(getattr(target_endpoint, "id", "") or "unknown")
api_format = target_api_format or "unknown"
raise HTTPException(
status_code=400,
detail=(
f"Target provider '{provider_name}' does not support model '{source_model}' "
f"(endpoint={endpoint_id}, api_format={api_format})"
),
)
def _apply_replay_model_to_body(
body: dict[str, Any],
resolved_model: str,
target_api_format: str | None,
) -> None:
"""根据目标格式决定是否写入 body.model。"""
from src.core.api_format.metadata import resolve_endpoint_definition
target_meta = (
resolve_endpoint_definition(target_api_format) if target_api_format else None
)
if target_meta is not None and not target_meta.model_in_body:
body.pop("model", None)
return
body["model"] = resolved_model
@dataclass @dataclass
class AdminUsageReplayAdapter(AdminApiAdapter): class AdminUsageReplayAdapter(AdminApiAdapter):
"""Replay a usage record request to the same or a different provider.""" """Replay a usage record request to the same or a different provider."""
@@ -1933,8 +2004,28 @@ class AdminUsageReplayAdapter(AdminApiAdapter):
original_api_family = ( original_api_family = (
original_api_format.split(":")[0] if ":" in original_api_format else "" original_api_format.split(":")[0] if ":" in original_api_format else ""
) )
original_request_body = usage_record.get_request_body()
body_override_payload = (
self.body_override if isinstance(self.body_override, dict) else None
)
override_model: str | None = None
if isinstance(body_override_payload, dict):
override_val = body_override_payload.get("model")
if isinstance(override_val, str) and override_val.strip():
override_model = override_val.strip()
# 确定目标端点和密钥 # 解析源模型名:优先 request_body.model缺失时回退 usage_record.model
source_model = usage_record.model
if override_model:
source_model = override_model
elif isinstance(original_request_body, dict):
raw_model = original_request_body.get("model")
if isinstance(raw_model, str) and raw_model.strip():
source_model = raw_model.strip()
original_target_model = usage_record.target_model
# 确定目标端点
target_pid = self.target_provider_id target_pid = self.target_provider_id
target_provider_obj: Provider | None = None target_provider_obj: Provider | None = None
if self.target_endpoint_id: if self.target_endpoint_id:
@@ -1994,6 +2085,16 @@ class AdminUsageReplayAdapter(AdminApiAdapter):
detail="Original endpoint not found, specify target_endpoint_id", detail="Original endpoint not found, specify target_endpoint_id",
) )
if not target_provider_obj:
target_provider_obj = (
db.query(Provider).filter(Provider.id == endpoint.provider_id).first()
)
if not target_provider_obj:
raise HTTPException(status_code=404, detail="Target provider not found")
if not target_pid:
target_pid = str(target_provider_obj.id)
# 确定 API Key # 确定 API Key
target_ep_format = (getattr(endpoint, "api_format", "") or "").strip().lower() target_ep_format = (getattr(endpoint, "api_format", "") or "").strip().lower()
provider_key = None provider_key = None
@@ -2036,6 +2137,53 @@ class AdminUsageReplayAdapter(AdminApiAdapter):
if not provider_key: if not provider_key:
raise HTTPException(status_code=404, detail="No API key available for replay") raise HTTPException(status_code=404, detail="No API key available for replay")
# 判定 replay 模式
original_provider_id = usage_record.provider_id
if not original_provider_id and usage_record.provider_endpoint_id:
original_endpoint = (
db.query(ProviderEndpoint)
.filter(ProviderEndpoint.id == usage_record.provider_endpoint_id)
.first()
)
if original_endpoint:
original_provider_id = str(original_endpoint.provider_id)
same_provider = bool(
original_provider_id and target_pid and str(original_provider_id) == str(target_pid)
)
same_endpoint = bool(
usage_record.provider_endpoint_id
and str(usage_record.provider_endpoint_id) == str(endpoint.id)
)
replay_mode = _resolve_replay_mode(same_provider, same_endpoint)
# 解析目标模型
resolved_model_name, mapping_source = await _resolve_replay_model_name(
db,
source_model=source_model,
original_target_model=original_target_model,
target_provider=target_provider_obj,
target_endpoint=endpoint,
target_api_key=provider_key,
same_provider=same_provider,
same_endpoint=same_endpoint,
force_remap=bool(override_model),
)
mapping_applied = mapping_source != "none"
mapping_info = {
"source_model": source_model,
"original_target_model": original_target_model,
"resolved_model": resolved_model_name,
"target_provider_id": str(target_provider_obj.id),
"target_provider": target_provider_obj.name,
"target_endpoint_id": str(endpoint.id),
"target_api_format": target_ep_format,
"replay_mode": replay_mode,
"mapping_applied": mapping_applied,
"mapping_source": mapping_source,
}
# 根据 auth_type 正确解析认证(支持 OAuth / Vertex AI / API Key # 根据 auth_type 正确解析认证(支持 OAuth / Vertex AI / API Key
try: try:
auth_headers, decrypted_auth_config = await _resolve_provider_auth( auth_headers, decrypted_auth_config = await _resolve_provider_auth(
@@ -2046,16 +2194,15 @@ class AdminUsageReplayAdapter(AdminApiAdapter):
raise HTTPException(status_code=500, detail="Failed to resolve provider authentication") raise HTTPException(status_code=500, detail="Failed to resolve provider authentication")
# 构建 URL # 构建 URL
model_name = usage_record.target_model or usage_record.model
url = _build_provider_url_safe( url = _build_provider_url_safe(
endpoint, model_name, False, provider_key, decrypted_auth_config endpoint, resolved_model_name, False, provider_key, decrypted_auth_config
) )
# 构建请求头Content-Type + 认证头 + 端点额外头) # 构建请求头Content-Type + 认证头 + 端点额外头)
headers = _build_fresh_headers(auth_headers, endpoint) headers = _build_fresh_headers(auth_headers, endpoint)
# 使用覆盖体或原始请求体 # 使用覆盖体或原始请求体
body = self.body_override or usage_record.get_request_body() or {} body = body_override_payload or original_request_body or {}
# 格式转换:如果存储的请求体格式与目标端点格式不同,需要转换 # 格式转换:如果存储的请求体格式与目标端点格式不同,需要转换
if isinstance(body, dict): if isinstance(body, dict):
@@ -2079,6 +2226,8 @@ class AdminUsageReplayAdapter(AdminApiAdapter):
) )
# 转换失败仍发送原始体,让用户看到上游的实际报错 # 转换失败仍发送原始体,让用户看到上游的实际报错
_apply_replay_model_to_body(body, resolved_model_name, target_format)
# 强制非流式以获取完整响应 # 强制非流式以获取完整响应
# Gemini 格式通过 URL 控制流式streamGenerateContent vs generateContent # Gemini 格式通过 URL 控制流式streamGenerateContent vs generateContent
# 不支持 body 中的 stream 字段,设置会导致 400 错误 # 不支持 body 中的 stream 字段,设置会导致 400 错误
@@ -2112,8 +2261,8 @@ class AdminUsageReplayAdapter(AdminApiAdapter):
if envelope: if envelope:
body, _ = envelope.wrap_request( body, _ = envelope.wrap_request(
body, body,
model=model_name or "", model=resolved_model_name or "",
url_model=model_name, url_model=resolved_model_name,
decrypted_auth_config=decrypted_auth_config, decrypted_auth_config=decrypted_auth_config,
) )
# envelope 可能注入额外请求头(如 Kiro 的 AWS 签名头、Codex 的 OAuth 头) # envelope 可能注入额外请求头(如 Kiro 的 AWS 签名头、Codex 的 OAuth 头)
@@ -2125,9 +2274,7 @@ class AdminUsageReplayAdapter(AdminApiAdapter):
# envelope 失败仍发送原始体 # envelope 失败仍发送原始体
# 获取提供商名称 # 获取提供商名称
provider_name = usage_record.provider_name provider_name = target_provider_obj.name if target_provider_obj else usage_record.provider_name
if target_provider_obj:
provider_name = target_provider_obj.name
# 发送请求 # 发送请求
try: try:
@@ -2172,6 +2319,15 @@ class AdminUsageReplayAdapter(AdminApiAdapter):
usage_id=self.usage_id, usage_id=self.usage_id,
target_provider=provider_name, target_provider=provider_name,
target_url=url, target_url=url,
source_model=source_model,
original_target_model=original_target_model,
resolved_model=resolved_model_name,
target_provider_id=str(target_provider_obj.id),
target_endpoint_id=str(endpoint.id),
target_api_format=target_ep_format,
replay_mode=replay_mode,
mapping_applied=mapping_applied,
mapping_source=mapping_source,
) )
return { return {
@@ -2181,6 +2337,7 @@ class AdminUsageReplayAdapter(AdminApiAdapter):
"response_headers": response_headers, "response_headers": response_headers,
"response_body": response_body, "response_body": response_body,
"response_time_ms": elapsed_ms, "response_time_ms": elapsed_ms,
"mapping": mapping_info,
} }