mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor: 使用 data_format_id 重构格式兼容性判断逻辑
- 在 ApiFormatDefinition 中新增 data_format_id 字段标识数据格式 - 新增 can_passthrough() 函数判断格式间是否可透传 - 重构 compatibility.py 使用 data_format_id 替代 get_base_format() - 使用量记录支持从 endpoint 回填格式信息(兼容历史数据) - 前端优化格式转换显示逻辑,区分跨格式转换和同族格式差异 - 首页 header 响应式布局优化
This commit is contained in:
@@ -356,13 +356,13 @@
|
|||||||
class="py-4 w-[80px]"
|
class="py-4 w-[80px]"
|
||||||
:title="getApiFormatTooltip(record)"
|
:title="getApiFormatTooltip(record)"
|
||||||
>
|
>
|
||||||
<!-- 有格式转换:两行显示 -->
|
<!-- 有格式转换或同族格式差异:两行显示 -->
|
||||||
<div
|
<div
|
||||||
v-if="record.api_format && record.has_format_conversion && record.endpoint_api_format"
|
v-if="shouldShowFormatConversion(record)"
|
||||||
class="flex flex-col text-xs gap-0.5"
|
class="flex flex-col text-xs gap-0.5"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<span>{{ formatApiFormat(record.api_format) }}</span>
|
<span>{{ formatApiFormat(record.api_format!) }}</span>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
@@ -376,7 +376,7 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-muted-foreground">{{ formatApiFormat(record.endpoint_api_format) }}</span>
|
<span class="text-muted-foreground">{{ formatApiFormat(record.endpoint_api_format!) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 无格式转换:单行显示 -->
|
<!-- 无格式转换:单行显示 -->
|
||||||
<span
|
<span
|
||||||
@@ -700,6 +700,20 @@ function formatApiFormat(format: string): string {
|
|||||||
return formatMap[format.toUpperCase()] || format
|
return formatMap[format.toUpperCase()] || format
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 判断是否应该显示格式转换信息
|
||||||
|
// 包括:1. 跨格式转换(has_format_conversion=true)2. 同族格式差异(如 CLAUDE_CLI → CLAUDE)
|
||||||
|
function shouldShowFormatConversion(record: UsageRecord): boolean {
|
||||||
|
if (!record.api_format || !record.endpoint_api_format) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 跨格式转换
|
||||||
|
if (record.has_format_conversion) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 同族格式差异(精确字符串比较,不区分大小写)
|
||||||
|
return record.api_format.toUpperCase() !== record.endpoint_api_format.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
// 获取 API 格式的 tooltip(包含转换信息)
|
// 获取 API 格式的 tooltip(包含转换信息)
|
||||||
function getApiFormatTooltip(record: UsageRecord): string {
|
function getApiFormatTooltip(record: UsageRecord): string {
|
||||||
if (!record.api_format) {
|
if (!record.api_format) {
|
||||||
@@ -707,10 +721,11 @@ function getApiFormatTooltip(record: UsageRecord): string {
|
|||||||
}
|
}
|
||||||
const displayFormat = formatApiFormat(record.api_format)
|
const displayFormat = formatApiFormat(record.api_format)
|
||||||
|
|
||||||
// 如果发生了格式转换,显示详细信息
|
// 如果发生了格式转换或同族格式差异,显示详细信息
|
||||||
if (record.has_format_conversion && record.endpoint_api_format) {
|
if (shouldShowFormatConversion(record)) {
|
||||||
const endpointDisplayFormat = formatApiFormat(record.endpoint_api_format)
|
const endpointDisplayFormat = formatApiFormat(record.endpoint_api_format!)
|
||||||
return `用户请求格式: ${displayFormat}\n端点原生格式: ${endpointDisplayFormat}\n系统进行了 ${displayFormat} → ${endpointDisplayFormat} 格式转换`
|
const conversionType = record.has_format_conversion ? '格式转换' : '格式兼容(无需转换)'
|
||||||
|
return `用户请求格式: ${displayFormat}\n端点原生格式: ${endpointDisplayFormat}\n${conversionType}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return record.api_format
|
return record.api_format
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
ref="sectionRef"
|
ref="sectionRef"
|
||||||
class="min-h-screen snap-start flex items-center px-16 lg:px-20 py-20"
|
class="min-h-screen snap-start flex items-center px-4 sm:px-8 md:px-16 lg:px-20 py-20"
|
||||||
>
|
>
|
||||||
<div class="max-w-7xl mx-auto grid md:grid-cols-2 gap-12 items-center">
|
<div class="max-w-7xl mx-auto grid md:grid-cols-2 gap-12 items-center">
|
||||||
<!-- Content column -->
|
<!-- Content column -->
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="scrollContainer"
|
ref="scrollContainer"
|
||||||
class="relative h-screen overflow-y-auto snap-y snap-mandatory scroll-smooth literary-grid literary-paper"
|
class="relative h-screen overflow-y-auto overflow-x-hidden snap-y snap-mandatory scroll-smooth literary-grid literary-paper"
|
||||||
>
|
>
|
||||||
<!-- Fixed scroll indicator -->
|
<!-- Fixed scroll indicator -->
|
||||||
<nav class="scroll-indicator">
|
<nav class="scroll-indicator">
|
||||||
@@ -21,93 +21,93 @@
|
|||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="sticky top-0 z-50 border-b border-[#cc785c]/10 dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/90 dark:bg-[#191714]/95 backdrop-blur-xl transition-all">
|
<header class="sticky top-0 z-50 border-b border-[#cc785c]/10 dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/90 dark:bg-[#191714]/95 backdrop-blur-xl transition-all">
|
||||||
<div class="h-16 flex items-center">
|
<div class="h-14 sm:h-16 flex items-center px-3 sm:px-4 md:px-8">
|
||||||
<!-- Centered content container (max-w-7xl) -->
|
<!-- Left: Logo & Brand -->
|
||||||
<div class="mx-auto max-w-7xl w-full px-6 flex items-center justify-between">
|
<div
|
||||||
<!-- Left: Logo & Brand -->
|
class="flex items-center gap-2 sm:gap-3 group/logo cursor-pointer shrink-0"
|
||||||
<div
|
@click="scrollToSection(0)"
|
||||||
class="flex items-center gap-3 group/logo cursor-pointer"
|
>
|
||||||
@click="scrollToSection(0)"
|
<HeaderLogo
|
||||||
>
|
size="h-7 w-7 sm:h-9 sm:w-9"
|
||||||
<HeaderLogo
|
class-name="text-[#191919] dark:text-white"
|
||||||
size="h-9 w-9"
|
/>
|
||||||
class-name="text-[#191919] dark:text-white"
|
<div class="flex flex-col justify-center">
|
||||||
/>
|
<h1 class="text-base sm:text-lg font-bold text-[#191919] dark:text-white leading-none">
|
||||||
<div class="flex flex-col justify-center">
|
Aether
|
||||||
<h1 class="text-lg font-bold text-[#191919] dark:text-white leading-none">
|
</h1>
|
||||||
Aether
|
<span class="text-[9px] sm:text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1 sm:mt-1.5 font-medium tracking-wide">AI Gateway</span>
|
||||||
</h1>
|
|
||||||
<span class="text-[10px] text-[#91918d] dark:text-muted-foreground leading-none mt-1.5 font-medium tracking-wide">AI Gateway</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Center: Navigation -->
|
<!-- Center: Navigation (flexible, takes remaining space) -->
|
||||||
<nav class="hidden md:flex items-center gap-2">
|
<nav class="hidden md:flex items-center justify-center gap-2 flex-1 min-w-0">
|
||||||
<button
|
<button
|
||||||
v-for="(section, index) in sections"
|
v-for="(section, index) in sections"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="group relative px-3 py-2 text-sm font-medium transition"
|
class="group relative px-3 py-2 text-sm font-medium transition"
|
||||||
:class="currentSection === index
|
:class="currentSection === index
|
||||||
? 'text-[#cc785c] dark:text-[#d4a27f]'
|
? 'text-[#cc785c] dark:text-[#d4a27f]'
|
||||||
: 'text-[#666663] dark:text-muted-foreground hover:text-[#191919] dark:hover:text-white'"
|
: 'text-[#666663] dark:text-muted-foreground hover:text-[#191919] dark:hover:text-white'"
|
||||||
@click="scrollToSection(index)"
|
@click="scrollToSection(index)"
|
||||||
>
|
>
|
||||||
{{ section.name }}
|
{{ section.name }}
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-0 left-0 right-0 h-0.5 rounded-full transition-all duration-300"
|
class="absolute bottom-0 left-0 right-0 h-0.5 rounded-full transition-all duration-300"
|
||||||
:class="currentSection === index ? 'bg-[#cc785c] dark:bg-[#d4a27f] scale-x-100' : 'bg-transparent scale-x-0'"
|
:class="currentSection === index ? 'bg-[#cc785c] dark:bg-[#d4a27f] scale-x-100' : 'bg-transparent scale-x-0'"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Right: Login/Dashboard Button -->
|
<!-- Spacer for mobile (when nav is hidden) -->
|
||||||
|
<div class="flex-1 md:hidden" />
|
||||||
|
|
||||||
|
<!-- Right: Login/Dashboard Button + Icons -->
|
||||||
|
<div class="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||||
|
<!-- Login/Dashboard Button -->
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-if="authStore.isAuthenticated"
|
v-if="authStore.isAuthenticated"
|
||||||
:to="dashboardPath"
|
:to="dashboardPath"
|
||||||
class="min-w-[72px] text-center rounded-xl bg-[#191919] dark:bg-[#cc785c] px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-[#262625] dark:hover:bg-[#b86d52] whitespace-nowrap"
|
class="min-w-[60px] sm:min-w-[72px] text-center rounded-lg sm:rounded-xl bg-[#191919] dark:bg-[#cc785c] px-3 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm font-medium text-white shadow-sm transition hover:bg-[#262625] dark:hover:bg-[#b86d52] whitespace-nowrap"
|
||||||
>
|
>
|
||||||
控制台
|
控制台
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
class="min-w-[72px] text-center rounded-xl bg-[#cc785c] px-4 py-2 text-sm font-medium text-white shadow-lg shadow-[#cc785c]/30 transition hover:bg-[#d4a27f] whitespace-nowrap"
|
class="min-w-[60px] sm:min-w-[72px] text-center rounded-lg sm:rounded-xl bg-[#cc785c] px-3 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm font-medium text-white shadow-lg shadow-[#cc785c]/30 transition hover:bg-[#d4a27f] whitespace-nowrap"
|
||||||
@click="showLoginDialog = true"
|
@click="showLoginDialog = true"
|
||||||
>
|
>
|
||||||
登录
|
登录
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<!-- Theme Toggle + GitHub Icons -->
|
||||||
|
<div class="flex items-center gap-0.5 sm:gap-1 ml-0.5 sm:ml-1 md:ml-3 lg:ml-4">
|
||||||
<!-- Fixed right icons (px-8 to match dashboard) -->
|
<button
|
||||||
<div class="absolute right-8 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
class="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||||
<!-- Theme Toggle -->
|
:title="themeMode === 'system' ? '跟随系统' : themeMode === 'dark' ? '深色模式' : '浅色模式'"
|
||||||
<button
|
@click="toggleDarkMode"
|
||||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
>
|
||||||
:title="themeMode === 'system' ? '跟随系统' : themeMode === 'dark' ? '深色模式' : '浅色模式'"
|
<SunMoon
|
||||||
@click="toggleDarkMode"
|
v-if="themeMode === 'system'"
|
||||||
>
|
class="h-3.5 w-3.5 sm:h-4 sm:w-4"
|
||||||
<SunMoon
|
/>
|
||||||
v-if="themeMode === 'system'"
|
<Sun
|
||||||
class="h-4 w-4"
|
v-else-if="themeMode === 'light'"
|
||||||
/>
|
class="h-3.5 w-3.5 sm:h-4 sm:w-4"
|
||||||
<Sun
|
/>
|
||||||
v-else-if="themeMode === 'light'"
|
<Moon
|
||||||
class="h-4 w-4"
|
v-else
|
||||||
/>
|
class="h-3.5 w-3.5 sm:h-4 sm:w-4"
|
||||||
<Moon
|
/>
|
||||||
v-else
|
</button>
|
||||||
class="h-4 w-4"
|
<a
|
||||||
/>
|
href="https://github.com/fawney19/Aether"
|
||||||
</button>
|
target="_blank"
|
||||||
<!-- GitHub Link -->
|
rel="noopener noreferrer"
|
||||||
<a
|
class="flex h-8 w-8 sm:h-9 sm:w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
||||||
href="https://github.com/fawney19/Aether"
|
title="GitHub 仓库"
|
||||||
target="_blank"
|
>
|
||||||
rel="noopener noreferrer"
|
<GithubIcon class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
class="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted/50 transition"
|
</a>
|
||||||
title="GitHub 仓库"
|
</div>
|
||||||
>
|
|
||||||
<GithubIcon class="h-4 w-4" />
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -115,9 +115,9 @@
|
|||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="relative z-10">
|
<main class="relative z-10">
|
||||||
<!-- Fixed Logo Container -->
|
<!-- Fixed Logo Container -->
|
||||||
<div class="mt-4 fixed inset-0 z-20 pointer-events-none flex items-center justify-center overflow-hidden">
|
<div class="fixed top-0 left-0 right-0 bottom-0 z-20 pointer-events-none flex items-center justify-center overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="mt-16 transform-gpu logo-container"
|
class="transform-gpu logo-container"
|
||||||
:class="[currentSection === SECTIONS.HOME ? 'home-section' : '', `logo-transition-${scrollDirection}`]"
|
:class="[currentSection === SECTIONS.HOME ? 'home-section' : '', `logo-transition-${scrollDirection}`]"
|
||||||
:style="fixedLogoStyle"
|
:style="fixedLogoStyle"
|
||||||
>
|
>
|
||||||
@@ -161,18 +161,18 @@
|
|||||||
<!-- Section 0: Introduction -->
|
<!-- Section 0: Introduction -->
|
||||||
<section
|
<section
|
||||||
ref="section0"
|
ref="section0"
|
||||||
class="min-h-screen snap-start flex items-center justify-center px-16 lg:px-20 py-20"
|
class="min-h-screen snap-start flex items-center justify-center px-4 sm:px-8 md:px-16 lg:px-20 py-20"
|
||||||
>
|
>
|
||||||
<div class="max-w-4xl mx-auto text-center">
|
<div class="max-w-4xl mx-auto text-center">
|
||||||
<div class="h-80 w-full mb-16 mt-8" />
|
<div class="h-48 sm:h-64 md:h-80 w-full mb-8 sm:mb-12 md:mb-16 mt-4 sm:mt-8" />
|
||||||
<h1
|
<h1
|
||||||
class="mb-6 text-5xl md:text-7xl font-bold text-[#191919] dark:text-white leading-tight transition-all duration-700"
|
class="mb-6 text-3xl sm:text-5xl md:text-7xl font-bold text-[#191919] dark:text-white leading-tight transition-all duration-700"
|
||||||
:style="getTitleStyle(SECTIONS.HOME)"
|
:style="getTitleStyle(SECTIONS.HOME)"
|
||||||
>
|
>
|
||||||
欢迎使用 <span class="text-primary">Aether</span>
|
欢迎使用 <span class="text-primary">Aether</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
class="mb-8 text-xl text-[#666663] dark:text-gray-300 max-w-2xl mx-auto transition-all duration-700"
|
class="mb-8 text-base sm:text-lg md:text-xl text-[#666663] dark:text-gray-300 max-w-2xl mx-auto transition-all duration-700"
|
||||||
:style="getDescStyle(SECTIONS.HOME)"
|
:style="getDescStyle(SECTIONS.HOME)"
|
||||||
>
|
>
|
||||||
AI 开发工具统一接入平台<br>
|
AI 开发工具统一接入平台<br>
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
<!-- Section 4: Features -->
|
<!-- Section 4: Features -->
|
||||||
<section
|
<section
|
||||||
ref="section4"
|
ref="section4"
|
||||||
class="min-h-screen snap-start flex items-center justify-center px-16 lg:px-20 py-20 relative overflow-hidden"
|
class="min-h-screen snap-start flex items-center justify-center px-4 sm:px-8 md:px-16 lg:px-20 py-20 relative overflow-hidden"
|
||||||
>
|
>
|
||||||
<div class="max-w-4xl mx-auto text-center relative z-10">
|
<div class="max-w-4xl mx-auto text-center relative z-10">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -28,6 +28,5 @@ def main():
|
|||||||
print(" - 这些密钥应该独立设置,避免相互耦合")
|
print(" - 这些密钥应该独立设置,避免相互耦合")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -730,6 +730,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
|||||||
async def handle(self, context): # type: ignore[override]
|
async def handle(self, context): # type: ignore[override]
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from src.models.database import ProviderEndpoint
|
||||||
from src.utils.database_helpers import escape_like_pattern, safe_truncate_escaped
|
from src.utils.database_helpers import escape_like_pattern, safe_truncate_escaped
|
||||||
|
|
||||||
db = context.db
|
db = context.db
|
||||||
@@ -837,8 +838,9 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
|||||||
summary_by_provider = sorted(summary_by_provider, key=lambda x: x["requests"], reverse=True)
|
summary_by_provider = sorted(summary_by_provider, key=lambda x: x["requests"], reverse=True)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
db.query(Usage, ApiKey)
|
db.query(Usage, ApiKey, ProviderEndpoint)
|
||||||
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
|
.outerjoin(ApiKey, Usage.api_key_id == ApiKey.id)
|
||||||
|
.outerjoin(ProviderEndpoint, Usage.provider_endpoint_id == ProviderEndpoint.id)
|
||||||
.filter(Usage.user_id == user.id)
|
.filter(Usage.user_id == user.id)
|
||||||
)
|
)
|
||||||
if self.start_date:
|
if self.start_date:
|
||||||
@@ -894,50 +896,14 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
|||||||
"offset": self.offset,
|
"offset": self.offset,
|
||||||
"has_more": self.offset + self.limit < total_records,
|
"has_more": self.offset + self.limit < total_records,
|
||||||
},
|
},
|
||||||
"records": [
|
"records": self._build_usage_records(usage_records),
|
||||||
{
|
|
||||||
"id": r.id,
|
|
||||||
"model": r.model,
|
|
||||||
"target_model": r.target_model, # 映射后的目标模型名
|
|
||||||
"api_format": r.api_format,
|
|
||||||
"endpoint_api_format": r.endpoint_api_format,
|
|
||||||
"has_format_conversion": bool(r.has_format_conversion) if r.has_format_conversion is not None else False,
|
|
||||||
"input_tokens": r.input_tokens,
|
|
||||||
"output_tokens": r.output_tokens,
|
|
||||||
"total_tokens": r.total_tokens,
|
|
||||||
"cost": r.total_cost_usd,
|
|
||||||
"response_time_ms": r.response_time_ms,
|
|
||||||
"first_byte_time_ms": r.first_byte_time_ms,
|
|
||||||
"is_stream": r.is_stream,
|
|
||||||
"status": r.status, # 请求状态: pending, streaming, completed, failed
|
|
||||||
"created_at": r.created_at.isoformat(),
|
|
||||||
"cache_creation_input_tokens": r.cache_creation_input_tokens,
|
|
||||||
"cache_read_input_tokens": r.cache_read_input_tokens,
|
|
||||||
"status_code": r.status_code,
|
|
||||||
"error_message": r.error_message,
|
|
||||||
"input_price_per_1m": r.input_price_per_1m,
|
|
||||||
"output_price_per_1m": r.output_price_per_1m,
|
|
||||||
"cache_creation_price_per_1m": r.cache_creation_price_per_1m,
|
|
||||||
"cache_read_price_per_1m": r.cache_read_price_per_1m,
|
|
||||||
"api_key": (
|
|
||||||
{
|
|
||||||
"id": str(api_key.id),
|
|
||||||
"name": api_key.name,
|
|
||||||
"display": api_key.get_display_key(),
|
|
||||||
}
|
|
||||||
if api_key
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
}
|
|
||||||
for r, api_key in usage_records
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 管理员可以看到真实成本
|
# 管理员可以看到真实成本
|
||||||
if user.role == "admin":
|
if user.role == "admin":
|
||||||
response_data["total_actual_cost"] = total_actual_cost
|
response_data["total_actual_cost"] = total_actual_cost
|
||||||
# 为每条记录添加真实成本和倍率信息
|
# 为每条记录添加真实成本和倍率信息
|
||||||
for i, (r, _) in enumerate(usage_records):
|
for i, (r, _, _) in enumerate(usage_records):
|
||||||
# 确保字段有值,避免前端显示 -
|
# 确保字段有值,避免前端显示 -
|
||||||
actual_cost = (
|
actual_cost = (
|
||||||
r.actual_total_cost_usd if r.actual_total_cost_usd is not None else 0.0
|
r.actual_total_cost_usd if r.actual_total_cost_usd is not None else 0.0
|
||||||
@@ -946,16 +912,66 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
|
|||||||
response_data["records"][i]["actual_cost"] = actual_cost
|
response_data["records"][i]["actual_cost"] = actual_cost
|
||||||
response_data["records"][i]["rate_multiplier"] = rate_mult
|
response_data["records"][i]["rate_multiplier"] = rate_mult
|
||||||
|
|
||||||
# 调试日志:检查前几条记录
|
|
||||||
if i < 3:
|
|
||||||
from src.core.logger import logger
|
|
||||||
logger.debug(
|
|
||||||
f"Usage record {i}: id={r.id}, actual_total_cost_usd={r.actual_total_cost_usd}, "
|
|
||||||
f"rate_multiplier={r.rate_multiplier}, returned: actual_cost={actual_cost}, rate_mult={rate_mult}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return response_data
|
return response_data
|
||||||
|
|
||||||
|
def _build_usage_records(self, usage_records: list) -> list:
|
||||||
|
"""构建使用记录列表,包含格式转换信息的回填逻辑"""
|
||||||
|
from src.core.api_format.metadata import can_passthrough
|
||||||
|
|
||||||
|
records = []
|
||||||
|
for r, api_key, endpoint in usage_records:
|
||||||
|
# 格式转换追踪(兼容历史数据:尽量回填可展示信息)
|
||||||
|
api_format = r.api_format
|
||||||
|
endpoint_api_format = r.endpoint_api_format or (
|
||||||
|
endpoint.api_format if endpoint else None
|
||||||
|
)
|
||||||
|
|
||||||
|
has_format_conversion = r.has_format_conversion
|
||||||
|
if has_format_conversion is None:
|
||||||
|
# 使用 can_passthrough 判断是否需要转换,与实际转换逻辑保持一致
|
||||||
|
client_fmt = str(api_format or "").upper()
|
||||||
|
endpoint_fmt = str(endpoint_api_format or "").upper()
|
||||||
|
if client_fmt and endpoint_fmt:
|
||||||
|
has_format_conversion = not can_passthrough(client_fmt, endpoint_fmt)
|
||||||
|
else:
|
||||||
|
has_format_conversion = False
|
||||||
|
|
||||||
|
records.append({
|
||||||
|
"id": r.id,
|
||||||
|
"model": r.model,
|
||||||
|
"target_model": r.target_model, # 映射后的目标模型名
|
||||||
|
"api_format": api_format,
|
||||||
|
"endpoint_api_format": endpoint_api_format,
|
||||||
|
"has_format_conversion": bool(has_format_conversion),
|
||||||
|
"input_tokens": r.input_tokens,
|
||||||
|
"output_tokens": r.output_tokens,
|
||||||
|
"total_tokens": r.total_tokens,
|
||||||
|
"cost": r.total_cost_usd,
|
||||||
|
"response_time_ms": r.response_time_ms,
|
||||||
|
"first_byte_time_ms": r.first_byte_time_ms,
|
||||||
|
"is_stream": r.is_stream,
|
||||||
|
"status": r.status, # 请求状态: pending, streaming, completed, failed
|
||||||
|
"created_at": r.created_at.isoformat(),
|
||||||
|
"cache_creation_input_tokens": r.cache_creation_input_tokens,
|
||||||
|
"cache_read_input_tokens": r.cache_read_input_tokens,
|
||||||
|
"status_code": r.status_code,
|
||||||
|
"error_message": r.error_message,
|
||||||
|
"input_price_per_1m": r.input_price_per_1m,
|
||||||
|
"output_price_per_1m": r.output_price_per_1m,
|
||||||
|
"cache_creation_price_per_1m": r.cache_creation_price_per_1m,
|
||||||
|
"cache_read_price_per_1m": r.cache_read_price_per_1m,
|
||||||
|
"api_key": (
|
||||||
|
{
|
||||||
|
"id": str(api_key.id),
|
||||||
|
"name": api_key.name,
|
||||||
|
"display": api_key.get_display_key(),
|
||||||
|
}
|
||||||
|
if api_key
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class GetActiveRequestsAdapter(AuthenticatedApiAdapter):
|
class GetActiveRequestsAdapter(AuthenticatedApiAdapter):
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
转换逻辑:
|
转换逻辑:
|
||||||
1. 格式完全匹配 -> 透传(无需转换)
|
1. 格式完全匹配 -> 透传(无需转换)
|
||||||
2. 同族格式透传(CLAUDE/CLAUDE_CLI、GEMINI/GEMINI_CLI 格式相同,只是认证不同)
|
2. data_format_id 相同 -> 透传(如 CLAUDE/CLAUDE_CLI 数据格式相同)
|
||||||
3. 需要转换的情况 -> 检查全局开关 + 端点配置 + 转换器能力
|
3. data_format_id 不同 -> 需要转换,检查全局开关 + 端点配置 + 转换器能力
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -17,7 +17,7 @@ from typing import TYPE_CHECKING, Optional, Tuple
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.core.api_format.conversion.registry import FormatConversionRegistry
|
from src.core.api_format.conversion.registry import FormatConversionRegistry
|
||||||
|
|
||||||
from src.core.api_format.utils import get_base_format
|
from src.core.api_format.metadata import can_passthrough
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -64,17 +64,25 @@ def is_format_compatible(
|
|||||||
if provider_format == client_format_upper:
|
if provider_format == client_format_upper:
|
||||||
return True, False, None
|
return True, False, None
|
||||||
|
|
||||||
# 2. 同族格式检查
|
# 2. 检查是否可以透传(data_format_id 相同)
|
||||||
provider_base = get_base_format(provider_format)
|
# 例如:CLAUDE/CLAUDE_CLI 的 data_format_id 都是 "claude",数据格式相同可透传
|
||||||
client_base = get_base_format(client_format_upper)
|
# OPENAI 是 "openai_chat",OPENAI_CLI 是 "openai_responses",需要转换
|
||||||
|
if can_passthrough(client_format_upper, provider_format):
|
||||||
is_same_family = provider_base == client_base
|
# 可透传,但仍需检查端点的格式限制配置(如果有)
|
||||||
if is_same_family and provider_base != "OPENAI":
|
if endpoint_format_acceptance_config and isinstance(endpoint_format_acceptance_config, dict):
|
||||||
# CLAUDE/CLAUDE_CLI、GEMINI/GEMINI_CLI 等:格式相同,只是认证不同,可透传
|
config = endpoint_format_acceptance_config
|
||||||
# 注意:这些格式之间没有定义转换器,因为数据格式完全相同
|
# 检查 reject_formats(即使可透传也应遵守拒绝列表)
|
||||||
|
reject_formats = config.get("reject_formats", [])
|
||||||
|
if client_format_upper in [f.upper() for f in reject_formats]:
|
||||||
|
return False, False, f"端点拒绝 {client_format} 格式"
|
||||||
|
# 检查 accept_formats(如果配置了白名单,也需在白名单中)
|
||||||
|
accept_formats = config.get("accept_formats", [])
|
||||||
|
if accept_formats and client_format_upper not in [f.upper() for f in accept_formats]:
|
||||||
|
return False, False, f"端点不接受 {client_format} 格式"
|
||||||
|
# 通过检查后,可透传(无需转换)
|
||||||
return True, False, None
|
return True, False, None
|
||||||
|
|
||||||
# 3. 需要转换的情况(OPENAI/OPENAI_CLI 同族转换 或 跨格式转换)
|
# 3. 需要转换的情况(data_format_id 不同)
|
||||||
# 检查全局开关(来自环境变量,默认开启)
|
# 检查全局开关(来自环境变量,默认开启)
|
||||||
if not global_conversion_enabled:
|
if not global_conversion_enabled:
|
||||||
return False, False, "全局格式转换未启用(环境变量 FORMAT_CONVERSION_ENABLED=false)"
|
return False, False, "全局格式转换未启用(环境变量 FORMAT_CONVERSION_ENABLED=false)"
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ class ApiFormatDefinition:
|
|||||||
- protected_keys: 不应被 extra_headers 覆盖的头部(小写)
|
- protected_keys: 不应被 extra_headers 覆盖的头部(小写)
|
||||||
- model_in_body: 是否需要在请求体中包含 model 字段(Gemini 等格式通过 URL 传递模型名)
|
- model_in_body: 是否需要在请求体中包含 model 字段(Gemini 等格式通过 URL 传递模型名)
|
||||||
- stream_in_body: 是否需要在请求体中包含 stream 字段(Gemini 等格式通过 URL 端点区分流式)
|
- stream_in_body: 是否需要在请求体中包含 stream 字段(Gemini 等格式通过 URL 端点区分流式)
|
||||||
|
- data_format_id: 数据格式标识,相同 ID 的格式数据结构相同可以透传,不同则需要转换
|
||||||
|
例如:CLAUDE/CLAUDE_CLI 都是 "claude",可以透传
|
||||||
|
OPENAI 是 "openai_chat",OPENAI_CLI 是 "openai_responses",需要转换
|
||||||
"""
|
"""
|
||||||
|
|
||||||
api_format: APIFormat
|
api_format: APIFormat
|
||||||
@@ -50,6 +53,7 @@ class ApiFormatDefinition:
|
|||||||
protected_keys: frozenset[str] = field(default_factory=frozenset) # 受保护的头部 key(小写)
|
protected_keys: frozenset[str] = field(default_factory=frozenset) # 受保护的头部 key(小写)
|
||||||
model_in_body: bool = True # 是否需要在请求体中包含 model 字段
|
model_in_body: bool = True # 是否需要在请求体中包含 model 字段
|
||||||
stream_in_body: bool = True # 是否需要在请求体中包含 stream 字段
|
stream_in_body: bool = True # 是否需要在请求体中包含 stream 字段
|
||||||
|
data_format_id: str = "" # 数据格式标识,相同 ID 可透传,不同需转换
|
||||||
|
|
||||||
def iter_aliases(self) -> Iterable[str]:
|
def iter_aliases(self) -> Iterable[str]:
|
||||||
"""返回大小写统一后的别名集合,包含枚举名本身。"""
|
"""返回大小写统一后的别名集合,包含枚举名本身。"""
|
||||||
@@ -70,6 +74,7 @@ _DEFINITIONS: Dict[APIFormat, ApiFormatDefinition] = {
|
|||||||
auth_type="header",
|
auth_type="header",
|
||||||
extra_headers={"anthropic-version": "2023-06-01"},
|
extra_headers={"anthropic-version": "2023-06-01"},
|
||||||
protected_keys=frozenset({"x-api-key", "content-type", "anthropic-version"}),
|
protected_keys=frozenset({"x-api-key", "content-type", "anthropic-version"}),
|
||||||
|
data_format_id="claude", # CLAUDE/CLAUDE_CLI 数据格式相同
|
||||||
),
|
),
|
||||||
APIFormat.CLAUDE_CLI: ApiFormatDefinition(
|
APIFormat.CLAUDE_CLI: ApiFormatDefinition(
|
||||||
api_format=APIFormat.CLAUDE_CLI,
|
api_format=APIFormat.CLAUDE_CLI,
|
||||||
@@ -79,6 +84,7 @@ _DEFINITIONS: Dict[APIFormat, ApiFormatDefinition] = {
|
|||||||
auth_header="Authorization",
|
auth_header="Authorization",
|
||||||
auth_type="bearer",
|
auth_type="bearer",
|
||||||
protected_keys=frozenset({"authorization", "content-type"}),
|
protected_keys=frozenset({"authorization", "content-type"}),
|
||||||
|
data_format_id="claude", # CLAUDE/CLAUDE_CLI 数据格式相同
|
||||||
),
|
),
|
||||||
APIFormat.OPENAI: ApiFormatDefinition(
|
APIFormat.OPENAI: ApiFormatDefinition(
|
||||||
api_format=APIFormat.OPENAI,
|
api_format=APIFormat.OPENAI,
|
||||||
@@ -98,6 +104,7 @@ _DEFINITIONS: Dict[APIFormat, ApiFormatDefinition] = {
|
|||||||
auth_header="Authorization",
|
auth_header="Authorization",
|
||||||
auth_type="bearer",
|
auth_type="bearer",
|
||||||
protected_keys=frozenset({"authorization", "content-type"}),
|
protected_keys=frozenset({"authorization", "content-type"}),
|
||||||
|
data_format_id="openai_chat", # Chat Completions API 格式
|
||||||
),
|
),
|
||||||
APIFormat.OPENAI_CLI: ApiFormatDefinition(
|
APIFormat.OPENAI_CLI: ApiFormatDefinition(
|
||||||
api_format=APIFormat.OPENAI_CLI,
|
api_format=APIFormat.OPENAI_CLI,
|
||||||
@@ -107,6 +114,7 @@ _DEFINITIONS: Dict[APIFormat, ApiFormatDefinition] = {
|
|||||||
auth_header="Authorization",
|
auth_header="Authorization",
|
||||||
auth_type="bearer",
|
auth_type="bearer",
|
||||||
protected_keys=frozenset({"authorization", "content-type"}),
|
protected_keys=frozenset({"authorization", "content-type"}),
|
||||||
|
data_format_id="openai_responses", # Responses API 格式,与 OPENAI 不同需转换
|
||||||
),
|
),
|
||||||
APIFormat.GEMINI: ApiFormatDefinition(
|
APIFormat.GEMINI: ApiFormatDefinition(
|
||||||
api_format=APIFormat.GEMINI,
|
api_format=APIFormat.GEMINI,
|
||||||
@@ -118,6 +126,7 @@ _DEFINITIONS: Dict[APIFormat, ApiFormatDefinition] = {
|
|||||||
protected_keys=frozenset({"x-goog-api-key", "content-type"}),
|
protected_keys=frozenset({"x-goog-api-key", "content-type"}),
|
||||||
model_in_body=False, # Gemini 通过 URL 路径传递模型名
|
model_in_body=False, # Gemini 通过 URL 路径传递模型名
|
||||||
stream_in_body=False, # Gemini 通过 URL 端点区分流式(streamGenerateContent vs generateContent)
|
stream_in_body=False, # Gemini 通过 URL 端点区分流式(streamGenerateContent vs generateContent)
|
||||||
|
data_format_id="gemini", # GEMINI/GEMINI_CLI 数据格式相同
|
||||||
),
|
),
|
||||||
APIFormat.GEMINI_CLI: ApiFormatDefinition(
|
APIFormat.GEMINI_CLI: ApiFormatDefinition(
|
||||||
api_format=APIFormat.GEMINI_CLI,
|
api_format=APIFormat.GEMINI_CLI,
|
||||||
@@ -129,6 +138,7 @@ _DEFINITIONS: Dict[APIFormat, ApiFormatDefinition] = {
|
|||||||
protected_keys=frozenset({"x-goog-api-key", "content-type"}),
|
protected_keys=frozenset({"x-goog-api-key", "content-type"}),
|
||||||
model_in_body=False, # Gemini 通过 URL 路径传递模型名
|
model_in_body=False, # Gemini 通过 URL 路径传递模型名
|
||||||
stream_in_body=False, # Gemini 通过 URL 端点区分流式
|
stream_in_body=False, # Gemini 通过 URL 端点区分流式
|
||||||
|
data_format_id="gemini", # GEMINI/GEMINI_CLI 数据格式相同
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +237,62 @@ def get_protected_keys(api_format: APIFormat) -> frozenset[str]:
|
|||||||
return frozenset({"authorization", "content-type"})
|
return frozenset({"authorization", "content-type"})
|
||||||
|
|
||||||
|
|
||||||
|
def get_data_format_id(api_format: Union[str, APIFormat]) -> str:
|
||||||
|
"""
|
||||||
|
获取格式的数据格式标识。
|
||||||
|
|
||||||
|
相同 data_format_id 的格式数据结构相同,可以透传;不同则需要转换。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_format: API 格式(字符串或枚举)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
数据格式标识,未找到时返回格式名称本身(小写)
|
||||||
|
"""
|
||||||
|
# 统一转换为 APIFormat 枚举
|
||||||
|
if isinstance(api_format, str):
|
||||||
|
resolved = resolve_api_format(api_format)
|
||||||
|
if resolved is None:
|
||||||
|
# 未知格式:返回小写,与已定义格式的 data_format_id 风格一致
|
||||||
|
return api_format.lower()
|
||||||
|
api_format = resolved
|
||||||
|
|
||||||
|
definition = API_FORMAT_DEFINITIONS.get(api_format)
|
||||||
|
if definition and definition.data_format_id:
|
||||||
|
return definition.data_format_id
|
||||||
|
# 兜底:返回格式名称本身(小写)
|
||||||
|
return api_format.value.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def can_passthrough(client_format: Union[str, APIFormat], endpoint_format: Union[str, APIFormat]) -> bool:
|
||||||
|
"""
|
||||||
|
判断两个格式之间是否可以透传(不需要数据转换)。
|
||||||
|
|
||||||
|
透传条件:
|
||||||
|
1. 格式完全相同
|
||||||
|
2. data_format_id 相同(如 CLAUDE 和 CLAUDE_CLI 都是 "claude")
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client_format: 客户端请求格式
|
||||||
|
endpoint_format: 端点 API 格式
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True 表示可以透传,False 表示需要转换
|
||||||
|
"""
|
||||||
|
# 统一转换为字符串比较
|
||||||
|
client_str = client_format.value if isinstance(client_format, APIFormat) else str(client_format).upper()
|
||||||
|
endpoint_str = endpoint_format.value if isinstance(endpoint_format, APIFormat) else str(endpoint_format).upper()
|
||||||
|
|
||||||
|
# 完全相同
|
||||||
|
if client_str == endpoint_str:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# 检查 data_format_id
|
||||||
|
client_data_id = get_data_format_id(client_format)
|
||||||
|
endpoint_data_id = get_data_format_id(endpoint_format)
|
||||||
|
return client_data_id == endpoint_data_id
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def _alias_lookup_cache() -> Dict[str, APIFormat]:
|
def _alias_lookup_cache() -> Dict[str, APIFormat]:
|
||||||
"""缓存 alias -> APIFormat 查找表,减少重复构建。"""
|
"""缓存 alias -> APIFormat 查找表,减少重复构建。"""
|
||||||
|
|||||||
Reference in New Issue
Block a user