mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat: OAuth 账户管理、维护调度、端点健康检查增强及前端优化
- 新增 OAuth 账户管理对话框和提供商详情抽屉中的 OAuth 信息展示 - 新增维护调度器(maintenance_scheduler)支持定时清理和健康检查 - 增强端点健康检查器,支持更多检测策略 - 重构 codex 服务为 metadata_collectors 模块 - 优化 OpenAI CLI normalizer 代码结构 - 前端: 改进使用量表格、统计图表、指南页面和异步任务管理 - 扩展多个数据库字符串列为 TEXT 类型 - 新增倒计时 composable 和 provider OAuth API 端点
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
"""Add provider_type and expand string columns to TEXT
|
||||
"""Add provider_type, upstream_metadata, oauth_invalid fields and expand string columns to TEXT
|
||||
|
||||
- Add providers.provider_type (String(20), server_default="custom")
|
||||
- Add provider_api_keys.upstream_metadata (JSON, nullable)
|
||||
- Add provider_api_keys.oauth_invalid_at (DateTime, nullable) - OAuth Token 失效时间
|
||||
- Add provider_api_keys.oauth_invalid_reason (String(255), nullable) - OAuth Token 失效原因
|
||||
- Expand multiple VARCHAR columns to TEXT for long values (OAuth tokens, LDAP DN, URLs, etc.)
|
||||
|
||||
Revision ID: b5c6d7e8f9a0
|
||||
@@ -12,9 +15,10 @@ Create Date: 2026-02-04 15:00:00.000000
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b5c6d7e8f9a0"
|
||||
down_revision: Union[str, None] = "c4e8f9a1b2c3"
|
||||
@@ -25,6 +29,11 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
# 需要扩展为 TEXT 的列(表名, 列名, 原始类型长度)
|
||||
COLUMNS_TO_EXPAND = [
|
||||
("provider_api_keys", "api_key", 500), # OAuth tokens can be very long
|
||||
(
|
||||
"provider_api_keys",
|
||||
"auth_config",
|
||||
None,
|
||||
), # 确保 auth_config 是 TEXT 类型(可能从 JSON 迁移过来)
|
||||
("ldap_configs", "bind_dn", 255), # LDAP DN can be deeply nested
|
||||
("ldap_configs", "base_dn", 255), # LDAP DN can be deeply nested
|
||||
("ldap_configs", "user_search_filter", 500), # Complex LDAP filters
|
||||
@@ -53,27 +62,57 @@ def is_sqlite() -> bool:
|
||||
return bind.dialect.name == "sqlite"
|
||||
|
||||
|
||||
def expand_column_to_text(table_name: str, column_name: str, original_length: int) -> None:
|
||||
def get_column_type(table_name: str, column_name: str) -> str | None:
|
||||
"""获取列的数据类型"""
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
for col in inspector.get_columns(table_name):
|
||||
if col["name"] == column_name:
|
||||
return str(col["type"]).upper()
|
||||
return None
|
||||
|
||||
|
||||
def expand_column_to_text(table_name: str, column_name: str, original_length: int | None) -> None:
|
||||
"""将 VARCHAR 列扩展为 TEXT(兼容 SQLite)"""
|
||||
if not table_exists(table_name):
|
||||
return
|
||||
if not column_exists(table_name, column_name):
|
||||
return
|
||||
|
||||
# 检查当前列类型,如果已经是 TEXT 则跳过
|
||||
col_type = get_column_type(table_name, column_name)
|
||||
if col_type and "TEXT" in col_type:
|
||||
return
|
||||
|
||||
# 如果是 JSON 类型(可能是历史遗留),先将 JSON 数据转为文本表示再变更类型
|
||||
is_json_col = col_type and "JSON" in col_type
|
||||
|
||||
if is_json_col and not is_sqlite():
|
||||
# PostgreSQL: 先用 CAST 把 JSON 值转为 TEXT,保留数据
|
||||
op.execute(
|
||||
sa.text(
|
||||
f"ALTER TABLE {table_name} ALTER COLUMN {column_name} "
|
||||
f"TYPE TEXT USING {column_name}::TEXT"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
if is_sqlite():
|
||||
# SQLite 不支持直接 ALTER COLUMN,需要用 batch 模式
|
||||
# batch 模式会自动处理 JSON->TEXT 的数据迁移
|
||||
with op.batch_alter_table(table_name) as batch_op:
|
||||
batch_op.alter_column(
|
||||
column_name,
|
||||
type_=sa.Text(),
|
||||
existing_type=sa.String(original_length),
|
||||
existing_type=sa.String(original_length) if original_length else sa.Text(),
|
||||
)
|
||||
else:
|
||||
op.alter_column(
|
||||
table_name,
|
||||
column_name,
|
||||
type_=sa.Text(),
|
||||
existing_type=sa.String(original_length),
|
||||
existing_type=sa.String(original_length) if original_length else sa.Text(),
|
||||
existing_nullable=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -114,6 +153,27 @@ def upgrade() -> None:
|
||||
sa.Column("provider_type", sa.String(20), nullable=False, server_default="custom"),
|
||||
)
|
||||
|
||||
# Add provider_api_keys.upstream_metadata
|
||||
if not column_exists("provider_api_keys", "upstream_metadata"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("upstream_metadata", sa.JSON(), nullable=True),
|
||||
)
|
||||
|
||||
# Add provider_api_keys.oauth_invalid_at
|
||||
if not column_exists("provider_api_keys", "oauth_invalid_at"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("oauth_invalid_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
# Add provider_api_keys.oauth_invalid_reason
|
||||
if not column_exists("provider_api_keys", "oauth_invalid_reason"):
|
||||
op.add_column(
|
||||
"provider_api_keys",
|
||||
sa.Column("oauth_invalid_reason", sa.String(255), nullable=True),
|
||||
)
|
||||
|
||||
# Expand VARCHAR columns to TEXT
|
||||
for table_name, column_name, original_length in COLUMNS_TO_EXPAND:
|
||||
expand_column_to_text(table_name, column_name, original_length)
|
||||
@@ -123,8 +183,23 @@ def downgrade() -> None:
|
||||
# Shrink TEXT columns back to VARCHAR
|
||||
# WARNING: Downgrade may fail if any values exceed original length
|
||||
for table_name, column_name, original_length in reversed(COLUMNS_TO_EXPAND):
|
||||
# 跳过没有原始长度的列(如 auth_config,由其他迁移创建)
|
||||
if original_length is None:
|
||||
continue
|
||||
shrink_column_to_varchar(table_name, column_name, original_length)
|
||||
|
||||
# Drop provider_api_keys.oauth_invalid_reason
|
||||
if column_exists("provider_api_keys", "oauth_invalid_reason"):
|
||||
op.drop_column("provider_api_keys", "oauth_invalid_reason")
|
||||
|
||||
# Drop provider_api_keys.oauth_invalid_at
|
||||
if column_exists("provider_api_keys", "oauth_invalid_at"):
|
||||
op.drop_column("provider_api_keys", "oauth_invalid_at")
|
||||
|
||||
# Drop provider_api_keys.upstream_metadata
|
||||
if column_exists("provider_api_keys", "upstream_metadata"):
|
||||
op.drop_column("provider_api_keys", "upstream_metadata")
|
||||
|
||||
# Drop providers.provider_type
|
||||
if column_exists("providers", "provider_type"):
|
||||
op.drop_column("providers", "provider_type")
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ProviderOAuthCompleteResponse {
|
||||
provider_type: string
|
||||
expires_at?: number | null
|
||||
has_refresh_token: boolean
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
export async function getProviderOAuthSupportedTypes(): Promise<ProviderOAuthSupportedType[]> {
|
||||
@@ -49,3 +50,31 @@ export async function refreshProviderOAuth(keyId: string): Promise<ProviderOAuth
|
||||
const resp = await client.post(`/api/admin/provider-oauth/keys/${keyId}/refresh`)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
// Provider-level OAuth (不需要预先创建 key)
|
||||
|
||||
export interface ProviderOAuthCompleteRequest {
|
||||
callback_url: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface ProviderOAuthCompleteResponseWithKey {
|
||||
key_id: string
|
||||
provider_type: string
|
||||
expires_at?: number | null
|
||||
has_refresh_token: boolean
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
export async function startProviderLevelOAuth(providerId: string): Promise<ProviderOAuthStartResponse> {
|
||||
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/start`)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function completeProviderLevelOAuth(
|
||||
providerId: string,
|
||||
data: ProviderOAuthCompleteRequest
|
||||
): Promise<ProviderOAuthCompleteResponseWithKey> {
|
||||
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/complete`, data)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
@@ -249,6 +249,28 @@ export interface EndpointAPIKey {
|
||||
model_exclude_patterns?: string[] // 模型排除规则(支持 * 和 ? 通配符)
|
||||
// OAuth 相关
|
||||
oauth_expires_at?: number | null // OAuth Token 过期时间(Unix 时间戳)
|
||||
oauth_email?: string | null // OAuth 授权的邮箱
|
||||
oauth_plan_type?: string | null // Codex 订阅类型: plus/free/team/enterprise
|
||||
oauth_account_id?: string | null // Codex ChatGPT 账号 ID
|
||||
oauth_invalid_at?: number | null // OAuth Token 失效时间(Unix 时间戳)
|
||||
oauth_invalid_reason?: string | null // OAuth Token 失效原因
|
||||
// 上游元数据(由响应头采集,如 Codex 额度信息)
|
||||
upstream_metadata?: CodexUpstreamMetadata | null
|
||||
}
|
||||
|
||||
// Codex 上游元数据类型
|
||||
export interface CodexUpstreamMetadata {
|
||||
plan_type?: string // 套餐类型
|
||||
primary_used_percent?: number // 主限额窗口使用百分比
|
||||
primary_reset_seconds?: number // 主限额重置剩余秒数
|
||||
primary_reset_at?: number // 主限额重置时间(Unix 时间戳)
|
||||
primary_window_minutes?: number // 主限额窗口大小(分钟)
|
||||
secondary_used_percent?: number // 次级限额窗口使用百分比
|
||||
secondary_reset_seconds?: number // 次级限额重置剩余秒数
|
||||
secondary_reset_at?: number // 次级限额重置时间(Unix 时间戳)
|
||||
secondary_window_minutes?: number // 次级限额窗口大小(分钟)
|
||||
has_credits?: boolean // 是否有积分
|
||||
credits_balance?: number // 积分余额
|
||||
}
|
||||
|
||||
// 按格式的健康度数据
|
||||
|
||||
@@ -1,21 +1,46 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Select v-model:open="presetSelectOpen" v-model="selectedPreset">
|
||||
<Select
|
||||
v-model:open="presetSelectOpen"
|
||||
v-model="selectedPreset"
|
||||
>
|
||||
<SelectTrigger class="h-8 w-32 text-xs border-border/60">
|
||||
<SelectValue placeholder="选择时间段" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="today">今天</SelectItem>
|
||||
<SelectItem value="yesterday">昨天</SelectItem>
|
||||
<SelectItem value="last7days">最近7天</SelectItem>
|
||||
<SelectItem value="last30days">最近30天</SelectItem>
|
||||
<SelectItem value="last90days">最近90天</SelectItem>
|
||||
<SelectItem value="this_week">本周</SelectItem>
|
||||
<SelectItem value="last_week">上周</SelectItem>
|
||||
<SelectItem value="this_month">本月</SelectItem>
|
||||
<SelectItem value="last_month">上月</SelectItem>
|
||||
<SelectItem value="this_year">今年</SelectItem>
|
||||
<SelectItem value="custom">自定义</SelectItem>
|
||||
<SelectItem value="today">
|
||||
今天
|
||||
</SelectItem>
|
||||
<SelectItem value="yesterday">
|
||||
昨天
|
||||
</SelectItem>
|
||||
<SelectItem value="last7days">
|
||||
最近7天
|
||||
</SelectItem>
|
||||
<SelectItem value="last30days">
|
||||
最近30天
|
||||
</SelectItem>
|
||||
<SelectItem value="last90days">
|
||||
最近90天
|
||||
</SelectItem>
|
||||
<SelectItem value="this_week">
|
||||
本周
|
||||
</SelectItem>
|
||||
<SelectItem value="last_week">
|
||||
上周
|
||||
</SelectItem>
|
||||
<SelectItem value="this_month">
|
||||
本月
|
||||
</SelectItem>
|
||||
<SelectItem value="last_month">
|
||||
上月
|
||||
</SelectItem>
|
||||
<SelectItem value="this_year">
|
||||
今年
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
自定义
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -36,15 +61,30 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select v-if="showGranularity" v-model:open="granularitySelectOpen" v-model="selectedGranularity">
|
||||
<Select
|
||||
v-if="showGranularity"
|
||||
v-model:open="granularitySelectOpen"
|
||||
v-model="selectedGranularity"
|
||||
>
|
||||
<SelectTrigger class="h-8 w-24 text-xs border-border/60">
|
||||
<SelectValue placeholder="粒度" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-if="allowHourly && canUseHourly" value="hour">小时</SelectItem>
|
||||
<SelectItem value="day">天</SelectItem>
|
||||
<SelectItem value="week">周</SelectItem>
|
||||
<SelectItem value="month">月</SelectItem>
|
||||
<SelectItem
|
||||
v-if="allowHourly && canUseHourly"
|
||||
value="hour"
|
||||
>
|
||||
小时
|
||||
</SelectItem>
|
||||
<SelectItem value="day">
|
||||
天
|
||||
</SelectItem>
|
||||
<SelectItem value="week">
|
||||
周
|
||||
</SelectItem>
|
||||
<SelectItem value="month">
|
||||
月
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<template>
|
||||
<Card class="p-4 space-y-2">
|
||||
<div class="text-xs text-muted-foreground">{{ label }}</div>
|
||||
<div class="text-lg font-semibold">{{ value }}</div>
|
||||
<div class="text-xs" :class="changeClass">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ label }}
|
||||
</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{{ value }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs"
|
||||
:class="changeClass"
|
||||
>
|
||||
<span v-if="changePercent !== null">{{ changePercent }}%</span>
|
||||
<span v-else>--</span>
|
||||
<span class="ml-1 text-muted-foreground">vs 对比期</span>
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">{{ title }}</h3>
|
||||
<span class="text-xs text-muted-foreground" v-if="subtitle">{{ subtitle }}</span>
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="subtitle"
|
||||
class="text-xs text-muted-foreground"
|
||||
>{{ subtitle }}</span>
|
||||
</div>
|
||||
<div v-if="loading" class="p-6">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="p-6"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div v-else class="h-[280px]">
|
||||
<LineChart :data="chartData" :options="chartOptions" />
|
||||
<div
|
||||
v-else
|
||||
class="h-[280px]"
|
||||
>
|
||||
<LineChart
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">{{ title }}</h3>
|
||||
<span class="text-xs text-muted-foreground" v-if="subtitle">{{ subtitle }}</span>
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="subtitle"
|
||||
class="text-xs text-muted-foreground"
|
||||
>{{ subtitle }}</span>
|
||||
</div>
|
||||
<div v-if="loading" class="p-6">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="p-6"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div v-else class="h-[260px]">
|
||||
<DoughnutChart :data="chartData" :options="chartOptions" />
|
||||
<div
|
||||
v-else
|
||||
class="h-[260px]"
|
||||
>
|
||||
<DoughnutChart
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,36 +11,70 @@
|
||||
<SelectValue placeholder="指标" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="requests">请求数</SelectItem>
|
||||
<SelectItem value="tokens">Tokens</SelectItem>
|
||||
<SelectItem value="cost">成本</SelectItem>
|
||||
<SelectItem value="requests">
|
||||
请求数
|
||||
</SelectItem>
|
||||
<SelectItem value="tokens">
|
||||
Tokens
|
||||
</SelectItem>
|
||||
<SelectItem value="cost">
|
||||
成本
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="p-6">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="p-6"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div v-else-if="items.length === 0" class="p-6">
|
||||
<EmptyState title="暂无数据" description="当前时间范围内没有统计结果" />
|
||||
<div
|
||||
v-else-if="items.length === 0"
|
||||
class="p-6"
|
||||
>
|
||||
<EmptyState
|
||||
title="暂无数据"
|
||||
description="当前时间范围内没有统计结果"
|
||||
/>
|
||||
</div>
|
||||
<Table v-else>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-16">排名</TableHead>
|
||||
<TableHead class="w-16">
|
||||
排名
|
||||
</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead class="text-right">请求数</TableHead>
|
||||
<TableHead class="text-right">Tokens</TableHead>
|
||||
<TableHead class="text-right">成本</TableHead>
|
||||
<TableHead class="text-right">
|
||||
请求数
|
||||
</TableHead>
|
||||
<TableHead class="text-right">
|
||||
Tokens
|
||||
</TableHead>
|
||||
<TableHead class="text-right">
|
||||
成本
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="item in items" :key="item.id">
|
||||
<TableCell class="font-medium">{{ item.rank }}</TableCell>
|
||||
<TableRow
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
>
|
||||
<TableCell class="font-medium">
|
||||
{{ item.rank }}
|
||||
</TableCell>
|
||||
<TableCell>{{ item.name }}</TableCell>
|
||||
<TableCell class="text-right">{{ item.requests }}</TableCell>
|
||||
<TableCell class="text-right">{{ formatTokens(item.tokens) }}</TableCell>
|
||||
<TableCell class="text-right">{{ formatCurrency(item.cost) }}</TableCell>
|
||||
<TableCell class="text-right">
|
||||
{{ item.requests }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
{{ formatTokens(item.tokens) }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
{{ formatCurrency(item.cost) }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">{{ title }}</h3>
|
||||
<span class="text-xs text-muted-foreground" v-if="subtitle">{{ subtitle }}</span>
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="subtitle"
|
||||
class="text-xs text-muted-foreground"
|
||||
>{{ subtitle }}</span>
|
||||
</div>
|
||||
<div v-if="loading" class="p-6">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="p-6"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div v-else class="h-[260px]">
|
||||
<LineChart :data="chartData" :options="chartOptions" />
|
||||
<div
|
||||
v-else
|
||||
class="h-[260px]"
|
||||
>
|
||||
<LineChart
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
<template>
|
||||
<Card class="p-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">{{ title }}</h3>
|
||||
<span class="text-xs text-muted-foreground" v-if="subtitle">{{ subtitle }}</span>
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<span
|
||||
v-if="subtitle"
|
||||
class="text-xs text-muted-foreground"
|
||||
>{{ subtitle }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="p-4">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="p-4"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div v-else-if="providers.length === 0" class="p-4">
|
||||
<EmptyState title="暂无数据" description="暂无月卡配额数据" />
|
||||
<div
|
||||
v-else-if="providers.length === 0"
|
||||
class="p-4"
|
||||
>
|
||||
<EmptyState
|
||||
title="暂无数据"
|
||||
description="暂无月卡配额数据"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="provider in providers" :key="provider.id" class="space-y-2">
|
||||
<div
|
||||
v-else
|
||||
class="space-y-4"
|
||||
>
|
||||
<div
|
||||
v-for="provider in providers"
|
||||
:key="provider.id"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between text-xs">
|
||||
<span class="font-medium">{{ provider.name }}</span>
|
||||
<span class="text-muted-foreground">
|
||||
|
||||
@@ -67,3 +67,70 @@ export function getProbeCountdown(nextProbeAt: string | null | undefined, _tick:
|
||||
}
|
||||
return '探测中'
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth Token 状态信息
|
||||
*/
|
||||
export interface OAuthStatusInfo {
|
||||
text: string
|
||||
isExpired: boolean
|
||||
isExpiringSoon: boolean
|
||||
isInvalid: boolean // Token 已失效(账号被封、授权撤销等)
|
||||
invalidReason?: string // 失效原因
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 OAuth Token 过期倒计时
|
||||
* @param expiresAt Unix 时间戳(秒)
|
||||
* @param _tick 响应式触发器(传入 tick.value 以触发响应式更新)
|
||||
* @param invalidAt 失效时间戳(秒),可选
|
||||
* @param invalidReason 失效原因,可选
|
||||
* @returns 状态信息对象
|
||||
*/
|
||||
export function getOAuthExpiresCountdown(
|
||||
expiresAt: number | null | undefined,
|
||||
_tick: number,
|
||||
invalidAt?: number | null,
|
||||
invalidReason?: string | null
|
||||
): OAuthStatusInfo | null {
|
||||
void _tick
|
||||
|
||||
// 优先检查失效状态(失效比过期更严重)
|
||||
if (invalidAt != null) {
|
||||
return {
|
||||
text: '已失效',
|
||||
isExpired: false,
|
||||
isExpiringSoon: false,
|
||||
isInvalid: true,
|
||||
invalidReason: invalidReason || undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (expiresAt == null) return null
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const diffSeconds = expiresAt - now
|
||||
|
||||
if (diffSeconds <= 0) {
|
||||
return { text: '已过期', isExpired: true, isExpiringSoon: false, isInvalid: false }
|
||||
}
|
||||
|
||||
// 24 小时内过期视为即将过期
|
||||
const isExpiringSoon = diffSeconds < 24 * 3600
|
||||
|
||||
// 格式化时间
|
||||
const days = Math.floor(diffSeconds / 86400)
|
||||
const hours = Math.floor((diffSeconds % 86400) / 3600)
|
||||
const minutes = Math.floor((diffSeconds % 3600) / 60)
|
||||
|
||||
let text: string
|
||||
if (days > 0) {
|
||||
text = `${days}天${hours}时`
|
||||
} else if (hours > 0) {
|
||||
text = `${hours}时${minutes}分`
|
||||
} else {
|
||||
text = `${minutes}分钟`
|
||||
}
|
||||
|
||||
return { text, isExpired: false, isExpiringSoon, isInvalid: false }
|
||||
}
|
||||
|
||||
@@ -200,7 +200,9 @@
|
||||
|
||||
<!-- 视频计费(分辨率 × 时长) -->
|
||||
<div class="pt-3 border-t space-y-2">
|
||||
<div class="text-sm font-medium">视频计费(分辨率 × 时长)</div>
|
||||
<div class="text-sm font-medium">
|
||||
视频计费(分辨率 × 时长)
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<Button
|
||||
@@ -249,7 +251,7 @@
|
||||
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
|
||||
<span>分辨率</span>
|
||||
<span>单价($/秒)</span>
|
||||
<span></span>
|
||||
<span />
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
<div
|
||||
|
||||
@@ -533,6 +533,12 @@ const props = defineProps<{
|
||||
providerFormatConversionEnabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'endpointCreated': []
|
||||
'endpointUpdated': []
|
||||
}>()
|
||||
|
||||
// 计算端点级格式转换是否应该被禁用
|
||||
const isEndpointFormatConversionDisabled = computed(() => {
|
||||
return props.systemFormatConversionEnabled || props.providerFormatConversionEnabled
|
||||
@@ -549,12 +555,6 @@ const formatConversionDisabledTooltip = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'endpointCreated': []
|
||||
'endpointUpdated': []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
// 规则 Select 的展开状态(与 Collapsible 分开管理)
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<Dialog
|
||||
:model-value="isOpen"
|
||||
title="添加账号"
|
||||
:icon="UserPlus"
|
||||
size="md"
|
||||
@update:model-value="handleDialogUpdate"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<!-- 加载中 -->
|
||||
<div
|
||||
v-if="oauth.starting && !oauth.authorization_url"
|
||||
class="py-12 text-center"
|
||||
>
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
正在准备授权...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 授权流程 -->
|
||||
<template v-else-if="oauth.authorization_url">
|
||||
<!-- 步骤 1: 打开授权链接 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0">
|
||||
1
|
||||
</div>
|
||||
<span class="text-sm font-medium">打开授权链接</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground pl-7">
|
||||
点击下方按钮在浏览器中完成登录授权
|
||||
</p>
|
||||
<div class="ml-7 p-2.5 rounded-md bg-muted/50 border border-border/50">
|
||||
<p class="text-xs font-mono text-muted-foreground break-all line-clamp-3 leading-relaxed">
|
||||
{{ oauth.authorization_url }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2 pl-7">
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="oauthBusy"
|
||||
@click="openAuthorizationUrl"
|
||||
>
|
||||
<ExternalLink class="w-3.5 h-3.5 mr-1.5" />
|
||||
前往授权
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="oauthBusy"
|
||||
@click="copyToClipboard(oauth.authorization_url)"
|
||||
>
|
||||
<Copy class="w-3.5 h-3.5 mr-1.5" />
|
||||
复制链接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤 2: 粘贴回调地址 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-5 h-5 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-xs font-medium shrink-0">
|
||||
2
|
||||
</div>
|
||||
<span class="text-sm font-medium">粘贴回调地址</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground pl-7">
|
||||
授权完成后,复制浏览器地址栏的完整 URL 并粘贴到下方
|
||||
</p>
|
||||
<div class="pl-7">
|
||||
<Textarea
|
||||
v-model="oauth.callback_url"
|
||||
:disabled="oauthBusy"
|
||||
placeholder="http://localhost:xxx/callback?code=..."
|
||||
class="min-h-[80px] text-xs font-mono resize-none"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="handleClose"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="!canCompleteOAuth"
|
||||
@click="handleCompleteOAuth"
|
||||
>
|
||||
{{ oauth.completing ? '验证中...' : '完成授权' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Dialog, Button, Textarea } from '@/components/ui'
|
||||
import { UserPlus, Copy, ExternalLink } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
startProviderLevelOAuth,
|
||||
completeProviderLevelOAuth,
|
||||
} from '@/api/endpoints'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
providerId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
saved: []
|
||||
}>()
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
// OAuth 状态
|
||||
interface OAuthState {
|
||||
authorization_url: string
|
||||
redirect_uri: string
|
||||
instructions: string
|
||||
provider_type: string
|
||||
callback_url: string
|
||||
starting: boolean
|
||||
completing: boolean
|
||||
}
|
||||
|
||||
function createInitialOAuthState(): OAuthState {
|
||||
return {
|
||||
authorization_url: '',
|
||||
redirect_uri: '',
|
||||
instructions: '',
|
||||
provider_type: '',
|
||||
callback_url: '',
|
||||
starting: false,
|
||||
completing: false,
|
||||
}
|
||||
}
|
||||
|
||||
const oauth = ref<OAuthState>(createInitialOAuthState())
|
||||
|
||||
const isOpen = computed(() => props.open)
|
||||
|
||||
const oauthBusy = computed(() =>
|
||||
oauth.value.starting || oauth.value.completing
|
||||
)
|
||||
|
||||
const canCompleteOAuth = computed(() => {
|
||||
if (!oauth.value.authorization_url) return false
|
||||
if (!oauth.value.callback_url.trim()) return false
|
||||
return !oauthBusy.value
|
||||
})
|
||||
|
||||
function resetForm() {
|
||||
oauth.value = createInitialOAuthState()
|
||||
}
|
||||
|
||||
function handleDialogUpdate(value: boolean) {
|
||||
if (!value) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetForm()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function openAuthorizationUrl() {
|
||||
const url = oauth.value.authorization_url
|
||||
if (!url) return
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
// 对话框打开时获取授权 URL(不创建 key)
|
||||
async function initOAuth() {
|
||||
if (!props.providerId) return
|
||||
|
||||
oauth.value.starting = true
|
||||
try {
|
||||
const resp = await startProviderLevelOAuth(props.providerId)
|
||||
oauth.value.authorization_url = resp.authorization_url
|
||||
oauth.value.redirect_uri = resp.redirect_uri
|
||||
oauth.value.instructions = resp.instructions
|
||||
oauth.value.provider_type = resp.provider_type
|
||||
} catch (err: any) {
|
||||
const errorMessage = parseApiError(err, '初始化授权失败')
|
||||
showError(errorMessage, '错误')
|
||||
handleClose()
|
||||
} finally {
|
||||
oauth.value.starting = false
|
||||
}
|
||||
}
|
||||
|
||||
// 完成授权(此时才创建 key)
|
||||
async function handleCompleteOAuth() {
|
||||
if (!canCompleteOAuth.value || !props.providerId) return
|
||||
oauth.value.completing = true
|
||||
try {
|
||||
await completeProviderLevelOAuth(props.providerId, {
|
||||
callback_url: oauth.value.callback_url.trim(),
|
||||
})
|
||||
success('授权成功,账号已添加')
|
||||
emit('saved')
|
||||
handleClose()
|
||||
} catch (err: any) {
|
||||
const errorMessage = parseApiError(err, '完成授权失败')
|
||||
showError(errorMessage, '错误')
|
||||
} finally {
|
||||
oauth.value.completing = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听对话框打开
|
||||
watch(() => props.open, (newOpen) => {
|
||||
if (newOpen) {
|
||||
initOAuth()
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -142,7 +142,7 @@
|
||||
{{ ((provider.monthly_used_usd || 0) / provider.monthly_quota_usd * 100).toFixed(1) }}%
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="relative w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div class="relative w-full h-2 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||
:class="{
|
||||
@@ -172,7 +172,7 @@
|
||||
<div class="p-4 border-b border-border/60">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold">
|
||||
密钥管理
|
||||
{{ provider.provider_type === 'custom' ? '密钥管理' : '账号管理' }}
|
||||
</h3>
|
||||
<Button
|
||||
v-if="endpoints.length > 0"
|
||||
@@ -182,7 +182,7 @@
|
||||
@click="handleAddKeyToFirstEndpoint"
|
||||
>
|
||||
<Plus class="w-3.5 h-3.5 mr-1.5" />
|
||||
添加密钥
|
||||
{{ provider.provider_type === 'custom' ? '添加密钥' : '添加账号' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -216,20 +216,58 @@
|
||||
<GripVertical class="w-4 h-4" />
|
||||
</div>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-sm font-medium truncate">{{ key.name || '未命名密钥' }}</span>
|
||||
<!-- OAuth 订阅类型标签 -->
|
||||
<Badge
|
||||
v-if="key.oauth_plan_type"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
:class="getOAuthPlanTypeClass(key.oauth_plan_type)"
|
||||
>
|
||||
{{ formatOAuthPlanType(key.oauth_plan_type) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-[11px] font-mono text-muted-foreground">
|
||||
{{ key.auth_type === 'vertex_ai' ? 'Vertex AI' : key.api_key_masked }}
|
||||
{{ key.auth_type === 'oauth' ? '[Refresh Token]' : (key.auth_type === 'vertex_ai' ? 'Vertex AI' : key.api_key_masked) }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
title="复制密钥"
|
||||
:title="key.auth_type === 'oauth' ? '复制 Refresh Token' : '复制密钥'"
|
||||
@click.stop="copyFullKey(key)"
|
||||
>
|
||||
<Copy class="w-2.5 h-2.5" />
|
||||
</Button>
|
||||
<!-- OAuth 状态(失效/过期/倒计时)和刷新按钮 -->
|
||||
<template v-if="getKeyOAuthExpires(key)">
|
||||
<span
|
||||
class="text-[10px]"
|
||||
:class="{
|
||||
'text-destructive': getKeyOAuthExpires(key)?.isInvalid || getKeyOAuthExpires(key)?.isExpired,
|
||||
'text-warning': getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isInvalid,
|
||||
'text-muted-foreground': !getKeyOAuthExpires(key)?.isExpired && !getKeyOAuthExpires(key)?.isExpiringSoon && !getKeyOAuthExpires(key)?.isInvalid
|
||||
}"
|
||||
:title="getOAuthStatusTitle(key)"
|
||||
>
|
||||
{{ getKeyOAuthExpires(key)?.text }}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:disabled="refreshingOAuthKeyId === key.id"
|
||||
:title="getKeyOAuthExpires(key)?.isInvalid ? '重新授权' : '刷新 Token'"
|
||||
@click.stop="handleRefreshOAuth(key)"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-2.5 h-2.5"
|
||||
:class="{ 'animate-spin': refreshingOAuthKeyId === key.id }"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -248,7 +286,7 @@
|
||||
v-if="key.health_score !== undefined"
|
||||
class="flex items-center gap-1 mr-1"
|
||||
>
|
||||
<div class="w-10 h-1.5 bg-muted/80 rounded-full overflow-hidden">
|
||||
<div class="w-10 h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-300"
|
||||
:class="getHealthScoreBarColor(key.health_score || 0)"
|
||||
@@ -273,6 +311,7 @@
|
||||
<RefreshCw class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="key.auth_type !== 'oauth'"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@@ -282,6 +321,7 @@
|
||||
<Shield class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="key.auth_type !== 'oauth'"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7"
|
||||
@@ -311,6 +351,61 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Codex 上游额度信息(仅当有元数据时显示) -->
|
||||
<div
|
||||
v-if="key.upstream_metadata && hasCodexQuotaData(key.upstream_metadata)"
|
||||
class="mt-2 p-2 bg-muted/30 rounded-md"
|
||||
>
|
||||
<!-- 限额并排显示 -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- 周限额(7天窗口) -->
|
||||
<div v-if="key.upstream_metadata.primary_used_percent !== undefined">
|
||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||
<span class="text-muted-foreground">周限额</span>
|
||||
<span :class="getQuotaRemainingClass(key.upstream_metadata.primary_used_percent)">
|
||||
{{ (100 - key.upstream_metadata.primary_used_percent).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||
:class="getQuotaRemainingBarColor(key.upstream_metadata.primary_used_percent)"
|
||||
:style="{ width: `${Math.max(100 - key.upstream_metadata.primary_used_percent, 0)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="key.upstream_metadata.primary_reset_seconds"
|
||||
class="text-[9px] text-muted-foreground/70 mt-0.5"
|
||||
>
|
||||
{{ formatResetTime(key.upstream_metadata.primary_reset_seconds) }}后重置
|
||||
</div>
|
||||
</div>
|
||||
<!-- 5小时限额 -->
|
||||
<div v-if="key.upstream_metadata.secondary_used_percent !== undefined">
|
||||
<div class="flex items-center justify-between text-[10px] mb-0.5">
|
||||
<span class="text-muted-foreground">5H限额</span>
|
||||
<span :class="getQuotaRemainingClass(key.upstream_metadata.secondary_used_percent)">
|
||||
{{ (100 - key.upstream_metadata.secondary_used_percent).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="relative w-full h-1.5 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full transition-all duration-300"
|
||||
:class="getQuotaRemainingBarColor(key.upstream_metadata.secondary_used_percent)"
|
||||
:style="{ width: `${Math.max(100 - key.upstream_metadata.secondary_used_percent, 0)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-[9px] text-muted-foreground/70 mt-0.5">
|
||||
<template v-if="key.upstream_metadata.secondary_reset_seconds">
|
||||
{{ formatResetTime(key.upstream_metadata.secondary_reset_seconds) }}后重置
|
||||
</template>
|
||||
<template v-else>
|
||||
已重置
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 第二行:优先级 + API 格式(展开显示) + 统计信息 -->
|
||||
<div class="flex items-center gap-1.5 mt-1 text-[11px] text-muted-foreground">
|
||||
<!-- 优先级放最前面,支持点击编辑 -->
|
||||
@@ -459,6 +554,15 @@
|
||||
@edit-created-key="handleEditCreatedKey"
|
||||
/>
|
||||
|
||||
<!-- OAuth 账号对话框 -->
|
||||
<OAuthAccountDialog
|
||||
v-if="open && provider"
|
||||
:open="oauthAccountDialogOpen"
|
||||
:provider-id="provider.id"
|
||||
@close="oauthAccountDialogOpen = false"
|
||||
@saved="handleKeyChanged"
|
||||
/>
|
||||
|
||||
<!-- 模型权限对话框 -->
|
||||
<KeyAllowedModelsEditDialog
|
||||
v-if="open"
|
||||
@@ -526,14 +630,15 @@ import Badge from '@/components/ui/badge.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useCountdownTimer, formatCountdown } from '@/composables/useCountdownTimer'
|
||||
import { useCountdownTimer, formatCountdown, getOAuthExpiresCountdown } from '@/composables/useCountdownTimer'
|
||||
import { getProvider, getProviderEndpoints, updateProvider } from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import {
|
||||
KeyFormDialog,
|
||||
KeyAllowedModelsEditDialog,
|
||||
ModelsTab,
|
||||
BatchAssignModelsDialog
|
||||
BatchAssignModelsDialog,
|
||||
OAuthAccountDialog
|
||||
} from '@/features/providers/components'
|
||||
import ModelMappingTab from '@/features/providers/components/provider-tabs/ModelMappingTab.vue'
|
||||
import EndpointFormDialog from '@/features/providers/components/EndpointFormDialog.vue'
|
||||
@@ -545,6 +650,7 @@ import {
|
||||
getProviderKeys,
|
||||
updateProviderKey,
|
||||
revealEndpointKey,
|
||||
refreshProviderOAuth,
|
||||
type ProviderEndpoint,
|
||||
type EndpointAPIKey,
|
||||
type Model,
|
||||
@@ -591,6 +697,7 @@ const endpointDialogOpen = ref(false)
|
||||
// 密钥相关状态
|
||||
const keyFormDialogOpen = ref(false)
|
||||
const keyPermissionsDialogOpen = ref(false)
|
||||
const oauthAccountDialogOpen = ref(false)
|
||||
const currentEndpoint = ref<ProviderEndpoint | null>(null)
|
||||
const editingKey = ref<EndpointAPIKey | null>(null)
|
||||
const deleteKeyConfirmOpen = ref(false)
|
||||
@@ -620,6 +727,9 @@ const editingPriorityValue = ref<number>(0)
|
||||
const priorityInputRef = ref<HTMLInputElement[] | null>(null)
|
||||
const prioritySaving = ref(false)
|
||||
|
||||
// OAuth 刷新状态
|
||||
const refreshingOAuthKeyId = ref<string | null>(null)
|
||||
|
||||
// 点击编辑倍率相关状态
|
||||
const editingMultiplierKey = ref<string | null>(null)
|
||||
const editingMultiplierFormat = ref<string | null>(null)
|
||||
@@ -632,6 +742,7 @@ const hasBlockingDialogOpen = computed(() =>
|
||||
endpointDialogOpen.value ||
|
||||
keyFormDialogOpen.value ||
|
||||
keyPermissionsDialogOpen.value ||
|
||||
oauthAccountDialogOpen.value ||
|
||||
deleteKeyConfirmOpen.value ||
|
||||
modelFormDialogOpen.value ||
|
||||
batchAssignDialogOpen.value ||
|
||||
@@ -695,6 +806,7 @@ watch(() => props.open, (newOpen) => {
|
||||
endpointDialogOpen.value = false
|
||||
keyFormDialogOpen.value = false
|
||||
keyPermissionsDialogOpen.value = false
|
||||
oauthAccountDialogOpen.value = false
|
||||
deleteKeyConfirmOpen.value = false
|
||||
batchAssignDialogOpen.value = false
|
||||
|
||||
@@ -759,9 +871,15 @@ function handleAddKey(endpoint: ProviderEndpoint) {
|
||||
keyFormDialogOpen.value = true
|
||||
}
|
||||
|
||||
// 添加密钥(如果有多个端点则添加到第一个)
|
||||
// 添加密钥/账号(如果有多个端点则添加到第一个)
|
||||
function handleAddKeyToFirstEndpoint() {
|
||||
if (endpoints.value.length > 0) {
|
||||
if (endpoints.value.length === 0) return
|
||||
|
||||
// 非自定义提供商:打开 OAuth 账号对话框
|
||||
if (provider.value?.provider_type !== 'custom') {
|
||||
oauthAccountDialogOpen.value = true
|
||||
} else {
|
||||
// 自定义提供商:打开密钥表单对话框
|
||||
handleAddKey(endpoints.value[0])
|
||||
}
|
||||
}
|
||||
@@ -849,6 +967,25 @@ async function handleRecoverKey(key: EndpointAPIKey) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefreshOAuth(key: EndpointAPIKey) {
|
||||
if (refreshingOAuthKeyId.value) return
|
||||
refreshingOAuthKeyId.value = key.id
|
||||
try {
|
||||
const result = await refreshProviderOAuth(key.id)
|
||||
showSuccess('Token 刷新成功')
|
||||
// 更新本地数据
|
||||
const keyInList = providerKeys.value.find(k => k.id === key.id)
|
||||
if (keyInList) {
|
||||
keyInList.oauth_expires_at = result.expires_at
|
||||
}
|
||||
emit('refresh')
|
||||
} catch (err: any) {
|
||||
showError(err.response?.data?.detail || 'Token 刷新失败', '错误')
|
||||
} finally {
|
||||
refreshingOAuthKeyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKeyChanged() {
|
||||
await loadEndpoints()
|
||||
// 并行刷新模型列表和模型映射(因为模型权限会影响正则映射预览)
|
||||
@@ -1203,6 +1340,95 @@ function getKeyRateMultiplier(key: EndpointAPIKey, format: string): number {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// OAuth 订阅类型格式化
|
||||
function formatOAuthPlanType(planType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
plus: 'Plus',
|
||||
pro: 'Pro',
|
||||
free: 'Free',
|
||||
team: 'Team',
|
||||
enterprise: 'Enterprise',
|
||||
}
|
||||
return labels[planType] || planType
|
||||
}
|
||||
|
||||
// Codex 剩余额度样式(基于已用百分比计算剩余)
|
||||
function getQuotaRemainingClass(usedPercent: number): string {
|
||||
const remaining = 100 - usedPercent
|
||||
if (remaining <= 10) return 'text-red-600 dark:text-red-400'
|
||||
if (remaining <= 30) return 'text-yellow-600 dark:text-yellow-400'
|
||||
return 'text-green-600 dark:text-green-400'
|
||||
}
|
||||
|
||||
// Codex 剩余额度进度条颜色
|
||||
function getQuotaRemainingBarColor(usedPercent: number): string {
|
||||
const remaining = 100 - usedPercent
|
||||
if (remaining <= 10) return 'bg-red-500 dark:bg-red-400'
|
||||
if (remaining <= 30) return 'bg-yellow-500 dark:bg-yellow-400'
|
||||
return 'bg-green-500 dark:bg-green-400'
|
||||
}
|
||||
|
||||
// 检查是否有 Codex 额度数据
|
||||
function hasCodexQuotaData(metadata: any): boolean {
|
||||
if (!metadata) return false
|
||||
return metadata.primary_used_percent !== undefined ||
|
||||
metadata.secondary_used_percent !== undefined ||
|
||||
(metadata.has_credits && metadata.credits_balance !== undefined)
|
||||
}
|
||||
|
||||
// 格式化重置时间
|
||||
function formatResetTime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}天 ${hours}小时`
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}小时 ${minutes}分钟`
|
||||
}
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
|
||||
// OAuth 订阅类型样式
|
||||
function getOAuthPlanTypeClass(planType: string): string {
|
||||
const classes: Record<string, string> = {
|
||||
plus: 'border-green-500/50 text-green-600 dark:text-green-400',
|
||||
pro: 'border-blue-500/50 text-blue-600 dark:text-blue-400',
|
||||
free: 'border-primary/50 text-primary',
|
||||
team: 'border-purple-500/50 text-purple-600 dark:text-purple-400',
|
||||
enterprise: 'border-amber-500/50 text-amber-600 dark:text-amber-400',
|
||||
}
|
||||
return classes[planType] || ''
|
||||
}
|
||||
|
||||
// OAuth 状态信息(包括失效和过期)
|
||||
function getKeyOAuthExpires(key: EndpointAPIKey) {
|
||||
if (key.auth_type !== 'oauth') return null
|
||||
// 即使没有 expires_at,也要检查 invalid_at
|
||||
if (!key.oauth_expires_at && !key.oauth_invalid_at) return null
|
||||
return getOAuthExpiresCountdown(
|
||||
key.oauth_expires_at,
|
||||
countdownTick.value,
|
||||
key.oauth_invalid_at,
|
||||
key.oauth_invalid_reason
|
||||
)
|
||||
}
|
||||
|
||||
// OAuth 状态的 title 提示
|
||||
function getOAuthStatusTitle(key: EndpointAPIKey): string {
|
||||
const status = getKeyOAuthExpires(key)
|
||||
if (!status) return ''
|
||||
if (status.isInvalid) {
|
||||
return status.invalidReason ? `Token 已失效: ${status.invalidReason}` : 'Token 已失效'
|
||||
}
|
||||
if (status.isExpired) {
|
||||
return 'Token 已过期,请重新授权'
|
||||
}
|
||||
return `Token 剩余有效期: ${status.text}`
|
||||
}
|
||||
|
||||
// 健康度颜色
|
||||
function getHealthScoreColor(score: number): string {
|
||||
if (score >= 0.8) return 'text-green-600 dark:text-green-400'
|
||||
|
||||
@@ -29,11 +29,21 @@
|
||||
<SelectValue placeholder="请选择" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="custom">自定义</SelectItem>
|
||||
<SelectItem value="claude_code">ClaudeCode</SelectItem>
|
||||
<SelectItem value="codex">Codex</SelectItem>
|
||||
<SelectItem value="gemini_cli">GeminiCli</SelectItem>
|
||||
<SelectItem value="antigravity">Antigravity</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
自定义
|
||||
</SelectItem>
|
||||
<SelectItem value="claude_code">
|
||||
ClaudeCode
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
Codex
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini_cli">
|
||||
GeminiCli
|
||||
</SelectItem>
|
||||
<SelectItem value="antigravity">
|
||||
Antigravity
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p
|
||||
|
||||
@@ -89,7 +89,9 @@
|
||||
|
||||
<!-- 视频计费(可选覆盖) -->
|
||||
<div class="pt-3 border-t space-y-2">
|
||||
<div class="text-sm font-medium">视频计费(可选覆盖)</div>
|
||||
<div class="text-sm font-medium">
|
||||
视频计费(可选覆盖)
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<Button
|
||||
@@ -138,7 +140,7 @@
|
||||
<div class="grid grid-cols-[1fr_1fr_32px] gap-0 text-xs text-muted-foreground bg-muted/50 px-3 py-1.5 border-b border-border">
|
||||
<span>分辨率</span>
|
||||
<span>单价($/秒)</span>
|
||||
<span></span>
|
||||
<span />
|
||||
</div>
|
||||
<div class="divide-y divide-border">
|
||||
<div
|
||||
@@ -176,7 +178,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
|
||||
@@ -8,6 +8,7 @@ export { default as ProviderModelFormDialog } from './ProviderModelFormDialog.vu
|
||||
export { default as ProviderDetailDrawer } from './ProviderDetailDrawer.vue'
|
||||
export { default as EndpointHealthTimeline } from './EndpointHealthTimeline.vue'
|
||||
export { default as BatchAssignModelsDialog } from './BatchAssignModelsDialog.vue'
|
||||
export { default as OAuthAccountDialog } from './OAuthAccountDialog.vue'
|
||||
|
||||
export { default as ModelsTab } from './provider-tabs/ModelsTab.vue'
|
||||
export { default as ProviderAuthDialog } from './ProviderAuthDialog.vue'
|
||||
|
||||
@@ -516,10 +516,10 @@ const navigation = computed(() => {
|
||||
// 动态添加已激活模块的菜单项
|
||||
// 图标映射
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
'Key': Key,
|
||||
'FileUp': FileUp,
|
||||
'Shield': Shield,
|
||||
'Puzzle': Puzzle,
|
||||
Key,
|
||||
FileUp,
|
||||
Shield,
|
||||
Puzzle,
|
||||
}
|
||||
|
||||
// 添加模块菜单项(按 admin_menu_order 排序,只显示已激活的)
|
||||
|
||||
@@ -2,71 +2,122 @@
|
||||
<div class="space-y-6 pb-8">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card variant="default" class="p-4">
|
||||
<Card
|
||||
variant="default"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Zap class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">{{ stats?.total ?? '-' }}</p>
|
||||
<p class="text-xs text-muted-foreground">总任务数</p>
|
||||
<p class="text-2xl font-bold">
|
||||
{{ stats?.total ?? '-' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
总任务数
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant="default" class="p-4">
|
||||
<Card
|
||||
variant="default"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Loader2 class="w-5 h-5 text-blue-500" :class="{ 'animate-spin': (stats?.processing_count ?? 0) > 0 }" />
|
||||
<Loader2
|
||||
class="w-5 h-5 text-blue-500"
|
||||
:class="{ 'animate-spin': (stats?.processing_count ?? 0) > 0 }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">{{ stats?.processing_count ?? stats?.by_status?.processing ?? '-' }}</p>
|
||||
<p class="text-xs text-muted-foreground">处理中</p>
|
||||
<p class="text-2xl font-bold">
|
||||
{{ stats?.processing_count ?? stats?.by_status?.processing ?? '-' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
处理中
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant="default" class="p-4">
|
||||
<Card
|
||||
variant="default"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
|
||||
<CheckCircle class="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">{{ stats?.by_status?.completed ?? '-' }}</p>
|
||||
<p class="text-xs text-muted-foreground">已完成</p>
|
||||
<p class="text-2xl font-bold">
|
||||
{{ stats?.by_status?.completed ?? '-' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
已完成
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant="default" class="p-4">
|
||||
<Card
|
||||
variant="default"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center">
|
||||
<Calendar class="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">{{ stats?.today_count ?? '-' }}</p>
|
||||
<p class="text-xs text-muted-foreground">今日任务</p>
|
||||
<p class="text-2xl font-bold">
|
||||
{{ stats?.today_count ?? '-' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
今日任务
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 任务表格 -->
|
||||
<Card variant="default" class="overflow-hidden">
|
||||
<Card
|
||||
variant="default"
|
||||
class="overflow-hidden"
|
||||
>
|
||||
<!-- 标题和筛选器 -->
|
||||
<div class="px-4 sm:px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 class="text-base font-semibold">异步任务</h3>
|
||||
<h3 class="text-base font-semibold">
|
||||
异步任务
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 状态筛选 -->
|
||||
<Select v-model:open="statusSelectOpen" v-model="filterStatus">
|
||||
<Select
|
||||
v-model:open="statusSelectOpen"
|
||||
v-model="filterStatus"
|
||||
>
|
||||
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue placeholder="状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="submitted">已提交</SelectItem>
|
||||
<SelectItem value="processing">处理中</SelectItem>
|
||||
<SelectItem value="completed">已完成</SelectItem>
|
||||
<SelectItem value="failed">失败</SelectItem>
|
||||
<SelectItem value="cancelled">已取消</SelectItem>
|
||||
<SelectItem value="all">
|
||||
全部状态
|
||||
</SelectItem>
|
||||
<SelectItem value="submitted">
|
||||
已提交
|
||||
</SelectItem>
|
||||
<SelectItem value="processing">
|
||||
处理中
|
||||
</SelectItem>
|
||||
<SelectItem value="completed">
|
||||
已完成
|
||||
</SelectItem>
|
||||
<SelectItem value="failed">
|
||||
失败
|
||||
</SelectItem>
|
||||
<SelectItem value="cancelled">
|
||||
已取消
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<!-- 模型筛选 -->
|
||||
@@ -84,34 +135,62 @@
|
||||
:disabled="loading"
|
||||
@click="fetchTasks"
|
||||
>
|
||||
<RefreshCw class="w-3.5 h-3.5" :class="{ 'animate-spin': loading }" />
|
||||
<RefreshCw
|
||||
class="w-3.5 h-3.5"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading && !tasks.length" class="p-8 text-center">
|
||||
<div
|
||||
v-if="loading && !tasks.length"
|
||||
class="p-8 text-center"
|
||||
>
|
||||
<Loader2 class="w-8 h-8 animate-spin mx-auto text-muted-foreground" />
|
||||
<p class="mt-2 text-sm text-muted-foreground">加载中...</p>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
加载中...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="!tasks.length" class="p-8 text-center">
|
||||
<div
|
||||
v-else-if="!tasks.length"
|
||||
class="p-8 text-center"
|
||||
>
|
||||
<Zap class="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||
<p class="mt-2 text-sm text-muted-foreground">暂无异步任务</p>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
暂无异步任务
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端表格 -->
|
||||
<Table v-else class="hidden md:table">
|
||||
<Table
|
||||
v-else
|
||||
class="hidden md:table"
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[25%]">任务</TableHead>
|
||||
<TableHead class="w-[15%]">用户/Provider</TableHead>
|
||||
<TableHead class="w-[12%]">状态</TableHead>
|
||||
<TableHead class="w-[10%]">参数</TableHead>
|
||||
<TableHead class="w-[15%]">时间</TableHead>
|
||||
<TableHead class="w-[8%] text-center">操作</TableHead>
|
||||
<TableHead class="w-[25%]">
|
||||
任务
|
||||
</TableHead>
|
||||
<TableHead class="w-[15%]">
|
||||
用户/Provider
|
||||
</TableHead>
|
||||
<TableHead class="w-[12%]">
|
||||
状态
|
||||
</TableHead>
|
||||
<TableHead class="w-[10%]">
|
||||
参数
|
||||
</TableHead>
|
||||
<TableHead class="w-[15%]">
|
||||
时间
|
||||
</TableHead>
|
||||
<TableHead class="w-[8%] text-center">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -125,10 +204,16 @@
|
||||
<TableCell>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Video v-if="isVideoTask(task)" class="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<Video
|
||||
v-if="isVideoTask(task)"
|
||||
class="w-4 h-4 text-muted-foreground shrink-0"
|
||||
/>
|
||||
<span class="font-medium text-sm truncate">{{ task.model }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground truncate max-w-[280px]" :title="task.prompt">
|
||||
<p
|
||||
class="text-xs text-muted-foreground truncate max-w-[280px]"
|
||||
:title="task.prompt"
|
||||
>
|
||||
{{ task.prompt }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -149,10 +234,16 @@
|
||||
<!-- 状态 -->
|
||||
<TableCell>
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<Badge :variant="getStatusVariant(task.status)" class="text-xs">
|
||||
<Badge
|
||||
:variant="getStatusVariant(task.status)"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ getStatusLabel(task.status) }}
|
||||
</Badge>
|
||||
<div v-if="task.progress_percent > 0 && task.status === 'processing'" class="w-full">
|
||||
<div
|
||||
v-if="task.progress_percent > 0 && task.status === 'processing'"
|
||||
class="w-full"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
@@ -168,12 +259,19 @@
|
||||
<!-- 参数 -->
|
||||
<TableCell>
|
||||
<div class="text-xs space-y-0.5 text-muted-foreground">
|
||||
<div v-if="task.duration_seconds" class="flex items-center gap-1">
|
||||
<div
|
||||
v-if="task.duration_seconds"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Timer class="w-3 h-3" />
|
||||
<span>{{ task.duration_seconds }}s</span>
|
||||
</div>
|
||||
<div v-if="task.resolution">{{ task.resolution }}</div>
|
||||
<div v-if="task.aspect_ratio">{{ task.aspect_ratio }}</div>
|
||||
<div v-if="task.resolution">
|
||||
{{ task.resolution }}
|
||||
</div>
|
||||
<div v-if="task.aspect_ratio">
|
||||
{{ task.aspect_ratio }}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<!-- 时间 -->
|
||||
@@ -183,7 +281,10 @@
|
||||
<Clock class="w-3 h-3" />
|
||||
<span>{{ formatDate(task.created_at) }}</span>
|
||||
</div>
|
||||
<div v-if="task.completed_at" class="flex items-center gap-1.5 text-green-600 dark:text-green-400">
|
||||
<div
|
||||
v-if="task.completed_at"
|
||||
class="flex items-center gap-1.5 text-green-600 dark:text-green-400"
|
||||
>
|
||||
<CheckCircle class="w-3 h-3" />
|
||||
<span>{{ formatDate(task.completed_at) }}</span>
|
||||
</div>
|
||||
@@ -217,7 +318,10 @@
|
||||
</Table>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div v-if="tasks.length" class="md:hidden divide-y divide-border/60">
|
||||
<div
|
||||
v-if="tasks.length"
|
||||
class="md:hidden divide-y divide-border/60"
|
||||
>
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="`m-${task.id}`"
|
||||
@@ -227,27 +331,40 @@
|
||||
<!-- 顶部:模型和状态 -->
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Video v-if="isVideoTask(task)" class="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<Video
|
||||
v-if="isVideoTask(task)"
|
||||
class="w-4 h-4 text-muted-foreground shrink-0"
|
||||
/>
|
||||
<span class="font-medium text-sm truncate">{{ task.model }}</span>
|
||||
</div>
|
||||
<Badge :variant="getStatusVariant(task.status)" class="text-xs shrink-0">
|
||||
<Badge
|
||||
:variant="getStatusVariant(task.status)"
|
||||
class="text-xs shrink-0"
|
||||
>
|
||||
{{ getStatusLabel(task.status) }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- 进度条(如果有) -->
|
||||
<div v-if="task.progress_percent > 0 && task.status === 'processing'" class="space-y-1">
|
||||
<div
|
||||
v-if="task.progress_percent > 0 && task.status === 'processing'"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-primary transition-all"
|
||||
:style="{ width: `${task.progress_percent}%` }"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground text-right">{{ task.progress_percent }}%</p>
|
||||
<p class="text-xs text-muted-foreground text-right">
|
||||
{{ task.progress_percent }}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Prompt -->
|
||||
<p class="text-sm text-muted-foreground line-clamp-2">{{ task.prompt }}</p>
|
||||
<p class="text-sm text-muted-foreground line-clamp-2">
|
||||
{{ task.prompt }}
|
||||
</p>
|
||||
|
||||
<!-- 信息网格 -->
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
@@ -263,7 +380,10 @@
|
||||
<Clock class="w-3 h-3" />
|
||||
<span>{{ formatDate(task.created_at) }}</span>
|
||||
</div>
|
||||
<div v-if="task.duration_seconds" class="flex items-center gap-1.5 text-muted-foreground">
|
||||
<div
|
||||
v-if="task.duration_seconds"
|
||||
class="flex items-center gap-1.5 text-muted-foreground"
|
||||
>
|
||||
<Timer class="w-3 h-3" />
|
||||
<span>{{ task.duration_seconds }}s</span>
|
||||
</div>
|
||||
@@ -326,9 +446,14 @@
|
||||
<!-- 第一行:标题、模型、状态、操作按钮 -->
|
||||
<div class="flex items-center justify-between gap-4 mb-3">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<h3 class="text-lg font-semibold">任务详情</h3>
|
||||
<h3 class="text-lg font-semibold">
|
||||
任务详情
|
||||
</h3>
|
||||
<div class="flex items-center gap-1 text-sm font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
||||
<Video v-if="isVideoTask(selectedTask)" class="w-3.5 h-3.5 mr-1" />
|
||||
<Video
|
||||
v-if="isVideoTask(selectedTask)"
|
||||
class="w-3.5 h-3.5 mr-1"
|
||||
/>
|
||||
<span>{{ selectedTask.model }}</span>
|
||||
</div>
|
||||
<Badge :variant="getStatusVariant(selectedTask.status)">
|
||||
@@ -344,9 +469,18 @@
|
||||
:title="detailAutoRefresh ? '停止自动刷新' : '开启自动刷新(每5秒)'"
|
||||
@click="toggleDetailAutoRefresh"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': detailAutoRefresh }" />
|
||||
<RefreshCw
|
||||
class="w-4 h-4"
|
||||
:class="{ 'animate-spin': detailAutoRefresh }"
|
||||
/>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" title="关闭" @click="closeDetail">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="关闭"
|
||||
@click="closeDetail"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -365,7 +499,10 @@
|
||||
<span>Provider: {{ selectedTask.provider_name }}</span>
|
||||
</div>
|
||||
<!-- 进度条 -->
|
||||
<div v-if="selectedTask.progress_percent > 0 && selectedTask.status === 'processing'" class="mt-3">
|
||||
<div
|
||||
v-if="selectedTask.progress_percent > 0 && selectedTask.status === 'processing'"
|
||||
class="mt-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
@@ -375,7 +512,10 @@
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground font-medium">{{ selectedTask.progress_percent }}%</span>
|
||||
</div>
|
||||
<p v-if="selectedTask.progress_message" class="text-xs text-muted-foreground mt-1">
|
||||
<p
|
||||
v-if="selectedTask.progress_message"
|
||||
class="text-xs text-muted-foreground mt-1"
|
||||
>
|
||||
{{ selectedTask.progress_message }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -384,20 +524,31 @@
|
||||
<!-- 可滚动内容区域 -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto px-3 sm:px-6 py-3 sm:py-4 space-y-5">
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="selectedTask.error_message" class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<div
|
||||
v-if="selectedTask.error_message"
|
||||
class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<AlertCircle class="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p v-if="selectedTask.error_code" class="text-xs font-medium text-red-600 dark:text-red-400 mb-1">
|
||||
<p
|
||||
v-if="selectedTask.error_code"
|
||||
class="text-xs font-medium text-red-600 dark:text-red-400 mb-1"
|
||||
>
|
||||
错误码: {{ selectedTask.error_code }}
|
||||
</p>
|
||||
<p class="text-sm text-red-600 dark:text-red-400">{{ selectedTask.error_message }}</p>
|
||||
<p class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ selectedTask.error_message }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频结果(放在最前面) -->
|
||||
<div v-if="selectedTask.video_url || selectedTask.video_urls?.length" class="space-y-3">
|
||||
<div
|
||||
v-if="selectedTask.video_url || selectedTask.video_urls?.length"
|
||||
class="space-y-3"
|
||||
>
|
||||
<!-- 主视频 -->
|
||||
<div v-if="selectedTask.video_url">
|
||||
<div class="rounded-lg overflow-hidden border border-border/60 bg-black">
|
||||
@@ -412,7 +563,10 @@
|
||||
<div class="mt-2 space-y-2">
|
||||
<!-- 链接 -->
|
||||
<div class="flex items-center gap-1 p-1.5 bg-muted/50 rounded border border-border/40">
|
||||
<code class="flex-1 text-xs text-foreground truncate px-1" :title="selectedTask.video_url">{{ selectedTask.video_url }}</code>
|
||||
<code
|
||||
class="flex-1 text-xs text-foreground truncate px-1"
|
||||
:title="selectedTask.video_url"
|
||||
>{{ selectedTask.video_url }}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -423,11 +577,17 @@
|
||||
</Button>
|
||||
</div>
|
||||
<!-- 元信息 -->
|
||||
<div v-if="selectedTask.video_size_bytes || selectedTask.video_expires_at" class="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div
|
||||
v-if="selectedTask.video_size_bytes || selectedTask.video_expires_at"
|
||||
class="flex items-center gap-3 text-xs text-muted-foreground"
|
||||
>
|
||||
<span v-if="selectedTask.video_size_bytes">
|
||||
大小: {{ formatFileSize(selectedTask.video_size_bytes) }}
|
||||
</span>
|
||||
<span v-if="selectedTask.video_expires_at" class="text-amber-600 dark:text-amber-400">
|
||||
<span
|
||||
v-if="selectedTask.video_expires_at"
|
||||
class="text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
过期: {{ formatDate(selectedTask.video_expires_at) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -435,14 +595,30 @@
|
||||
</div>
|
||||
|
||||
<!-- 多个视频 -->
|
||||
<div v-else-if="selectedTask.video_urls?.length" class="space-y-4">
|
||||
<div v-for="(url, index) in selectedTask.video_urls" :key="index">
|
||||
<p class="text-xs text-muted-foreground font-medium mb-1.5">视频 {{ index + 1 }}</p>
|
||||
<div
|
||||
v-else-if="selectedTask.video_urls?.length"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div
|
||||
v-for="(url, index) in selectedTask.video_urls"
|
||||
:key="index"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground font-medium mb-1.5">
|
||||
视频 {{ index + 1 }}
|
||||
</p>
|
||||
<div class="rounded-lg overflow-hidden border border-border/60 bg-black">
|
||||
<video :src="getVideoUrl(selectedTask.id, url)" controls preload="none" class="w-full max-h-[250px] object-contain" />
|
||||
<video
|
||||
:src="getVideoUrl(selectedTask.id, url)"
|
||||
controls
|
||||
preload="none"
|
||||
class="w-full max-h-[250px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-center gap-1 p-1.5 bg-muted/50 rounded border border-border/40">
|
||||
<code class="flex-1 text-xs text-foreground truncate px-1" :title="url">{{ url }}</code>
|
||||
<code
|
||||
class="flex-1 text-xs text-foreground truncate px-1"
|
||||
:title="url"
|
||||
>{{ url }}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -457,15 +633,22 @@
|
||||
</div>
|
||||
|
||||
<!-- 任务完成但无视频 -->
|
||||
<div v-else-if="selectedTask.status === 'completed'" class="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800 text-center">
|
||||
<div
|
||||
v-else-if="selectedTask.status === 'completed'"
|
||||
class="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800 text-center"
|
||||
>
|
||||
<Video class="w-8 h-8 mx-auto mb-2 text-amber-500" />
|
||||
<p class="text-sm text-amber-600 dark:text-amber-400">视频链接不可用或已过期</p>
|
||||
<p class="text-sm text-amber-600 dark:text-amber-400">
|
||||
视频链接不可用或已过期
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Prompt -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">Prompt</h4>
|
||||
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Prompt
|
||||
</h4>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -482,69 +665,134 @@
|
||||
</div>
|
||||
|
||||
<!-- 视频信息(网格布局) -->
|
||||
<div v-if="selectedTask.video_duration_seconds || selectedTask.resolution || selectedTask.aspect_ratio || selectedTask.size" class="space-y-2">
|
||||
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">视频信息</h4>
|
||||
<div
|
||||
v-if="selectedTask.video_duration_seconds || selectedTask.resolution || selectedTask.aspect_ratio || selectedTask.size"
|
||||
class="space-y-2"
|
||||
>
|
||||
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
视频信息
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div v-if="selectedTask.video_duration_seconds" class="p-3 bg-muted/30 rounded-lg">
|
||||
<p class="text-xs text-muted-foreground mb-0.5">视频时长</p>
|
||||
<p class="text-sm font-medium">{{ selectedTask.video_duration_seconds.toFixed(1) }}s</p>
|
||||
<div
|
||||
v-if="selectedTask.video_duration_seconds"
|
||||
class="p-3 bg-muted/30 rounded-lg"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground mb-0.5">
|
||||
视频时长
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
{{ selectedTask.video_duration_seconds.toFixed(1) }}s
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="selectedTask.resolution" class="p-3 bg-muted/30 rounded-lg">
|
||||
<p class="text-xs text-muted-foreground mb-0.5">分辨率</p>
|
||||
<p class="text-sm font-medium">{{ selectedTask.resolution }}</p>
|
||||
<div
|
||||
v-if="selectedTask.resolution"
|
||||
class="p-3 bg-muted/30 rounded-lg"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground mb-0.5">
|
||||
分辨率
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
{{ selectedTask.resolution }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="selectedTask.aspect_ratio" class="p-3 bg-muted/30 rounded-lg">
|
||||
<p class="text-xs text-muted-foreground mb-0.5">宽高比</p>
|
||||
<p class="text-sm font-medium">{{ selectedTask.aspect_ratio }}</p>
|
||||
<div
|
||||
v-if="selectedTask.aspect_ratio"
|
||||
class="p-3 bg-muted/30 rounded-lg"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground mb-0.5">
|
||||
宽高比
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
{{ selectedTask.aspect_ratio }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="selectedTask.size" class="p-3 bg-muted/30 rounded-lg">
|
||||
<p class="text-xs text-muted-foreground mb-0.5">尺寸</p>
|
||||
<p class="text-sm font-medium">{{ selectedTask.size }}</p>
|
||||
<div
|
||||
v-if="selectedTask.size"
|
||||
class="p-3 bg-muted/30 rounded-lg"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground mb-0.5">
|
||||
尺寸
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
{{ selectedTask.size }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 执行状态(网格布局) -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">执行状态</h4>
|
||||
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
执行状态
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div class="p-3 bg-muted/30 rounded-lg">
|
||||
<p class="text-xs text-muted-foreground mb-0.5">轮询</p>
|
||||
<p class="text-sm font-medium">{{ selectedTask.poll_count }} / {{ selectedTask.max_poll_count }}</p>
|
||||
<p class="text-xs text-muted-foreground mb-0.5">
|
||||
轮询
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
{{ selectedTask.poll_count }} / {{ selectedTask.max_poll_count }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted/30 rounded-lg">
|
||||
<p class="text-xs text-muted-foreground mb-0.5">重试</p>
|
||||
<p class="text-sm font-medium">{{ selectedTask.retry_count }} / {{ selectedTask.max_retries }}</p>
|
||||
<p class="text-xs text-muted-foreground mb-0.5">
|
||||
重试
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
{{ selectedTask.retry_count }} / {{ selectedTask.max_retries }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-3 bg-muted/30 rounded-lg">
|
||||
<p class="text-xs text-muted-foreground mb-0.5">轮询间隔</p>
|
||||
<p class="text-sm font-medium">{{ selectedTask.poll_interval_seconds }}s</p>
|
||||
<p class="text-xs text-muted-foreground mb-0.5">
|
||||
轮询间隔
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
{{ selectedTask.poll_interval_seconds }}s
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="selectedTask.next_poll_at" class="p-3 bg-muted/30 rounded-lg">
|
||||
<p class="text-xs text-muted-foreground mb-0.5">下次轮询</p>
|
||||
<p class="text-sm font-medium">{{ formatDate(selectedTask.next_poll_at) }}</p>
|
||||
<div
|
||||
v-if="selectedTask.next_poll_at"
|
||||
class="p-3 bg-muted/30 rounded-lg"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground mb-0.5">
|
||||
下次轮询
|
||||
</p>
|
||||
<p class="text-sm font-medium">
|
||||
{{ formatDate(selectedTask.next_poll_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间范围 -->
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">时间范围</h4>
|
||||
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
时间范围
|
||||
</h4>
|
||||
<div class="flex items-center gap-1 text-sm font-medium">
|
||||
<span>{{ formatTimeWithMs(selectedTask.created_at) }}</span>
|
||||
<span class="time-arrow-container">
|
||||
<span v-if="selectedTask.completed_at" class="time-duration">+{{ calcDuration(selectedTask.created_at, selectedTask.completed_at) }}</span>
|
||||
<span
|
||||
v-if="selectedTask.completed_at"
|
||||
class="time-duration"
|
||||
>+{{ calcDuration(selectedTask.created_at, selectedTask.completed_at) }}</span>
|
||||
<span class="time-arrow">→</span>
|
||||
</span>
|
||||
<template v-if="selectedTask.completed_at">
|
||||
<span>{{ formatTimeWithMs(selectedTask.completed_at) }}</span>
|
||||
</template>
|
||||
<span v-else class="text-muted-foreground">处理中...</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-muted-foreground"
|
||||
>处理中...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 响应数据 -->
|
||||
<div v-if="selectedTask.request_metadata?.poll_raw_response" class="space-y-2">
|
||||
<div
|
||||
v-if="selectedTask.request_metadata?.poll_raw_response"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<FileJson class="w-3.5 h-3.5" />
|
||||
@@ -566,7 +814,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div v-if="canCancel(selectedTask.status)" class="pt-4 border-t border-border/60">
|
||||
<div
|
||||
v-if="canCancel(selectedTask.status)"
|
||||
class="pt-4 border-t border-border/60"
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
class="w-full"
|
||||
@@ -591,52 +842,6 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.drawer-enter-active > div:first-child,
|
||||
.drawer-leave-active > div:first-child {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.drawer-enter-active > div:last-child,
|
||||
.drawer-leave-active > div:last-child {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.drawer-enter-from > div:last-child,
|
||||
.drawer-leave-to > div:last-child {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* 时间范围箭头容器 */
|
||||
.time-arrow-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.time-arrow {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.time-duration {
|
||||
position: absolute;
|
||||
top: -1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.65rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { asyncTasksApi, type AsyncTaskItem, type AsyncTaskDetail, type AsyncTaskStatsResponse, type AsyncTaskStatus } from '@/api/async-tasks'
|
||||
@@ -1043,3 +1248,49 @@ onUnmounted(() => {
|
||||
clearTimeout(filterTimeout)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.drawer-enter-active > div:first-child,
|
||||
.drawer-leave-active > div:first-child {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.drawer-enter-active > div:last-child,
|
||||
.drawer-leave-active > div:last-child {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.drawer-enter-from > div:last-child,
|
||||
.drawer-leave-to > div:last-child {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* 时间范围箭头容器 */
|
||||
.time-arrow-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.time-arrow {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.time-duration {
|
||||
position: absolute;
|
||||
top: -1rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.65rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,31 +2,49 @@
|
||||
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold">成本分析</h1>
|
||||
<p class="text-xs text-muted-foreground">成本趋势、预测与节省统计</p>
|
||||
<h1 class="text-lg font-semibold">
|
||||
成本分析
|
||||
</h1>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
成本趋势、预测与节省统计
|
||||
</p>
|
||||
</div>
|
||||
<TimeRangePicker v-model="timeRange" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<Card class="p-4 space-y-2">
|
||||
<div class="text-xs text-muted-foreground">缓存节省</div>
|
||||
<div class="text-lg font-semibold">{{ formatCurrency(costSavings?.cache_savings ?? 0) }}</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
缓存节省
|
||||
</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{{ formatCurrency(costSavings?.cache_savings ?? 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
读取成本 {{ formatCurrency(costSavings?.cache_read_cost ?? 0) }}
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4 space-y-2">
|
||||
<div class="text-xs text-muted-foreground">缓存读取 Tokens</div>
|
||||
<div class="text-lg font-semibold">{{ formatTokens(costSavings?.cache_read_tokens ?? 0) }}</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
缓存读取 Tokens
|
||||
</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{{ formatTokens(costSavings?.cache_read_tokens ?? 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
预计全额成本 {{ formatCurrency(costSavings?.estimated_full_cost ?? 0) }}
|
||||
</div>
|
||||
</Card>
|
||||
<Card class="p-4 space-y-2">
|
||||
<div class="text-xs text-muted-foreground">缓存创建成本</div>
|
||||
<div class="text-lg font-semibold">{{ formatCurrency(costSavings?.cache_creation_cost ?? 0) }}</div>
|
||||
<div class="text-xs text-muted-foreground">基于当前时间范围</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
缓存创建成本
|
||||
</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{{ formatCurrency(costSavings?.cache_creation_cost ?? 0) }}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
基于当前时间范围
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,56 +2,89 @@
|
||||
<div class="space-y-6 pb-8">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card variant="default" class="p-4">
|
||||
<Card
|
||||
variant="default"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<FileUp class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">{{ stats?.total_mappings ?? '-' }}</p>
|
||||
<p class="text-xs text-muted-foreground">总文件数</p>
|
||||
<p class="text-2xl font-bold">
|
||||
{{ stats?.total_mappings ?? '-' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
总文件数
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant="default" class="p-4">
|
||||
<Card
|
||||
variant="default"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
|
||||
<CheckCircle class="w-5 h-5 text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">{{ stats?.active_mappings ?? '-' }}</p>
|
||||
<p class="text-xs text-muted-foreground">有效文件</p>
|
||||
<p class="text-2xl font-bold">
|
||||
{{ stats?.active_mappings ?? '-' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
有效文件
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant="default" class="p-4">
|
||||
<Card
|
||||
variant="default"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center">
|
||||
<Clock class="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">{{ stats?.expired_mappings ?? '-' }}</p>
|
||||
<p class="text-xs text-muted-foreground">已过期</p>
|
||||
<p class="text-2xl font-bold">
|
||||
{{ stats?.expired_mappings ?? '-' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
已过期
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant="default" class="p-4">
|
||||
<Card
|
||||
variant="default"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
||||
<Key class="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-2xl font-bold">{{ stats?.capable_keys_count ?? '-' }}</p>
|
||||
<p class="text-xs text-muted-foreground">支持的 Key</p>
|
||||
<p class="text-2xl font-bold">
|
||||
{{ stats?.capable_keys_count ?? '-' }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
支持的 Key
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- 上传区域 -->
|
||||
<Card variant="default" class="p-4">
|
||||
<Card
|
||||
variant="default"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-medium">上传文件</h3>
|
||||
<h3 class="text-sm font-medium">
|
||||
上传文件
|
||||
</h3>
|
||||
<Button
|
||||
v-if="capableKeys.length > 0"
|
||||
variant="ghost"
|
||||
@@ -64,8 +97,13 @@
|
||||
</div>
|
||||
|
||||
<!-- Key 选择器 -->
|
||||
<div v-if="capableKeys.length > 0" class="mb-4">
|
||||
<p class="text-xs text-muted-foreground mb-2">选择要上传到的 Key(可多选):</p>
|
||||
<div
|
||||
v-if="capableKeys.length > 0"
|
||||
class="mb-4"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground mb-2">
|
||||
选择要上传到的 Key(可多选):
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="key in capableKeys"
|
||||
@@ -77,11 +115,17 @@
|
||||
@click="toggleKeySelection(key.id)"
|
||||
>
|
||||
<span class="font-medium">{{ key.name }}</span>
|
||||
<span v-if="key.provider_name" class="text-muted-foreground ml-1">({{ key.provider_name }})</span>
|
||||
<span
|
||||
v-if="key.provider_name"
|
||||
class="text-muted-foreground ml-1"
|
||||
>({{ key.provider_name }})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mb-4 text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/30 rounded-lg p-3">
|
||||
<div
|
||||
v-else
|
||||
class="mb-4 text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/30 rounded-lg p-3"
|
||||
>
|
||||
暂无可用的 Key,请先配置具有「Gemini 文件 API」能力的 Key
|
||||
</div>
|
||||
|
||||
@@ -102,12 +146,20 @@
|
||||
type="file"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
<div v-if="uploading" class="flex flex-col items-center gap-2">
|
||||
>
|
||||
<div
|
||||
v-if="uploading"
|
||||
class="flex flex-col items-center gap-2"
|
||||
>
|
||||
<Loader2 class="w-8 h-8 animate-spin text-primary" />
|
||||
<p class="text-sm text-muted-foreground">正在上传到 {{ selectedKeyIds.length }} 个 Key...</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
正在上传到 {{ selectedKeyIds.length }} 个 Key...
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center gap-2">
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center gap-2"
|
||||
>
|
||||
<Upload class="w-8 h-8 text-muted-foreground" />
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<template v-if="selectedKeyIds.length > 0">
|
||||
@@ -131,8 +183,14 @@
|
||||
</Card>
|
||||
|
||||
<!-- MIME 类型分布 -->
|
||||
<Card v-if="stats?.by_mime_type && Object.keys(stats.by_mime_type).length > 0" variant="default" class="p-4">
|
||||
<h3 class="text-sm font-medium mb-3">文件类型分布</h3>
|
||||
<Card
|
||||
v-if="stats?.by_mime_type && Object.keys(stats.by_mime_type).length > 0"
|
||||
variant="default"
|
||||
class="p-4"
|
||||
>
|
||||
<h3 class="text-sm font-medium mb-3">
|
||||
文件类型分布
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="(count, mimeType) in stats.by_mime_type"
|
||||
@@ -146,11 +204,16 @@
|
||||
</Card>
|
||||
|
||||
<!-- 文件映射表格 -->
|
||||
<Card variant="default" class="overflow-hidden">
|
||||
<Card
|
||||
variant="default"
|
||||
class="overflow-hidden"
|
||||
>
|
||||
<!-- 标题和筛选器 -->
|
||||
<div class="px-4 sm:px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 class="text-base font-semibold">文件映射</h3>
|
||||
<h3 class="text-base font-semibold">
|
||||
文件映射
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 搜索 -->
|
||||
<Input
|
||||
@@ -165,7 +228,7 @@
|
||||
v-model="includeExpired"
|
||||
type="checkbox"
|
||||
class="rounded border-border"
|
||||
/>
|
||||
>
|
||||
包含过期
|
||||
</label>
|
||||
<!-- 清理过期按钮 -->
|
||||
@@ -187,29 +250,45 @@
|
||||
:disabled="loading"
|
||||
@click="fetchData"
|
||||
>
|
||||
<RefreshCw class="w-3.5 h-3.5" :class="{ 'animate-spin': loading }" />
|
||||
<RefreshCw
|
||||
class="w-3.5 h-3.5"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading && !mappings.length" class="p-8 text-center">
|
||||
<div
|
||||
v-if="loading && !mappings.length"
|
||||
class="p-8 text-center"
|
||||
>
|
||||
<Loader2 class="w-8 h-8 animate-spin mx-auto text-muted-foreground" />
|
||||
<p class="mt-2 text-sm text-muted-foreground">加载中...</p>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
加载中...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="!mappings.length" class="p-8 text-center">
|
||||
<div
|
||||
v-else-if="!mappings.length"
|
||||
class="p-8 text-center"
|
||||
>
|
||||
<FileUp class="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||
<p class="mt-2 text-sm text-muted-foreground">暂无文件映射</p>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
暂无文件映射
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
用户通过 Gemini Files API 上传文件后会在此显示
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-else class="divide-y divide-border/60">
|
||||
<div
|
||||
v-else
|
||||
class="divide-y divide-border/60"
|
||||
>
|
||||
<div
|
||||
v-for="mapping in mappings"
|
||||
:key="mapping.id"
|
||||
@@ -220,30 +299,53 @@
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 文件名和状态 -->
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<component :is="getFileIcon(mapping.mime_type)" class="w-4 h-4 text-muted-foreground" />
|
||||
<component
|
||||
:is="getFileIcon(mapping.mime_type)"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
/>
|
||||
<span class="font-mono text-sm font-medium">{{ mapping.file_name }}</span>
|
||||
<Badge v-if="mapping.is_expired" variant="secondary" class="text-xs">
|
||||
<Badge
|
||||
v-if="mapping.is_expired"
|
||||
variant="secondary"
|
||||
class="text-xs"
|
||||
>
|
||||
已过期
|
||||
</Badge>
|
||||
<Badge v-else variant="outline" class="text-xs text-green-600">
|
||||
<Badge
|
||||
v-else
|
||||
variant="outline"
|
||||
class="text-xs text-green-600"
|
||||
>
|
||||
有效
|
||||
</Badge>
|
||||
</div>
|
||||
<!-- 显示名 -->
|
||||
<p v-if="mapping.display_name" class="text-sm text-muted-foreground truncate">
|
||||
<p
|
||||
v-if="mapping.display_name"
|
||||
class="text-sm text-muted-foreground truncate"
|
||||
>
|
||||
{{ mapping.display_name }}
|
||||
</p>
|
||||
<!-- 元信息 -->
|
||||
<div class="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||
<span v-if="mapping.mime_type" class="flex items-center gap-1">
|
||||
<span
|
||||
v-if="mapping.mime_type"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<File class="w-3 h-3" />
|
||||
{{ mapping.mime_type }}
|
||||
</span>
|
||||
<span v-if="mapping.username" class="flex items-center gap-1">
|
||||
<span
|
||||
v-if="mapping.username"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<User class="w-3 h-3" />
|
||||
{{ mapping.username }}
|
||||
</span>
|
||||
<span v-if="mapping.key_name" class="flex items-center gap-1">
|
||||
<span
|
||||
v-if="mapping.key_name"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Key class="w-3 h-3" />
|
||||
{{ mapping.key_name }}
|
||||
</span>
|
||||
@@ -251,7 +353,10 @@
|
||||
<Clock class="w-3 h-3" />
|
||||
{{ formatDate(mapping.created_at) }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1" :class="{ 'text-red-500': mapping.is_expired }">
|
||||
<span
|
||||
class="flex items-center gap-1"
|
||||
:class="{ 'text-red-500': mapping.is_expired }"
|
||||
>
|
||||
<Timer class="w-3 h-3" />
|
||||
过期: {{ formatDate(mapping.expires_at) }}
|
||||
</span>
|
||||
@@ -274,7 +379,10 @@
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div v-if="totalPages > 1" class="px-4 sm:px-6 py-3 border-t border-border/60 flex items-center justify-between">
|
||||
<div
|
||||
v-if="totalPages > 1"
|
||||
class="px-4 sm:px-6 py-3 border-t border-border/60 flex items-center justify-between"
|
||||
>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
共 {{ total }} 条记录
|
||||
</p>
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold">性能分析</h1>
|
||||
<p class="text-xs text-muted-foreground">延迟分布与错误统计</p>
|
||||
<h1 class="text-lg font-semibold">
|
||||
性能分析
|
||||
</h1>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
延迟分布与错误统计
|
||||
</p>
|
||||
</div>
|
||||
<TimeRangePicker v-model="timeRange" />
|
||||
</div>
|
||||
@@ -36,22 +40,38 @@
|
||||
/>
|
||||
</Card>
|
||||
<Card class="p-4 space-y-3">
|
||||
<h3 class="text-sm font-semibold">错误趋势</h3>
|
||||
<div v-if="errorLoading" class="p-6">
|
||||
<h3 class="text-sm font-semibold">
|
||||
错误趋势
|
||||
</h3>
|
||||
<div
|
||||
v-if="errorLoading"
|
||||
class="p-6"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div v-else class="h-[260px]">
|
||||
<div
|
||||
v-else
|
||||
class="h-[260px]"
|
||||
>
|
||||
<LineChart :data="errorTrendChartData" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card class="p-4 space-y-3">
|
||||
<h3 class="text-sm font-semibold">提供商健康度</h3>
|
||||
<div v-if="providerLoading" class="p-4">
|
||||
<h3 class="text-sm font-semibold">
|
||||
提供商健康度
|
||||
</h3>
|
||||
<div
|
||||
v-if="providerLoading"
|
||||
class="p-4"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-sm">
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-sm"
|
||||
>
|
||||
<div
|
||||
v-for="provider in providerStatus"
|
||||
:key="provider.name"
|
||||
|
||||
@@ -2,12 +2,22 @@
|
||||
<div class="space-y-6 px-4 sm:px-6 lg:px-0">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold">用户统计</h1>
|
||||
<p class="text-xs text-muted-foreground">查看用户排行榜与使用趋势</p>
|
||||
<h1 class="text-lg font-semibold">
|
||||
用户统计
|
||||
</h1>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
查看用户排行榜与使用趋势
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<TimeRangePicker v-model="timeRange" :allow-hourly="true" />
|
||||
<Select v-model:open="userSelectOpen" v-model="selectedUserId">
|
||||
<TimeRangePicker
|
||||
v-model="timeRange"
|
||||
:allow-hourly="true"
|
||||
/>
|
||||
<Select
|
||||
v-model:open="userSelectOpen"
|
||||
v-model="selectedUserId"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs w-52">
|
||||
<SelectValue placeholder="选择用户" />
|
||||
</SelectTrigger>
|
||||
@@ -21,12 +31,17 @@
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select v-model:open="compareUserSelectOpen" v-model="compareUserId">
|
||||
<Select
|
||||
v-model:open="compareUserSelectOpen"
|
||||
v-model="compareUserId"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs w-52">
|
||||
<SelectValue placeholder="对比用户(可选)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">不对比</SelectItem>
|
||||
<SelectItem value="__none__">
|
||||
不对比
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-for="user in users"
|
||||
:key="`compare-${user.id}`"
|
||||
@@ -49,43 +64,80 @@
|
||||
/>
|
||||
|
||||
<Card class="p-4 space-y-3">
|
||||
<h3 class="text-sm font-semibold">用户摘要</h3>
|
||||
<div v-if="summaryLoading" class="p-6">
|
||||
<h3 class="text-sm font-semibold">
|
||||
用户摘要
|
||||
</h3>
|
||||
<div
|
||||
v-if="summaryLoading"
|
||||
class="p-6"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-2 gap-3 text-sm"
|
||||
>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">请求数</div>
|
||||
<div class="font-semibold">{{ userSummary?.total_requests ?? 0 }}</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
请求数
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ userSummary?.total_requests ?? 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">Tokens</div>
|
||||
<div class="font-semibold">{{ formatTokens(userSummary?.total_tokens ?? 0) }}</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
Tokens
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ formatTokens(userSummary?.total_tokens ?? 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">成本</div>
|
||||
<div class="font-semibold">{{ formatCurrency(userSummary?.total_cost ?? 0) }}</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
成本
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ formatCurrency(userSummary?.total_cost ?? 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-muted-foreground">错误率</div>
|
||||
<div class="font-semibold">{{ userSummary?.error_rate ?? 0 }}%</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
错误率
|
||||
</div>
|
||||
<div class="font-semibold">
|
||||
{{ userSummary?.error_rate ?? 0 }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card class="p-4 space-y-4">
|
||||
<h3 class="text-sm font-semibold">用户使用趋势</h3>
|
||||
<div v-if="seriesLoading" class="p-6">
|
||||
<h3 class="text-sm font-semibold">
|
||||
用户使用趋势
|
||||
</h3>
|
||||
<div
|
||||
v-if="seriesLoading"
|
||||
class="p-6"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div v-else class="h-[280px]">
|
||||
<div
|
||||
v-else
|
||||
class="h-[280px]"
|
||||
>
|
||||
<LineChart :data="seriesChartData" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card v-if="comparisonSeries.length > 0" class="p-4 space-y-4">
|
||||
<h3 class="text-sm font-semibold">用户对比趋势</h3>
|
||||
<Card
|
||||
v-if="comparisonSeries.length > 0"
|
||||
class="p-4 space-y-4"
|
||||
>
|
||||
<h3 class="text-sm font-semibold">
|
||||
用户对比趋势
|
||||
</h3>
|
||||
<div class="h-[280px]">
|
||||
<LineChart :data="comparisonChartData" />
|
||||
</div>
|
||||
|
||||
@@ -247,7 +247,10 @@
|
||||
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)"
|
||||
>
|
||||
欢迎使用 <span class="text-primary typewriter">{{ aetherText }}<span class="cursor" :class="{ 'cursor-hidden': !showCursor }">_</span></span>
|
||||
欢迎使用 <span class="text-primary typewriter">{{ aetherText }}<span
|
||||
class="cursor"
|
||||
:class="{ 'cursor-hidden': !showCursor }"
|
||||
>_</span></span>
|
||||
</h1>
|
||||
<p
|
||||
class="mb-8 text-base sm:text-lg md:text-xl text-[#666663] dark:text-[#c9c3b4] max-w-2xl mx-auto transition-all duration-700"
|
||||
@@ -568,7 +571,7 @@ const aetherText = ref('')
|
||||
const showCursor = ref(true)
|
||||
const typewriterFullText = 'Aether'
|
||||
let typewriterTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let hasTypewriterStarted = ref(false)
|
||||
const hasTypewriterStarted = ref(false)
|
||||
|
||||
const startTypewriter = () => {
|
||||
if (hasTypewriterStarted.value) return
|
||||
|
||||
@@ -88,12 +88,17 @@ const systemSettings = [
|
||||
格式转换
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-5']">
|
||||
<div
|
||||
class="p-5"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 rounded-lg bg-purple-500/10">
|
||||
<Shuffle class="h-5 w-5 text-purple-500" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">什么是格式转换?</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
什么是格式转换?
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mb-4">
|
||||
@@ -112,21 +117,30 @@ const systemSettings = [
|
||||
<ArrowRight class="h-4 w-4 text-[#999]" />
|
||||
<span :class="panelClasses.badgeGreen">{{ example.to }}</span>
|
||||
</div>
|
||||
<p class="text-xs text-[#666663] dark:text-[#a3a094]">{{ example.description }}</p>
|
||||
<p class="text-xs text-[#666663] dark:text-[#a3a094]">
|
||||
{{ example.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 如何启用 -->
|
||||
<div :class="[panelClasses.section, 'p-5 space-y-4']">
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">如何启用格式转换</h3>
|
||||
<div
|
||||
class="p-5 space-y-4"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
如何启用格式转换
|
||||
</h3>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-8 h-8 rounded-full bg-[#cc785c] flex items-center justify-center text-white font-bold text-sm flex-shrink-0">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">开启系统设置</p>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
开启系统设置
|
||||
</p>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
在「系统设置」中开启 <code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">ENABLE_API_FORMAT_CONVERSION</code>
|
||||
</p>
|
||||
@@ -138,7 +152,9 @@ const systemSettings = [
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">配置端点</p>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
配置端点
|
||||
</p>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
在端点配置中,启用「格式接受配置」并选择接受的入站格式
|
||||
</p>
|
||||
@@ -150,7 +166,9 @@ const systemSettings = [
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">使用</p>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
使用
|
||||
</p>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
用户现在可以用 OpenAI SDK 调用配置在 Claude 格式端点上的模型
|
||||
</p>
|
||||
@@ -158,11 +176,16 @@ const systemSettings = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-4']">
|
||||
<div
|
||||
class="p-4"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Info class="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">注意事项</p>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
注意事项
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
格式转换会增加少量延迟(通常 <10ms)。部分特有功能(如 Claude 的 thinking、OpenAI 的 function calling)
|
||||
可能无法完美转换,建议在实际场景中测试。
|
||||
@@ -178,25 +201,37 @@ const systemSettings = [
|
||||
请求头规则
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-5']">
|
||||
<div
|
||||
class="p-5"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 rounded-lg bg-green-500/10">
|
||||
<FileCode class="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">什么是请求头规则?</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
什么是请求头规则?
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mb-4">
|
||||
请求头规则允许你在转发请求时修改 HTTP 请求头。可以添加、修改或删除特定的请求头。
|
||||
</p>
|
||||
|
||||
<div :class="[panelClasses.section, 'overflow-hidden']">
|
||||
<div
|
||||
class="overflow-hidden"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">类型</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">说明</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
类型
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
说明
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -218,11 +253,16 @@ const systemSettings = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-4']">
|
||||
<div
|
||||
class="p-4"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Info class="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">使用场景</p>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
使用场景
|
||||
</p>
|
||||
<ul class="mt-1 space-y-1">
|
||||
<li>• 添加额外的认证信息(如 API 版本号)</li>
|
||||
<li>• 添加跟踪标记(如请求 ID、来源标识)</li>
|
||||
@@ -239,12 +279,17 @@ const systemSettings = [
|
||||
代理设置
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-5']">
|
||||
<div
|
||||
class="p-5"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 rounded-lg bg-blue-500/10">
|
||||
<Globe class="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">HTTP 代理</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
HTTP 代理
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mb-4">
|
||||
@@ -253,14 +298,18 @@ const systemSettings = [
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="p-4 rounded-lg bg-[#f5f5f0]/50 dark:bg-[#1f1d1a]/50">
|
||||
<h4 class="font-medium text-[#262624] dark:text-[#f1ead8]">端点级代理</h4>
|
||||
<h4 class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
端点级代理
|
||||
</h4>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
在端点配置中填写代理地址,格式如 <code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">http://proxy:8080</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 rounded-lg bg-[#f5f5f0]/50 dark:bg-[#1f1d1a]/50">
|
||||
<h4 class="font-medium text-[#262624] dark:text-[#f1ead8]">全局代理</h4>
|
||||
<h4 class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
全局代理
|
||||
</h4>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
也可以通过环境变量 <code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">HTTP_PROXY</code> 设置全局代理
|
||||
</p>
|
||||
@@ -283,11 +332,14 @@ const systemSettings = [
|
||||
<div
|
||||
v-for="category in systemSettings"
|
||||
:key="category.category"
|
||||
:class="[panelClasses.section, 'p-4']"
|
||||
class="p-4"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<Settings class="h-4 w-4 text-[#cc785c]" />
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">{{ category.category }}</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
{{ category.category }}
|
||||
</h3>
|
||||
</div>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
@@ -309,12 +361,17 @@ const systemSettings = [
|
||||
健康监控
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-5']">
|
||||
<div
|
||||
class="p-5"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 rounded-lg bg-green-500/10">
|
||||
<Shield class="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">端点健康检查</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
端点健康检查
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-3 text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
@@ -337,11 +394,16 @@ const systemSettings = [
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-4']">
|
||||
<div
|
||||
class="p-4"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<AlertTriangle class="h-5 w-5 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">端点显示不健康?</p>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
端点显示不健康?
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
检查:1) API URL 是否正确;2) API Key 是否有效;3) 网络是否可达;4) 是否需要配置代理。
|
||||
</p>
|
||||
@@ -354,11 +416,16 @@ const systemSettings = [
|
||||
<section class="pt-4">
|
||||
<RouterLink
|
||||
to="/guide/faq"
|
||||
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
|
||||
class="p-4 flex items-center gap-3 group"
|
||||
:class="[panelClasses.section, panelClasses.cardHover]"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">常见问题</div>
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">查看使用中的常见问题和解答</div>
|
||||
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
常见问题
|
||||
</div>
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
查看使用中的常见问题和解答
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight class="h-5 w-5 text-[#999] group-hover:text-[#cc785c] transition-colors" />
|
||||
</RouterLink>
|
||||
|
||||
@@ -81,7 +81,10 @@ function toggleAll() {
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div :class="[panelClasses.section, 'p-4']">
|
||||
<div
|
||||
class="p-4"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Search class="h-5 w-5 text-[#999]" />
|
||||
<input
|
||||
@@ -89,7 +92,7 @@ function toggleAll() {
|
||||
type="text"
|
||||
placeholder="搜索问题..."
|
||||
class="flex-1 bg-transparent border-none outline-none text-[#262624] dark:text-[#f1ead8] placeholder:text-[#999]"
|
||||
/>
|
||||
>
|
||||
<button
|
||||
v-if="filteredFaqs.length > 0"
|
||||
class="text-sm text-[#cc785c] hover:underline"
|
||||
@@ -101,7 +104,10 @@ function toggleAll() {
|
||||
</div>
|
||||
|
||||
<!-- FAQ 列表 -->
|
||||
<div v-if="filteredFaqs.length > 0" class="space-y-6">
|
||||
<div
|
||||
v-if="filteredFaqs.length > 0"
|
||||
class="space-y-6"
|
||||
>
|
||||
<div
|
||||
v-for="category in Object.keys(faqsByCategory)"
|
||||
:key="category"
|
||||
@@ -116,7 +122,8 @@ function toggleAll() {
|
||||
<div
|
||||
v-for="faq in faqsByCategory[category]"
|
||||
:key="faq.id"
|
||||
:class="[panelClasses.section, 'overflow-hidden']"
|
||||
class="overflow-hidden"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<button
|
||||
class="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-[#f5f5f0]/50 dark:hover:bg-[#1f1d1a]/50 transition-colors"
|
||||
@@ -144,7 +151,8 @@ function toggleAll() {
|
||||
<!-- 无结果 -->
|
||||
<div
|
||||
v-else
|
||||
:class="[panelClasses.section, 'p-8 text-center']"
|
||||
class="p-8 text-center"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<HelpCircle class="h-12 w-12 text-[#999] mx-auto mb-4" />
|
||||
<p class="text-[#666663] dark:text-[#a3a094]">
|
||||
@@ -163,10 +171,15 @@ function toggleAll() {
|
||||
href="https://github.com/your-repo/aether"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
|
||||
class="p-4 flex items-center gap-3 group"
|
||||
:class="[panelClasses.section, panelClasses.cardHover]"
|
||||
>
|
||||
<div class="p-2 rounded-lg bg-gray-500/10">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -181,11 +194,18 @@ function toggleAll() {
|
||||
href="https://github.com/your-repo/aether/discussions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
|
||||
class="p-4 flex items-center gap-3 group"
|
||||
:class="[panelClasses.section, panelClasses.cardHover]"
|
||||
>
|
||||
<div class="p-2 rounded-lg bg-blue-500/10">
|
||||
<svg class="h-5 w-5 text-blue-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||
<svg
|
||||
class="h-5 w-5 text-blue-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
type="text"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg border border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-white dark:bg-[#1f1d1a] text-[#191919] dark:text-white placeholder-[#91918d] focus:outline-none focus:ring-2 focus:ring-[#cc785c]/30"
|
||||
placeholder="https://your-aether.com"
|
||||
/>
|
||||
>
|
||||
<p class="mt-1.5 text-xs text-[#91918d]">
|
||||
代码示例将使用此 URL
|
||||
</p>
|
||||
@@ -176,7 +176,10 @@
|
||||
<main class="flex-1 min-w-0">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 md:py-12">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" :base-url="baseUrl" />
|
||||
<component
|
||||
:is="Component"
|
||||
:base-url="baseUrl"
|
||||
/>
|
||||
</RouterView>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -50,12 +50,17 @@ const lbIcons = {
|
||||
什么是模型?
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-5']">
|
||||
<div
|
||||
class="p-5"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 rounded-lg bg-purple-500/10">
|
||||
<Layers class="h-5 w-5 text-purple-500" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">模型 (Model)</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
模型 (Model)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-3 text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
@@ -78,11 +83,16 @@ const lbIcons = {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-4']">
|
||||
<div
|
||||
class="p-4"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Info class="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">示例场景</p>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
示例场景
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
用户请求模型 <code class="text-xs bg-[#f5f5f0] dark:bg-[#1f1d1a] px-1.5 py-0.5 rounded">gpt-4</code>,
|
||||
你可以配置它同时使用 OpenAI 官方端点和某个代理端点。
|
||||
@@ -99,13 +109,18 @@ const lbIcons = {
|
||||
创建模型
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-5 space-y-4']">
|
||||
<div
|
||||
class="p-5 space-y-4"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-8 h-8 rounded-full bg-[#cc785c] flex items-center justify-center text-white font-bold text-sm flex-shrink-0">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">进入模型管理页面</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
进入模型管理页面
|
||||
</h3>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
在管理后台左侧菜单点击「模型管理」
|
||||
</p>
|
||||
@@ -117,7 +132,9 @@ const lbIcons = {
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">添加模型</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
添加模型
|
||||
</h3>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
点击「添加模型」,填写模型名称(这是用户调用时使用的名称)
|
||||
</p>
|
||||
@@ -129,7 +146,9 @@ const lbIcons = {
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">关联端点</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
关联端点
|
||||
</h3>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
选择该模型要使用的端点,可以选择多个。如果端点的模型名与你定义的不同,需要设置目标模型名映射。
|
||||
</p>
|
||||
@@ -141,7 +160,9 @@ const lbIcons = {
|
||||
4
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">配置负载均衡</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
配置负载均衡
|
||||
</h3>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
如果选择了多个端点,可以配置负载均衡策略
|
||||
</p>
|
||||
@@ -156,14 +177,23 @@ const lbIcons = {
|
||||
配置字段说明
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'overflow-hidden']">
|
||||
<div
|
||||
class="overflow-hidden"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">字段</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">说明</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">必填</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
字段
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
说明
|
||||
</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
必填
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -179,8 +209,14 @@ const lbIcons = {
|
||||
{{ field.description }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span v-if="field.required" :class="panelClasses.badgeGreen">必填</span>
|
||||
<span v-else class="text-[#999]">可选</span>
|
||||
<span
|
||||
v-if="field.required"
|
||||
:class="panelClasses.badgeGreen"
|
||||
>必填</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-[#999]"
|
||||
>可选</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -202,26 +238,36 @@ const lbIcons = {
|
||||
<div
|
||||
v-for="lb in loadBalanceModes"
|
||||
:key="lb.mode"
|
||||
:class="[panelClasses.section, 'p-4']"
|
||||
class="p-4"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<component
|
||||
:is="lbIcons[lb.mode as keyof typeof lbIcons] || Shuffle"
|
||||
class="h-5 w-5 text-[#cc785c]"
|
||||
/>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">{{ lb.name }}</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
{{ lb.name }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094]">{{ lb.description }}</p>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
{{ lb.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-4']">
|
||||
<div
|
||||
class="p-4"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Info class="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">推荐配置</p>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
推荐配置
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
<strong>主备切换场景</strong>:使用「优先级」模式,将官方端点设为高优先级,代理端点设为低优先级。<br />
|
||||
<strong>主备切换场景</strong>:使用「优先级」模式,将官方端点设为高优先级,代理端点设为低优先级。<br>
|
||||
<strong>分摊负载场景</strong>:使用「轮询」或「加权」模式,将多个同质端点平均分配请求。
|
||||
</p>
|
||||
</div>
|
||||
@@ -235,7 +281,10 @@ const lbIcons = {
|
||||
模型名映射
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-5']">
|
||||
<div
|
||||
class="p-5"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mb-4">
|
||||
有时候你希望用户调用的模型名与实际发给端点的模型名不同。例如:
|
||||
</p>
|
||||
@@ -278,7 +327,10 @@ const lbIcons = {
|
||||
模型别名
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-5']">
|
||||
<div
|
||||
class="p-5"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mb-4">
|
||||
别名让用户可以用多个名称访问同一个模型。适用于:
|
||||
</p>
|
||||
@@ -304,11 +356,16 @@ const lbIcons = {
|
||||
<section class="pt-4">
|
||||
<RouterLink
|
||||
to="/guide/user-key"
|
||||
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
|
||||
class="p-4 flex items-center gap-3 group"
|
||||
:class="[panelClasses.section, panelClasses.cardHover]"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">下一步:用户与密钥</div>
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">管理用户和 API Key</div>
|
||||
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
下一步:用户与密钥
|
||||
</div>
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
管理用户和 API Key
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight class="h-5 w-5 text-[#999] group-hover:text-[#cc785c] transition-colors" />
|
||||
</RouterLink>
|
||||
|
||||
@@ -61,16 +61,16 @@ const conceptIconColors = {
|
||||
<div
|
||||
v-for="concept in coreConcepts"
|
||||
:key="concept.name"
|
||||
class="p-4 rounded-xl border-2 transition-all"
|
||||
:class="[
|
||||
'p-4 rounded-xl border-2 transition-all',
|
||||
conceptColors[concept.color as keyof typeof conceptColors]
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<component
|
||||
:is="conceptIcons[concept.color as keyof typeof conceptIcons]"
|
||||
class="h-6 w-6 flex-shrink-0 mt-0.5"
|
||||
:class="[
|
||||
'h-6 w-6 flex-shrink-0 mt-0.5',
|
||||
conceptIconColors[concept.color as keyof typeof conceptIconColors]
|
||||
]"
|
||||
/>
|
||||
@@ -87,7 +87,10 @@ const conceptIconColors = {
|
||||
</div>
|
||||
|
||||
<!-- 关系图 -->
|
||||
<div :class="[panelClasses.section, 'p-6 mt-6']">
|
||||
<div
|
||||
class="p-6 mt-6"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-[#666663] dark:text-[#a3a094] mb-4">
|
||||
它们之间的关系
|
||||
</h3>
|
||||
@@ -134,7 +137,8 @@ const conceptIconColors = {
|
||||
<div
|
||||
v-for="step in configSteps"
|
||||
:key="step.step"
|
||||
:class="[panelClasses.section, 'p-4 relative']"
|
||||
class="p-4 relative"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="absolute -top-3 -left-2 w-8 h-8 rounded-full bg-[#cc785c] flex items-center justify-center text-white font-bold text-sm">
|
||||
{{ step.step }}
|
||||
@@ -160,15 +164,26 @@ const conceptIconColors = {
|
||||
Aether 支持多种 API 格式,可以作为不同客户端的统一入口:
|
||||
</p>
|
||||
|
||||
<div :class="[panelClasses.section, 'overflow-hidden']">
|
||||
<div
|
||||
class="overflow-hidden"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">格式</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">端点</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">认证方式</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">常用客户端</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
格式
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
端点
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
认证方式
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
常用客户端
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -204,36 +219,51 @@ const conceptIconColors = {
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<RouterLink
|
||||
to="/guide/provider"
|
||||
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
|
||||
class="p-4 flex items-center gap-3 group"
|
||||
:class="[panelClasses.section, panelClasses.cardHover]"
|
||||
>
|
||||
<Server class="h-5 w-5 text-[#cc785c]" />
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">供应商管理</div>
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">添加和配置 API 供应商</div>
|
||||
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
供应商管理
|
||||
</div>
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
添加和配置 API 供应商
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight class="h-5 w-5 text-[#999] group-hover:text-[#cc785c] transition-colors" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
to="/guide/model"
|
||||
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
|
||||
class="p-4 flex items-center gap-3 group"
|
||||
:class="[panelClasses.section, panelClasses.cardHover]"
|
||||
>
|
||||
<Layers class="h-5 w-5 text-[#cc785c]" />
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">模型管理</div>
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">配置模型和负载均衡</div>
|
||||
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
模型管理
|
||||
</div>
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
配置模型和负载均衡
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight class="h-5 w-5 text-[#999] group-hover:text-[#cc785c] transition-colors" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
to="/guide/user-key"
|
||||
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
|
||||
class="p-4 flex items-center gap-3 group"
|
||||
:class="[panelClasses.section, panelClasses.cardHover]"
|
||||
>
|
||||
<Key class="h-5 w-5 text-[#cc785c]" />
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">用户与密钥</div>
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">管理用户和 API Key</div>
|
||||
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
用户与密钥
|
||||
</div>
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
管理用户和 API Key
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight class="h-5 w-5 text-[#999] group-hover:text-[#cc785c] transition-colors" />
|
||||
</RouterLink>
|
||||
|
||||
@@ -84,12 +84,17 @@ const providerExamples = [
|
||||
</h2>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div :class="[panelClasses.section, 'p-5']">
|
||||
<div
|
||||
class="p-5"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="p-2 rounded-lg bg-blue-500/10">
|
||||
<Server class="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">供应商 (Provider)</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
供应商 (Provider)
|
||||
</h3>
|
||||
</div>
|
||||
<ul class="space-y-2 text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
<li class="flex items-start gap-2">
|
||||
@@ -107,12 +112,17 @@ const providerExamples = [
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-5']">
|
||||
<div
|
||||
class="p-5"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="p-2 rounded-lg bg-green-500/10">
|
||||
<Settings class="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">端点 (Endpoint)</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
端点 (Endpoint)
|
||||
</h3>
|
||||
</div>
|
||||
<ul class="space-y-2 text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
<li class="flex items-start gap-2">
|
||||
@@ -138,13 +148,18 @@ const providerExamples = [
|
||||
添加供应商
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-5 space-y-4']">
|
||||
<div
|
||||
class="p-5 space-y-4"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-8 h-8 rounded-full bg-[#cc785c] flex items-center justify-center text-white font-bold text-sm flex-shrink-0">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">进入供应商管理页面</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
进入供应商管理页面
|
||||
</h3>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
登录管理后台,在左侧菜单点击「供应商管理」
|
||||
</p>
|
||||
@@ -156,7 +171,9 @@ const providerExamples = [
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">创建供应商</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
创建供应商
|
||||
</h3>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
点击「添加供应商」按钮,填写供应商名称(如 OpenAI、Anthropic)
|
||||
</p>
|
||||
@@ -168,7 +185,9 @@ const providerExamples = [
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">添加端点</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
添加端点
|
||||
</h3>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
在供应商下点击「添加端点」,填写 API URL、密钥等配置
|
||||
</p>
|
||||
@@ -183,14 +202,23 @@ const providerExamples = [
|
||||
端点配置字段
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'overflow-hidden']">
|
||||
<div
|
||||
class="overflow-hidden"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">字段</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">说明</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">必填</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
字段
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
说明
|
||||
</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
必填
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -206,8 +234,14 @@ const providerExamples = [
|
||||
{{ field.description }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span v-if="field.required" :class="panelClasses.badgeGreen">必填</span>
|
||||
<span v-else class="text-[#999]">可选</span>
|
||||
<span
|
||||
v-if="field.required"
|
||||
:class="panelClasses.badgeGreen"
|
||||
>必填</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-[#999]"
|
||||
>可选</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -222,11 +256,16 @@ const providerExamples = [
|
||||
API 格式选择
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-4']">
|
||||
<div
|
||||
class="p-4"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Info class="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">如何选择正确的 API 格式?</p>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
如何选择正确的 API 格式?
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
根据目标 API 服务的实际格式选择。大多数第三方 API 代理(如 OpenRouter)都兼容 OpenAI 格式。
|
||||
如果不确定,可以先尝试 OpenAI 格式。
|
||||
@@ -235,14 +274,23 @@ const providerExamples = [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="[panelClasses.section, 'overflow-hidden']">
|
||||
<div
|
||||
class="overflow-hidden"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">格式</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">端点路径</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">认证方式</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
格式
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
端点路径
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
认证方式
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -277,7 +325,8 @@ const providerExamples = [
|
||||
<div
|
||||
v-for="example in providerExamples"
|
||||
:key="example.name"
|
||||
:class="[panelClasses.section, 'p-4']"
|
||||
class="p-4"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
{{ example.name }}
|
||||
@@ -292,7 +341,9 @@ const providerExamples = [
|
||||
<span :class="panelClasses.badgeBlue">{{ example.format }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-[#999]">{{ example.note }}</p>
|
||||
<p class="mt-2 text-xs text-[#999]">
|
||||
{{ example.note }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -303,11 +354,16 @@ const providerExamples = [
|
||||
注意事项
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-4 space-y-3']">
|
||||
<div
|
||||
class="p-4 space-y-3"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<AlertTriangle class="h-5 w-5 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">API Key 安全</p>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
API Key 安全
|
||||
</p>
|
||||
<p class="text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
端点的 API Key 会被加密存储,但仍建议使用子账号或限定范围的 Key,而非主账号 Key。
|
||||
</p>
|
||||
@@ -317,7 +373,9 @@ const providerExamples = [
|
||||
<div class="flex items-start gap-3">
|
||||
<AlertTriangle class="h-5 w-5 text-yellow-500 flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">测试端点</p>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
测试端点
|
||||
</p>
|
||||
<p class="text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
添加端点后,可以在健康监控页面测试连通性。如果显示不健康,请检查 URL 和 Key 是否正确。
|
||||
</p>
|
||||
@@ -330,11 +388,16 @@ const providerExamples = [
|
||||
<section class="pt-4">
|
||||
<RouterLink
|
||||
to="/guide/model"
|
||||
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
|
||||
class="p-4 flex items-center gap-3 group"
|
||||
:class="[panelClasses.section, panelClasses.cardHover]"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">下一步:模型管理</div>
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">配置模型映射和负载均衡</div>
|
||||
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
下一步:模型管理
|
||||
</div>
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
配置模型映射和负载均衡
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight class="h-5 w-5 text-[#999] group-hover:text-[#cc785c] transition-colors" />
|
||||
</RouterLink>
|
||||
|
||||
@@ -65,12 +65,17 @@ const roleComparison = [
|
||||
</h2>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div :class="[panelClasses.section, 'p-5']">
|
||||
<div
|
||||
class="p-5"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="p-2 rounded-lg bg-blue-500/10">
|
||||
<Users class="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">普通用户</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
普通用户
|
||||
</h3>
|
||||
</div>
|
||||
<ul class="space-y-2 text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
<li class="flex items-start gap-2">
|
||||
@@ -88,12 +93,17 @@ const roleComparison = [
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-5']">
|
||||
<div
|
||||
class="p-5"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="p-2 rounded-lg bg-orange-500/10">
|
||||
<Shield class="h-5 w-5 text-orange-500" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">管理员</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
管理员
|
||||
</h3>
|
||||
</div>
|
||||
<ul class="space-y-2 text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
<li class="flex items-start gap-2">
|
||||
@@ -113,14 +123,23 @@ const roleComparison = [
|
||||
</div>
|
||||
|
||||
<!-- 权限对比表 -->
|
||||
<div :class="[panelClasses.section, 'overflow-hidden']">
|
||||
<div
|
||||
class="overflow-hidden"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">功能</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">普通用户</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">管理员</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
功能
|
||||
</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
普通用户
|
||||
</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
管理员
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -133,12 +152,24 @@ const roleComparison = [
|
||||
{{ item.feature }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<Check v-if="item.user" class="h-5 w-5 text-green-500 mx-auto" />
|
||||
<span v-else class="text-[#999]">—</span>
|
||||
<Check
|
||||
v-if="item.user"
|
||||
class="h-5 w-5 text-green-500 mx-auto"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="text-[#999]"
|
||||
>—</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<Check v-if="item.admin" class="h-5 w-5 text-green-500 mx-auto" />
|
||||
<span v-else class="text-[#999]">—</span>
|
||||
<Check
|
||||
v-if="item.admin"
|
||||
class="h-5 w-5 text-green-500 mx-auto"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="text-[#999]"
|
||||
>—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -153,12 +184,17 @@ const roleComparison = [
|
||||
API Key 管理
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-5']">
|
||||
<div
|
||||
class="p-5"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 rounded-lg bg-orange-500/10">
|
||||
<Key class="h-5 w-5 text-orange-500" />
|
||||
</div>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">什么是 API Key?</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
什么是 API Key?
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-3 text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
@@ -188,14 +224,23 @@ const roleComparison = [
|
||||
Key 配置选项
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'overflow-hidden']">
|
||||
<div
|
||||
class="overflow-hidden"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-[#e5e4df] dark:border-[rgba(227,224,211,0.12)] bg-[#fafaf7]/50 dark:bg-[#1f1d1a]/50">
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">选项</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">说明</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">必填</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
选项
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
说明
|
||||
</th>
|
||||
<th class="px-4 py-3 text-center font-medium text-[#666663] dark:text-[#a3a094]">
|
||||
必填
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -211,8 +256,14 @@ const roleComparison = [
|
||||
{{ field.description }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span v-if="field.required" :class="panelClasses.badgeGreen">必填</span>
|
||||
<span v-else class="text-[#999]">可选</span>
|
||||
<span
|
||||
v-if="field.required"
|
||||
:class="panelClasses.badgeGreen"
|
||||
>必填</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-[#999]"
|
||||
>可选</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -227,22 +278,31 @@ const roleComparison = [
|
||||
配额设置
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-5 space-y-4']">
|
||||
<div
|
||||
class="p-5 space-y-4"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Clock class="h-5 w-5 text-[#cc785c]" />
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">配额类型</h3>
|
||||
<h3 class="font-semibold text-[#262624] dark:text-[#f1ead8]">
|
||||
配额类型
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="p-4 rounded-lg bg-[#f5f5f0]/50 dark:bg-[#1f1d1a]/50">
|
||||
<h4 class="font-medium text-[#262624] dark:text-[#f1ead8]">请求次数配额</h4>
|
||||
<h4 class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
请求次数配额
|
||||
</h4>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
限制每日/每月的 API 调用次数,超过后请求会被拒绝
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 rounded-lg bg-[#f5f5f0]/50 dark:bg-[#1f1d1a]/50">
|
||||
<h4 class="font-medium text-[#262624] dark:text-[#f1ead8]">Token 用量配额</h4>
|
||||
<h4 class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
Token 用量配额
|
||||
</h4>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
限制每日/每月的 Token 消耗量,适合控制成本
|
||||
</p>
|
||||
@@ -252,7 +312,9 @@ const roleComparison = [
|
||||
<div class="flex items-start gap-3">
|
||||
<Info class="h-5 w-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">配额继承</p>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
配额继承
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
可以在用户级别设置默认配额,新创建的 Key 会自动继承该配额。
|
||||
也可以在创建 Key 时覆盖默认配额。
|
||||
@@ -268,7 +330,10 @@ const roleComparison = [
|
||||
模型访问控制
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-5']">
|
||||
<div
|
||||
class="p-5"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<p class="text-sm text-[#666663] dark:text-[#a3a094] mb-4">
|
||||
通过「允许的模型」字段,可以限制 Key 只能访问特定模型:
|
||||
</p>
|
||||
@@ -288,7 +353,9 @@ const roleComparison = [
|
||||
<div class="flex items-start gap-2">
|
||||
<AlertTriangle class="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-yellow-700 dark:text-yellow-300">使用场景</p>
|
||||
<p class="font-medium text-yellow-700 dark:text-yellow-300">
|
||||
使用场景
|
||||
</p>
|
||||
<p class="text-yellow-600 dark:text-yellow-400 mt-1">
|
||||
比如限制免费用户只能使用 gpt-3.5,付费用户可以使用 gpt-4。
|
||||
或者为不同项目创建只能访问特定模型的 Key。
|
||||
@@ -305,11 +372,16 @@ const roleComparison = [
|
||||
安全建议
|
||||
</h2>
|
||||
|
||||
<div :class="[panelClasses.section, 'p-4 space-y-3']">
|
||||
<div
|
||||
class="p-4 space-y-3"
|
||||
:class="[panelClasses.section]"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Shield class="h-5 w-5 text-[#cc785c] flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">设置 Key 有效期</p>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
设置 Key 有效期
|
||||
</p>
|
||||
<p class="text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
为临时使用的 Key 设置过期时间,避免遗忘造成安全风险。
|
||||
</p>
|
||||
@@ -319,7 +391,9 @@ const roleComparison = [
|
||||
<div class="flex items-start gap-3">
|
||||
<Shield class="h-5 w-5 text-[#cc785c] flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">按场景分配 Key</p>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
按场景分配 Key
|
||||
</p>
|
||||
<p class="text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
为开发、测试、生产环境分别创建 Key,便于追踪和管理。
|
||||
</p>
|
||||
@@ -329,7 +403,9 @@ const roleComparison = [
|
||||
<div class="flex items-start gap-3">
|
||||
<Shield class="h-5 w-5 text-[#cc785c] flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">启用 IP 白名单</p>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
启用 IP 白名单
|
||||
</p>
|
||||
<p class="text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
对于生产环境的 Key,配置 IP 白名单可以防止 Key 泄露后被滥用。
|
||||
</p>
|
||||
@@ -339,7 +415,9 @@ const roleComparison = [
|
||||
<div class="flex items-start gap-3">
|
||||
<Shield class="h-5 w-5 text-[#cc785c] flex-shrink-0 mt-0.5" />
|
||||
<div class="text-sm">
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">定期审计</p>
|
||||
<p class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
定期审计
|
||||
</p>
|
||||
<p class="text-[#666663] dark:text-[#a3a094] mt-1">
|
||||
定期检查用量统计和审计日志,发现异常及时禁用相关 Key。
|
||||
</p>
|
||||
@@ -352,11 +430,16 @@ const roleComparison = [
|
||||
<section class="pt-4">
|
||||
<RouterLink
|
||||
to="/guide/advanced"
|
||||
:class="[panelClasses.section, panelClasses.cardHover, 'p-4 flex items-center gap-3 group']"
|
||||
class="p-4 flex items-center gap-3 group"
|
||||
:class="[panelClasses.section, panelClasses.cardHover]"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">下一步:高级功能</div>
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">格式转换、请求头规则等高级配置</div>
|
||||
<div class="font-medium text-[#262624] dark:text-[#f1ead8]">
|
||||
下一步:高级功能
|
||||
</div>
|
||||
<div class="text-sm text-[#666663] dark:text-[#a3a094]">
|
||||
格式转换、请求头规则等高级配置
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight class="h-5 w-5 text-[#999] group-hover:text-[#cc785c] transition-colors" />
|
||||
</RouterLink>
|
||||
|
||||
@@ -398,7 +398,10 @@
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
统计周期
|
||||
</h3>
|
||||
<TimeRangePicker v-model="dailyTimeRange" :allow-hourly="true" />
|
||||
<TimeRangePicker
|
||||
v-model="dailyTimeRange"
|
||||
:allow-hourly="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 趋势图表区域 -->
|
||||
|
||||
@@ -242,9 +242,7 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
|
||||
if "auth_type" in update_data:
|
||||
if target_auth_type == "api_key":
|
||||
if current_auth_type in {"vertex_ai", "oauth"} and not update_data.get("api_key"):
|
||||
raise InvalidRequestException(
|
||||
"切换到 API Key 认证模式时,必须提供新的 API Key"
|
||||
)
|
||||
raise InvalidRequestException("切换到 API Key 认证模式时,必须提供新的 API Key")
|
||||
# 切换回 API Key:清理非本模式配置
|
||||
update_data["auth_config"] = None
|
||||
elif target_auth_type == "vertex_ai":
|
||||
@@ -629,16 +627,29 @@ def _build_key_response(
|
||||
key_dict.pop("_sa_instance_state", None)
|
||||
key_dict.pop("api_key", None) # 移除敏感字段,避免泄露
|
||||
|
||||
# 提取 OAuth expires_at(如果是 OAuth 类型)
|
||||
# 提取 OAuth 元数据(如果是 OAuth 类型)
|
||||
oauth_expires_at = None
|
||||
oauth_email = None
|
||||
oauth_plan_type = None
|
||||
oauth_account_id = None
|
||||
encrypted_auth_config = key_dict.pop("auth_config", None) # 移除敏感字段,避免泄露
|
||||
if auth_type == "oauth" and encrypted_auth_config:
|
||||
try:
|
||||
decrypted_config = crypto_service.decrypt(encrypted_auth_config)
|
||||
auth_config = json.loads(decrypted_config)
|
||||
oauth_expires_at = auth_config.get("expires_at")
|
||||
except Exception:
|
||||
pass
|
||||
oauth_email = auth_config.get("email")
|
||||
oauth_plan_type = auth_config.get("plan_type") # Codex: plus/free/team/enterprise
|
||||
oauth_account_id = auth_config.get("account_id") # Codex: chatgpt_account_id
|
||||
logger.debug(
|
||||
"OAuth key {} auth_config: email={} plan_type={} account_id={}",
|
||||
key.id,
|
||||
oauth_email,
|
||||
oauth_plan_type,
|
||||
oauth_account_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to decrypt auth_config for key {}: {}", key.id, e)
|
||||
|
||||
# 从 health_by_format 计算汇总字段(便于列表展示)
|
||||
health_by_format = key.health_by_format or {}
|
||||
@@ -685,6 +696,13 @@ def _build_key_response(
|
||||
"circuit_breaker_open": any_circuit_open,
|
||||
# OAuth 相关
|
||||
"oauth_expires_at": oauth_expires_at,
|
||||
"oauth_email": oauth_email,
|
||||
"oauth_plan_type": oauth_plan_type,
|
||||
"oauth_account_id": oauth_account_id,
|
||||
"oauth_invalid_at": (
|
||||
int(key.oauth_invalid_at.timestamp()) if key.oauth_invalid_at else None
|
||||
),
|
||||
"oauth_invalid_reason": key.oauth_invalid_reason,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -12,14 +12,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from urllib.parse import parse_qsl, urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
@@ -32,12 +31,11 @@ from src.clients.redis_client import get_redis_client
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||
from src.core.logger import logger
|
||||
from src.database.database import get_db
|
||||
from src.models.database import Provider, ProviderAPIKey
|
||||
|
||||
from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
|
||||
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
|
||||
from src.core.provider_templates.types import ProviderType
|
||||
from src.database.database import get_db
|
||||
from src.models.database import Provider, ProviderAPIKey
|
||||
|
||||
router = APIRouter(prefix="/api/admin/provider-oauth", tags=["Provider OAuth"])
|
||||
|
||||
@@ -66,7 +64,8 @@ def _state_key(nonce: str) -> str:
|
||||
@dataclass(frozen=True)
|
||||
class ProviderOAuthStateData:
|
||||
nonce: str
|
||||
key_id: str
|
||||
key_id: str # 可能为空(新流程)
|
||||
provider_id: str # 新增
|
||||
provider_type: str
|
||||
pkce_verifier: str | None
|
||||
created_at: int
|
||||
@@ -76,6 +75,7 @@ async def _create_state(
|
||||
redis: Redis,
|
||||
*,
|
||||
key_id: str,
|
||||
provider_id: str,
|
||||
provider_type: str,
|
||||
pkce_verifier: str | None,
|
||||
) -> str:
|
||||
@@ -83,6 +83,7 @@ async def _create_state(
|
||||
data = {
|
||||
"nonce": nonce,
|
||||
"key_id": key_id,
|
||||
"provider_id": provider_id,
|
||||
"provider_type": provider_type,
|
||||
"pkce_verifier": pkce_verifier,
|
||||
"created_at": int(time.time()),
|
||||
@@ -105,6 +106,7 @@ async def _consume_state(redis: Redis, nonce: str) -> ProviderOAuthStateData | N
|
||||
return ProviderOAuthStateData(
|
||||
nonce=str(parsed.get("nonce") or ""),
|
||||
key_id=str(parsed.get("key_id") or ""),
|
||||
provider_id=str(parsed.get("provider_id") or ""),
|
||||
provider_type=str(parsed.get("provider_type") or ""),
|
||||
pkce_verifier=parsed.get("pkce_verifier"),
|
||||
created_at=int(parsed.get("created_at") or 0),
|
||||
@@ -131,6 +133,20 @@ class CompleteOAuthResponse(BaseModel):
|
||||
provider_type: str
|
||||
expires_at: int | None = None
|
||||
has_refresh_token: bool = False
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class ProviderCompleteOAuthRequest(BaseModel):
|
||||
callback_url: str = Field(..., min_length=5, description="浏览器地址栏中的完整回调 URL")
|
||||
name: str | None = Field(None, max_length=100, description="账号名称(可选)")
|
||||
|
||||
|
||||
class ProviderCompleteOAuthResponse(BaseModel):
|
||||
key_id: str
|
||||
provider_type: str
|
||||
expires_at: int | None = None
|
||||
has_refresh_token: bool = False
|
||||
email: str | None = None
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -179,7 +195,11 @@ async def supported_types() -> list[dict[str, Any]]:
|
||||
for provider_type, template in FIXED_PROVIDERS.items():
|
||||
result.append(
|
||||
{
|
||||
"provider_type": str(provider_type.value) if hasattr(provider_type, "value") else str(provider_type),
|
||||
"provider_type": (
|
||||
str(provider_type.value)
|
||||
if hasattr(provider_type, "value")
|
||||
else str(provider_type)
|
||||
),
|
||||
"display_name": template.display_name,
|
||||
"scopes": list(template.oauth.scopes),
|
||||
"redirect_uri": template.oauth.redirect_uri,
|
||||
@@ -228,6 +248,7 @@ async def start_oauth(
|
||||
state = await _create_state(
|
||||
redis,
|
||||
key_id=key_id,
|
||||
provider_id=str(provider.id),
|
||||
provider_type=provider_type,
|
||||
pkce_verifier=pkce_verifier,
|
||||
)
|
||||
@@ -336,7 +357,10 @@ async def complete_oauth(
|
||||
form["client_secret"] = template.oauth.client_secret
|
||||
if state_data.pkce_verifier:
|
||||
form["code_verifier"] = state_data.pkce_verifier
|
||||
headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
data = form
|
||||
json_body = None
|
||||
|
||||
@@ -391,10 +415,19 @@ async def complete_oauth(
|
||||
key.auth_config = crypto_service.encrypt(json.dumps(auth_config))
|
||||
db.commit()
|
||||
|
||||
# 触发 OAuth 刷新任务重新调度
|
||||
try:
|
||||
from src.services.system import get_maintenance_scheduler
|
||||
|
||||
get_maintenance_scheduler().trigger_oauth_refresh_check()
|
||||
except Exception as e:
|
||||
logger.debug("trigger_oauth_refresh_check 调用失败: {}", e)
|
||||
|
||||
return CompleteOAuthResponse(
|
||||
provider_type=provider_type,
|
||||
expires_at=expires_at,
|
||||
has_refresh_token=bool(refresh_token),
|
||||
email=auth_config.get("email"),
|
||||
)
|
||||
|
||||
|
||||
@@ -452,7 +485,10 @@ async def refresh_oauth(
|
||||
}
|
||||
if template.oauth.client_secret:
|
||||
form["client_secret"] = template.oauth.client_secret
|
||||
headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
data = form
|
||||
json_body = None
|
||||
|
||||
@@ -469,7 +505,25 @@ async def refresh_oauth(
|
||||
)
|
||||
|
||||
if resp.status_code < 200 or resp.status_code >= 300:
|
||||
raise InvalidRequestException("token refresh 失败")
|
||||
# 解析错误原因
|
||||
error_reason = f"HTTP {resp.status_code}"
|
||||
try:
|
||||
error_body = resp.json()
|
||||
if "error" in error_body:
|
||||
error_reason = str(error_body.get("error_description") or error_body.get("error"))
|
||||
except Exception:
|
||||
error_reason = resp.text[:100] if resp.text else f"HTTP {resp.status_code}"
|
||||
|
||||
# 标记为失效(400/401/403 通常表示永久性错误)
|
||||
if resp.status_code in (400, 401, 403):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
key.oauth_invalid_at = datetime.now(timezone.utc)
|
||||
key.oauth_invalid_reason = error_reason
|
||||
db.commit()
|
||||
logger.warning("Key {} OAuth token 刷新失败,已标记为失效: {}", key_id, error_reason)
|
||||
|
||||
raise InvalidRequestException(f"token refresh 失败: {error_reason}")
|
||||
|
||||
token = resp.json()
|
||||
access_token = str(token.get("access_token") or "")
|
||||
@@ -503,10 +557,247 @@ async def refresh_oauth(
|
||||
)
|
||||
|
||||
key.auth_config = crypto_service.encrypt(json.dumps(parsed))
|
||||
# 刷新成功,清除失效标记
|
||||
key.oauth_invalid_at = None
|
||||
key.oauth_invalid_reason = None
|
||||
db.commit()
|
||||
|
||||
return CompleteOAuthResponse(
|
||||
provider_type=provider_type,
|
||||
expires_at=expires_at,
|
||||
has_refresh_token=bool(parsed.get("refresh_token")),
|
||||
email=parsed.get("email"),
|
||||
)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Provider-level OAuth (不需要预先创建 key)
|
||||
# ==============================================================================
|
||||
|
||||
|
||||
@router.post("/providers/{provider_id}/start", response_model=StartOAuthResponse)
|
||||
async def start_provider_oauth(
|
||||
provider_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> StartOAuthResponse:
|
||||
"""基于 Provider 启动 OAuth(不需要预先创建 key)。"""
|
||||
provider = db.query(Provider).filter(Provider.id == provider_id).first()
|
||||
if not provider:
|
||||
raise NotFoundException("Provider 不存在", "provider")
|
||||
provider_type = _require_fixed_provider(provider)
|
||||
|
||||
try:
|
||||
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
|
||||
except Exception:
|
||||
template = None
|
||||
if not template:
|
||||
raise InvalidRequestException("不支持的 provider_type")
|
||||
|
||||
redis = await get_redis_client(require_redis=True)
|
||||
assert redis is not None
|
||||
|
||||
pkce_verifier: str | None = None
|
||||
code_challenge: str | None = None
|
||||
|
||||
if template.oauth.use_pkce:
|
||||
pkce_verifier = secrets.token_urlsafe(32)
|
||||
code_challenge = _pkce_s256(pkce_verifier)
|
||||
|
||||
state = await _create_state(
|
||||
redis,
|
||||
key_id="", # 空,complete 时创建
|
||||
provider_id=provider_id,
|
||||
provider_type=provider_type,
|
||||
pkce_verifier=pkce_verifier,
|
||||
)
|
||||
|
||||
params: dict[str, Any] = {
|
||||
"client_id": template.oauth.client_id,
|
||||
"response_type": "code",
|
||||
"redirect_uri": template.oauth.redirect_uri,
|
||||
"scope": " ".join(template.oauth.scopes),
|
||||
"state": state,
|
||||
}
|
||||
|
||||
if provider_type == ProviderType.CODEX.value:
|
||||
params.update(
|
||||
{
|
||||
"prompt": "login",
|
||||
"id_token_add_organizations": "true",
|
||||
"codex_cli_simplified_flow": "true",
|
||||
}
|
||||
)
|
||||
|
||||
if template.oauth.use_pkce and code_challenge:
|
||||
params["code_challenge"] = code_challenge
|
||||
params["code_challenge_method"] = "S256"
|
||||
|
||||
authorization_url = f"{template.oauth.authorize_url}?{urlencode(params)}"
|
||||
|
||||
return StartOAuthResponse(
|
||||
authorization_url=authorization_url,
|
||||
redirect_uri=template.oauth.redirect_uri,
|
||||
provider_type=provider_type,
|
||||
instructions=(
|
||||
"1) 打开 authorization_url 完成授权\n"
|
||||
"2) 授权后会跳转到 redirect_uri(localhost)\n"
|
||||
"3) 复制浏览器地址栏完整 URL,调用 complete 接口粘贴 callback_url"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/providers/{provider_id}/complete", response_model=ProviderCompleteOAuthResponse)
|
||||
async def complete_provider_oauth(
|
||||
provider_id: str,
|
||||
payload: ProviderCompleteOAuthRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> ProviderCompleteOAuthResponse:
|
||||
"""完成 Provider OAuth 并创建 key。"""
|
||||
redis = await get_redis_client(require_redis=True)
|
||||
assert redis is not None
|
||||
|
||||
params = _parse_callback_params(payload.callback_url)
|
||||
code = params.get("code")
|
||||
state = params.get("state")
|
||||
if not code or not state:
|
||||
raise InvalidRequestException("callback_url 缺少 code/state")
|
||||
|
||||
state_data = await _consume_state(redis, state)
|
||||
if not state_data or state_data.provider_id != provider_id:
|
||||
raise InvalidRequestException("state 无效或已过期")
|
||||
|
||||
provider = db.query(Provider).filter(Provider.id == provider_id).first()
|
||||
if not provider:
|
||||
raise NotFoundException("Provider 不存在", "provider")
|
||||
provider_type = _require_fixed_provider(provider)
|
||||
|
||||
try:
|
||||
template = FIXED_PROVIDERS.get(ProviderType(provider_type))
|
||||
except Exception:
|
||||
template = None
|
||||
if not template:
|
||||
raise InvalidRequestException("不支持的 provider_type")
|
||||
|
||||
# exchange token
|
||||
token_url = template.oauth.token_url
|
||||
is_json = "anthropic.com" in token_url
|
||||
|
||||
if is_json:
|
||||
body: dict[str, Any] = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": template.oauth.client_id,
|
||||
"redirect_uri": template.oauth.redirect_uri,
|
||||
"code": code,
|
||||
"state": state,
|
||||
}
|
||||
if state_data.pkce_verifier:
|
||||
body["code_verifier"] = state_data.pkce_verifier
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
data = None
|
||||
json_body = body
|
||||
else:
|
||||
form: dict[str, str] = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": template.oauth.client_id,
|
||||
"redirect_uri": template.oauth.redirect_uri,
|
||||
"code": code,
|
||||
}
|
||||
if template.oauth.client_secret:
|
||||
form["client_secret"] = template.oauth.client_secret
|
||||
if state_data.pkce_verifier:
|
||||
form["code_verifier"] = state_data.pkce_verifier
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
data = form
|
||||
json_body = None
|
||||
|
||||
proxy_config = getattr(provider, "proxy", None)
|
||||
|
||||
resp = await post_oauth_token(
|
||||
provider_type=provider_type,
|
||||
token_url=token_url,
|
||||
headers=headers,
|
||||
data=data,
|
||||
json_body=json_body,
|
||||
proxy_config=proxy_config,
|
||||
timeout_seconds=30.0,
|
||||
)
|
||||
|
||||
if resp.status_code < 200 or resp.status_code >= 300:
|
||||
raise InvalidRequestException("token exchange 失败")
|
||||
|
||||
token = resp.json()
|
||||
access_token = str(token.get("access_token") or "")
|
||||
refresh_token = str(token.get("refresh_token") or "")
|
||||
expires_in = token.get("expires_in")
|
||||
expires_at: int | None = None
|
||||
try:
|
||||
if expires_in is not None:
|
||||
expires_at = int(time.time()) + int(expires_in)
|
||||
except Exception:
|
||||
expires_at = None
|
||||
|
||||
if not access_token:
|
||||
raise InvalidRequestException("token exchange 返回缺少 access_token")
|
||||
|
||||
# 构建 auth_config
|
||||
auth_config: dict[str, Any] = {
|
||||
"provider_type": provider_type,
|
||||
"token_type": token.get("token_type"),
|
||||
"refresh_token": refresh_token or None,
|
||||
"expires_at": expires_at,
|
||||
"scope": token.get("scope"),
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
|
||||
auth_config = await enrich_auth_config(
|
||||
provider_type=provider_type,
|
||||
auth_config=auth_config,
|
||||
token_response=token,
|
||||
access_token=access_token,
|
||||
proxy_config=proxy_config,
|
||||
)
|
||||
|
||||
# 确定账号名称
|
||||
name = (payload.name or "").strip()
|
||||
if not name:
|
||||
name = auth_config.get("email") or f"账号_{int(time.time())}"
|
||||
|
||||
# 从 Provider 的 endpoints 中提取所有 api_format 作为 Key 的支持格式
|
||||
api_formats = [ep.api_format for ep in provider.endpoints if ep.api_format and ep.is_active]
|
||||
|
||||
# 创建 key
|
||||
from src.models.database import ProviderAPIKey as ProviderAPIKeyModel
|
||||
|
||||
new_key = ProviderAPIKeyModel(
|
||||
provider_id=provider_id,
|
||||
name=name,
|
||||
api_key=crypto_service.encrypt(access_token),
|
||||
auth_type="oauth",
|
||||
auth_config=crypto_service.encrypt(json.dumps(auth_config)),
|
||||
api_formats=api_formats,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(new_key)
|
||||
db.commit()
|
||||
db.refresh(new_key)
|
||||
|
||||
# 触发 OAuth 刷新任务重新调度
|
||||
try:
|
||||
from src.services.system import get_maintenance_scheduler
|
||||
|
||||
get_maintenance_scheduler().trigger_oauth_refresh_check()
|
||||
except Exception as e:
|
||||
logger.debug("trigger_oauth_refresh_check 调用失败: {}", e)
|
||||
|
||||
return ProviderCompleteOAuthResponse(
|
||||
key_id=str(new_key.id),
|
||||
provider_type=provider_type,
|
||||
expires_at=expires_at,
|
||||
has_refresh_token=bool(refresh_token),
|
||||
email=auth_config.get("email"),
|
||||
)
|
||||
|
||||
@@ -438,12 +438,28 @@ async def test_model(
|
||||
raise HTTPException(status_code=500, detail="Failed to decrypt API key")
|
||||
|
||||
# 构建请求配置
|
||||
extra_headers = get_extra_headers_from_endpoint(endpoint) or {}
|
||||
|
||||
# OAuth 认证:从 auth_config 获取 account_id 并添加到请求头
|
||||
if api_key.auth_type == "oauth" and api_key.auth_config:
|
||||
try:
|
||||
import json
|
||||
|
||||
decrypted_config = crypto_service.decrypt(api_key.auth_config)
|
||||
auth_config = json.loads(decrypted_config)
|
||||
account_id = auth_config.get("account_id")
|
||||
if account_id:
|
||||
extra_headers["chatgpt-account-id"] = account_id
|
||||
logger.debug("[test-model] Added chatgpt-account-id header: {}", account_id)
|
||||
except Exception as e:
|
||||
logger.warning("[test-model] Failed to parse OAuth auth_config: {}", e)
|
||||
|
||||
endpoint_config = {
|
||||
"api_key": api_key_value,
|
||||
"api_key_id": api_key.id, # 添加API Key ID用于用量记录
|
||||
"base_url": endpoint.base_url,
|
||||
"api_format": endpoint.api_format,
|
||||
"extra_headers": get_extra_headers_from_endpoint(endpoint),
|
||||
"extra_headers": extra_headers if extra_headers else None,
|
||||
"timeout": TimeoutDefaults.HTTP_REQUEST,
|
||||
}
|
||||
|
||||
@@ -478,8 +494,7 @@ async def test_model(
|
||||
async with httpx.AsyncClient(
|
||||
timeout=endpoint_config["timeout"], verify=get_ssl_context()
|
||||
) as client:
|
||||
# 非流式测试
|
||||
logger.debug(f"[test-model] 开始非流式测试...")
|
||||
logger.debug("[test-model] 开始端点测试...")
|
||||
|
||||
response = await adapter_class.check_endpoint(
|
||||
client,
|
||||
@@ -497,7 +512,7 @@ async def test_model(
|
||||
)
|
||||
|
||||
# 记录提供商返回信息
|
||||
logger.debug(f"[test-model] 非流式测试结果:")
|
||||
logger.debug("[test-model] 端点测试结果:")
|
||||
logger.debug(f"[test-model] Status Code: {response.get('status_code')}")
|
||||
logger.debug(f"[test-model] Response Headers: {response.get('headers', {})}")
|
||||
response_data = response.get("response", {})
|
||||
|
||||
@@ -68,7 +68,6 @@ from src.models.database import (
|
||||
User,
|
||||
)
|
||||
from src.services.cache.aware_scheduler import ProviderCandidate
|
||||
from src.services.provider.codex import maybe_patch_request_for_codex
|
||||
from src.services.provider.transport import (
|
||||
build_provider_url,
|
||||
get_vertex_ai_effective_format,
|
||||
@@ -720,6 +719,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
key_id=str(key.id),
|
||||
provider_api_format=str(endpoint.api_format) if endpoint.api_format else None,
|
||||
)
|
||||
ctx.provider_type = str(getattr(provider, "provider_type", "") or "")
|
||||
|
||||
# ctx.api_format 是枚举,需要取 value 作为字符串
|
||||
_api_format_str = (
|
||||
@@ -754,13 +754,18 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
else:
|
||||
request_body = dict(original_request_body)
|
||||
|
||||
# 确定目标变体(用于 Codex 等需要特殊处理的上游)
|
||||
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
|
||||
target_variant = provider_type if provider_type == "codex" else None
|
||||
|
||||
# 跨格式:先做请求体转换(失败触发 failover)
|
||||
if needs_conversion:
|
||||
registry = get_format_converter_registry()
|
||||
if needs_conversion:
|
||||
request_body = registry.convert_request(
|
||||
request_body,
|
||||
str(client_api_format),
|
||||
str(provider_api_format),
|
||||
target_variant=target_variant,
|
||||
)
|
||||
# 格式转换后,为需要 model 字段的格式设置模型名
|
||||
self._set_model_after_conversion(
|
||||
@@ -779,12 +784,13 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
else:
|
||||
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
|
||||
request_body = self.prepare_provider_request_body(request_body)
|
||||
|
||||
# Provider-specific compatibility patches (e.g. Codex requires store=false and instructions).
|
||||
request_body = maybe_patch_request_for_codex(
|
||||
provider_type=str(getattr(provider, "provider_type", "") or ""),
|
||||
provider_api_format=str(provider_api_format),
|
||||
request_body=request_body,
|
||||
# 同格式时也需要应用 target_variant 转换(如 Codex)
|
||||
if target_variant:
|
||||
request_body = registry.convert_request(
|
||||
request_body,
|
||||
str(provider_api_format),
|
||||
str(provider_api_format),
|
||||
target_variant=target_variant,
|
||||
)
|
||||
|
||||
# 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式)
|
||||
@@ -1087,13 +1093,18 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
else:
|
||||
request_body = dict(request_body_ref["body"])
|
||||
|
||||
# 确定目标变体(用于 Codex 等需要特殊处理的上游)
|
||||
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
|
||||
target_variant = provider_type if provider_type == "codex" else None
|
||||
|
||||
# 跨格式:先做请求体转换(失败触发 failover)
|
||||
if needs_conversion:
|
||||
registry = get_format_converter_registry()
|
||||
if needs_conversion:
|
||||
request_body = registry.convert_request(
|
||||
request_body,
|
||||
client_api_format,
|
||||
provider_api_format,
|
||||
target_variant=target_variant,
|
||||
)
|
||||
# 格式转换后,为需要 model 字段的格式设置模型名
|
||||
self._set_model_after_conversion(
|
||||
@@ -1112,12 +1123,13 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
||||
else:
|
||||
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
|
||||
request_body = self.prepare_provider_request_body(request_body)
|
||||
|
||||
# Provider-specific compatibility patches (e.g. Codex requires store=false and instructions).
|
||||
request_body = maybe_patch_request_for_codex(
|
||||
provider_type=str(getattr(provider, "provider_type", "") or ""),
|
||||
provider_api_format=str(provider_api_format),
|
||||
request_body=request_body,
|
||||
# 同格式时也需要应用 target_variant 转换(如 Codex)
|
||||
if target_variant:
|
||||
request_body = registry.convert_request(
|
||||
request_body,
|
||||
provider_api_format,
|
||||
provider_api_format,
|
||||
target_variant=target_variant,
|
||||
)
|
||||
|
||||
# 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式)
|
||||
|
||||
@@ -638,13 +638,13 @@ class CliAdapterBase(ApiAdapter):
|
||||
url = cls.build_endpoint_url(base_url, request_data, model_name)
|
||||
|
||||
# 合并 CLI 额外头部到 extra_headers
|
||||
cli_extra = cls.get_cli_extra_headers()
|
||||
cli_extra = cls.get_cli_extra_headers(base_url=base_url)
|
||||
merged_extra = dict(extra_headers) if extra_headers else {}
|
||||
merged_extra.update(cli_extra)
|
||||
|
||||
# 使用统一的头部构建函数
|
||||
headers = cls.build_headers_with_extra(api_key, merged_extra if merged_extra else None)
|
||||
body = cls.build_request_body(request_data)
|
||||
body = cls.build_request_body(request_data, base_url=base_url)
|
||||
|
||||
# 获取有效的模型名称
|
||||
effective_model_name = model_name or request_data.get("model")
|
||||
@@ -686,17 +686,25 @@ class CliAdapterBase(ApiAdapter):
|
||||
raise NotImplementedError(f"{cls.FORMAT_ID} adapter must implement build_endpoint_url")
|
||||
|
||||
@classmethod
|
||||
def build_request_body(cls, request_data: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
def build_request_body(
|
||||
cls,
|
||||
request_data: dict[str, Any] | None = None,
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""构建测试请求体,使用转换器注册表自动处理格式转换
|
||||
|
||||
Args:
|
||||
request_data: 可选的请求数据,会与默认测试请求合并
|
||||
base_url: API 基础 URL,用于判断特殊端点(如 Codex)
|
||||
|
||||
Returns:
|
||||
转换为目标 API 格式的请求体
|
||||
"""
|
||||
from src.api.handlers.base.request_builder import build_test_request_body
|
||||
|
||||
# 基类不使用 base_url,子类可覆盖以支持特殊端点
|
||||
_ = base_url
|
||||
return build_test_request_body(cls.FORMAT_ID, request_data)
|
||||
|
||||
@classmethod
|
||||
@@ -710,13 +718,16 @@ class CliAdapterBase(ApiAdapter):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_cli_extra_headers(cls) -> dict[str, str]:
|
||||
def get_cli_extra_headers(cls, *, base_url: str | None = None) -> dict[str, str]:
|
||||
"""
|
||||
获取CLI额外请求头 - 子类可覆盖
|
||||
|
||||
用于 check_endpoint 测试请求时添加额外的头部。
|
||||
默认实现只添加 User-Agent(如果有)。
|
||||
|
||||
Args:
|
||||
base_url: API 基础 URL,子类可据此判断特殊端点(如 Codex)
|
||||
|
||||
Returns:
|
||||
额外请求头字典
|
||||
"""
|
||||
|
||||
@@ -72,7 +72,6 @@ from src.models.database import (
|
||||
User,
|
||||
)
|
||||
from src.services.cache.aware_scheduler import ProviderCandidate
|
||||
from src.services.provider.codex import maybe_patch_request_for_codex
|
||||
from src.services.provider.transport import build_provider_url
|
||||
from src.services.system.config import SystemConfigService
|
||||
from src.utils.sse_parser import SSEEventParser
|
||||
@@ -425,6 +424,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
mapped_model: str | None,
|
||||
fallback_model: str,
|
||||
is_stream: bool,
|
||||
*,
|
||||
target_variant: str | None = None,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
"""
|
||||
跨格式请求转换的公共逻辑
|
||||
@@ -438,6 +439,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
mapped_model: 映射后的模型名
|
||||
fallback_model: 备用模型名(通常是原始请求的 model)
|
||||
is_stream: 是否流式请求
|
||||
target_variant: 目标变体(如 "codex"),用于同格式但有细微差异的上游
|
||||
|
||||
Returns:
|
||||
(转换后的请求体, 用于 URL 的模型名)
|
||||
@@ -447,6 +449,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
request_body,
|
||||
str(client_api_format),
|
||||
str(provider_api_format),
|
||||
target_variant=target_variant,
|
||||
)
|
||||
|
||||
# 先计算 URL 模型(在清理 body 中的 model 字段之前)
|
||||
@@ -697,6 +700,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
# 记录 Provider 信息
|
||||
ctx.provider_name = str(provider.name)
|
||||
ctx.provider_id = str(provider.id)
|
||||
ctx.provider_type = str(getattr(provider, "provider_type", "") or "")
|
||||
ctx.endpoint_id = str(endpoint.id)
|
||||
ctx.key_id = str(key.id)
|
||||
|
||||
@@ -730,6 +734,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
)
|
||||
ctx.needs_conversion = needs_conversion
|
||||
|
||||
# 确定目标变体(用于 Codex 等需要特殊处理的上游)
|
||||
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
|
||||
target_variant = provider_type if provider_type == "codex" else None
|
||||
|
||||
# 跨格式:先做请求体转换(失败触发 failover)
|
||||
if needs_conversion and provider_api_format:
|
||||
request_body, url_model = self._convert_request_for_cross_format(
|
||||
@@ -739,6 +747,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
mapped_model,
|
||||
ctx.model,
|
||||
is_stream=True,
|
||||
target_variant=target_variant,
|
||||
)
|
||||
else:
|
||||
# 同格式:按原逻辑做轻量清理(子类可覆盖)
|
||||
@@ -746,12 +755,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
url_model = (
|
||||
self.get_model_for_url(request_body, mapped_model) or mapped_model or ctx.model
|
||||
)
|
||||
|
||||
# Provider-specific compatibility patches (e.g. Codex requires store=false and instructions).
|
||||
request_body = maybe_patch_request_for_codex(
|
||||
provider_type=str(getattr(provider, "provider_type", "") or ""),
|
||||
provider_api_format=str(provider_api_format),
|
||||
request_body=request_body,
|
||||
# 同格式时也需要应用 target_variant 转换(如 Codex)
|
||||
if target_variant:
|
||||
registry = get_format_converter_registry()
|
||||
request_body = registry.convert_request(
|
||||
request_body,
|
||||
provider_api_format,
|
||||
provider_api_format,
|
||||
target_variant=target_variant,
|
||||
)
|
||||
|
||||
# 获取认证信息(处理 Service Account 等异步认证场景)
|
||||
@@ -1922,12 +1933,19 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
try:
|
||||
from src.models.database import ApiKey as ApiKeyModel
|
||||
|
||||
# 采集上游元数据(仅成功请求)
|
||||
if ctx.is_success():
|
||||
self._collect_upstream_metadata(bg_db, ctx)
|
||||
|
||||
user = bg_db.query(User).filter(User.id == ctx.user_id).first()
|
||||
api_key = bg_db.query(ApiKeyModel).filter(ApiKeyModel.id == ctx.api_key_id).first()
|
||||
|
||||
if not user or not api_key:
|
||||
logger.warning(
|
||||
f"[{ctx.request_id}] 无法记录统计: user={user is not None}, api_key={api_key is not None}"
|
||||
"[{}] 无法记录统计: user={} api_key={}",
|
||||
ctx.request_id,
|
||||
user is not None,
|
||||
api_key is not None,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -2153,6 +2171,19 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
except Exception as e:
|
||||
logger.exception("记录流式统计信息时出错")
|
||||
|
||||
@staticmethod
|
||||
def _collect_upstream_metadata(db: Session, ctx: StreamContext) -> None:
|
||||
"""采集上游元数据并更新 ProviderAPIKey.upstream_metadata(带节流)"""
|
||||
from src.services.provider.metadata_collectors import collect_and_save_upstream_metadata
|
||||
|
||||
collect_and_save_upstream_metadata(
|
||||
db,
|
||||
provider_type=ctx.provider_type or "",
|
||||
key_id=ctx.key_id or "",
|
||||
response_headers=ctx.response_headers or {},
|
||||
request_id=ctx.request_id or "",
|
||||
)
|
||||
|
||||
async def _record_stream_failure(
|
||||
self,
|
||||
ctx: StreamContext,
|
||||
@@ -2285,6 +2316,10 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
)
|
||||
needs_conversion = bool(getattr(candidate, "needs_conversion", False))
|
||||
|
||||
# 确定目标变体(用于 Codex 等需要特殊处理的上游)
|
||||
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
|
||||
target_variant = provider_type if provider_type == "codex" else None
|
||||
|
||||
# 跨格式:先做请求体转换(失败触发 failover)
|
||||
if needs_conversion and provider_api_format:
|
||||
request_body, url_model = self._convert_request_for_cross_format(
|
||||
@@ -2294,6 +2329,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
mapped_model,
|
||||
model,
|
||||
is_stream=False,
|
||||
target_variant=target_variant,
|
||||
)
|
||||
else:
|
||||
# 同格式:按原逻辑做轻量清理(子类可覆盖)
|
||||
@@ -2301,12 +2337,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
||||
url_model = (
|
||||
self.get_model_for_url(request_body, mapped_model) or mapped_model or model
|
||||
)
|
||||
|
||||
# Provider-specific compatibility patches (e.g. Codex requires store=false and instructions).
|
||||
request_body = maybe_patch_request_for_codex(
|
||||
provider_type=str(getattr(provider, "provider_type", "") or ""),
|
||||
provider_api_format=str(provider_api_format),
|
||||
request_body=request_body,
|
||||
# 同格式时也需要应用 target_variant 转换(如 Codex)
|
||||
if target_variant:
|
||||
registry = get_format_converter_registry()
|
||||
request_body = registry.convert_request(
|
||||
request_body,
|
||||
provider_api_format,
|
||||
provider_api_format,
|
||||
target_variant=target_variant,
|
||||
)
|
||||
|
||||
# 获取认证信息(处理 Service Account 等异步认证场景)
|
||||
|
||||
@@ -271,7 +271,7 @@ async def _calculate_and_record_usage(
|
||||
cache_read_input_tokens=cache_read_input_tokens,
|
||||
request_type="endpoint_test", # 使用特殊的请求类型标识测试
|
||||
api_format=api_format,
|
||||
is_stream=False,
|
||||
is_stream=request_data.get("stream", False) if request_data else False,
|
||||
response_time_ms=response_time_ms,
|
||||
first_byte_time_ms=response_time_ms,
|
||||
status_code=status_code,
|
||||
@@ -587,13 +587,41 @@ class HttpRequestExecutor:
|
||||
self.timeout = timeout
|
||||
|
||||
async def execute(self, request: EndpointCheckRequest) -> EndpointCheckResult:
|
||||
"""执行HTTP请求"""
|
||||
"""执行HTTP请求(支持流式和非流式响应)"""
|
||||
start_time = time.time()
|
||||
request_id = request.request_id or str(uuid.uuid4())[:8]
|
||||
|
||||
# 检查是否是流式请求
|
||||
is_stream = request.json_body.get("stream", False) if request.json_body else False
|
||||
|
||||
try:
|
||||
# 使用httpx进行异步请求
|
||||
async with httpx.AsyncClient(timeout=self.timeout, verify=get_ssl_context()) as client:
|
||||
if is_stream:
|
||||
# 流式请求:读取 SSE 事件直到完成
|
||||
response_data = await self._execute_stream_request(client, request)
|
||||
end_time = time.time()
|
||||
response_time_ms = int((end_time - start_time) * 1000)
|
||||
|
||||
if response_data.get("error"):
|
||||
# 流式请求返回错误
|
||||
return EndpointCheckResult(
|
||||
status_code=response_data.get("status_code", 500),
|
||||
headers=response_data.get("headers", {}),
|
||||
response_time_ms=response_time_ms,
|
||||
request_id=request_id,
|
||||
response_data=None,
|
||||
error_message=response_data.get("error"),
|
||||
)
|
||||
|
||||
return EndpointCheckResult(
|
||||
status_code=200,
|
||||
headers=response_data.get("headers", {}),
|
||||
response_time_ms=response_time_ms,
|
||||
request_id=request_id,
|
||||
response_data=response_data.get("final_response"),
|
||||
)
|
||||
else:
|
||||
# 非流式请求:直接读取响应
|
||||
response = await client.post(
|
||||
url=request.url, json=request.json_body, headers=request.headers
|
||||
)
|
||||
@@ -601,7 +629,6 @@ class HttpRequestExecutor:
|
||||
end_time = time.time()
|
||||
response_time_ms = int((end_time - start_time) * 1000)
|
||||
|
||||
# 处理响应
|
||||
if response.status_code == 200:
|
||||
try:
|
||||
response_data = response.json()
|
||||
@@ -610,7 +637,9 @@ class HttpRequestExecutor:
|
||||
)
|
||||
except Exception:
|
||||
response_data = None
|
||||
logger.debug(f"[{request.api_format}] check_endpoint | response | invalid json")
|
||||
logger.debug(
|
||||
f"[{request.api_format}] check_endpoint | response | invalid json"
|
||||
)
|
||||
|
||||
return EndpointCheckResult(
|
||||
status_code=response.status_code,
|
||||
@@ -620,25 +649,129 @@ class HttpRequestExecutor:
|
||||
response_data=response_data,
|
||||
)
|
||||
else:
|
||||
# 对于非200状态码,使用错误处理器
|
||||
error_body = response.text[:500] if response.text else "(empty)"
|
||||
logger.debug(
|
||||
f"[{request.api_format}] check_endpoint | response | error={error_body}"
|
||||
)
|
||||
|
||||
# 创建HTTPStatusError让错误处理器处理
|
||||
http_error = httpx.HTTPStatusError(
|
||||
message=f"HTTP {response.status_code}: {error_body}",
|
||||
request=None, # 我们不需要完整的request对象
|
||||
request=None,
|
||||
response=response,
|
||||
)
|
||||
|
||||
return await ErrorHandler.handle_error(http_error, request)
|
||||
|
||||
except Exception as e:
|
||||
# 使用统一错误处理器处理异常
|
||||
return await ErrorHandler.handle_error(e, request)
|
||||
|
||||
async def _execute_stream_request(
|
||||
self, client: httpx.AsyncClient, request: EndpointCheckRequest
|
||||
) -> dict[str, Any]:
|
||||
"""执行流式请求并收集响应"""
|
||||
try:
|
||||
async with client.stream(
|
||||
"POST", request.url, json=request.json_body, headers=request.headers
|
||||
) as response:
|
||||
headers = dict(response.headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
error_body = ""
|
||||
async for chunk in response.aiter_text():
|
||||
error_body += chunk
|
||||
if len(error_body) > 500:
|
||||
break
|
||||
logger.debug(
|
||||
"[{}] check_endpoint | stream error | {}",
|
||||
request.api_format,
|
||||
error_body[:500],
|
||||
)
|
||||
return {
|
||||
"error": f"HTTP {response.status_code}: {error_body[:500]}",
|
||||
"status_code": response.status_code,
|
||||
"headers": headers,
|
||||
}
|
||||
|
||||
# 收集 SSE 事件(兼容多种 API 格式)
|
||||
final_response: dict[str, Any] = {}
|
||||
collected_text = ""
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line or not line.startswith("data:"):
|
||||
continue
|
||||
|
||||
data_str = line[5:].strip()
|
||||
if data_str == "[DONE]":
|
||||
break
|
||||
|
||||
try:
|
||||
event = json.loads(data_str)
|
||||
event_type = event.get("type", "")
|
||||
|
||||
# OpenAI Responses API 事件
|
||||
if event_type == "response.output_text.delta":
|
||||
delta = event.get("delta", "")
|
||||
if isinstance(delta, str):
|
||||
collected_text += delta
|
||||
elif event_type == "response.completed":
|
||||
final_response = event.get("response", {})
|
||||
break
|
||||
|
||||
# OpenAI Chat Completions 格式
|
||||
elif "choices" in event:
|
||||
for choice in event.get("choices", []):
|
||||
delta = choice.get("delta", {})
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
collected_text += content
|
||||
if choice.get("finish_reason"):
|
||||
final_response = event
|
||||
break
|
||||
|
||||
# Claude Messages API 格式
|
||||
elif event_type == "content_block_delta":
|
||||
delta = event.get("delta", {})
|
||||
text = delta.get("text", "")
|
||||
if text:
|
||||
collected_text += text
|
||||
elif event_type == "message_stop":
|
||||
break
|
||||
|
||||
# Gemini SSE 格式
|
||||
elif "candidates" in event:
|
||||
for candidate in event.get("candidates", []):
|
||||
content = candidate.get("content", {})
|
||||
for part in content.get("parts", []):
|
||||
text = part.get("text", "")
|
||||
if text:
|
||||
collected_text += text
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# 如果没有收到最终响应事件,构建一个基本响应
|
||||
if not final_response:
|
||||
final_response = {
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": collected_text}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
"[{}] check_endpoint | stream completed | text_length={}",
|
||||
request.api_format,
|
||||
len(collected_text),
|
||||
)
|
||||
|
||||
return {"final_response": final_response, "headers": headers}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("[{}] check_endpoint | stream error | {}", request.api_format, e)
|
||||
return {"error": str(e), "status_code": 500, "headers": {}}
|
||||
|
||||
|
||||
class UsageCalculator:
|
||||
"""用量计算器 - 专门负责Token计数和费用计算"""
|
||||
|
||||
@@ -15,12 +15,14 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import object_session
|
||||
|
||||
from src.clients.redis_client import get_redis_client
|
||||
from src.core.api_format import (
|
||||
UPSTREAM_DROP_HEADERS,
|
||||
HeaderBuilder,
|
||||
@@ -30,9 +32,6 @@ from src.core.api_format import (
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.logger import logger
|
||||
from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
|
||||
from sqlalchemy.orm import object_session
|
||||
|
||||
from src.clients.redis_client import get_redis_client
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.models.database import ProviderAPIKey, ProviderEndpoint
|
||||
@@ -108,6 +107,8 @@ def get_test_request_data(request_data: dict[str, Any] | None = None) -> dict[st
|
||||
def build_test_request_body(
|
||||
format_id: str,
|
||||
request_data: dict[str, Any] | None = None,
|
||||
*,
|
||||
target_variant: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""构建测试请求体,自动处理格式转换
|
||||
|
||||
@@ -116,6 +117,7 @@ def build_test_request_body(
|
||||
Args:
|
||||
format_id: 目标 endpoint signature(如 "claude:chat", "gemini:chat", "openai:cli")
|
||||
request_data: 可选的请求数据,会与默认测试请求合并
|
||||
target_variant: 目标变体(如 "codex"),用于同格式但有细微差异的上游
|
||||
|
||||
Returns:
|
||||
转换为目标 API 格式的请求体
|
||||
@@ -124,21 +126,19 @@ def build_test_request_body(
|
||||
format_conversion_registry,
|
||||
register_default_normalizers,
|
||||
)
|
||||
from src.core.api_format.utils import get_base_format
|
||||
|
||||
register_default_normalizers()
|
||||
|
||||
# 获取测试请求数据(OpenAI 格式)
|
||||
source_data = get_test_request_data(request_data)
|
||||
|
||||
# CLI 格式使用基础格式进行转换(claude:cli -> claude:chat)
|
||||
target_format = get_base_format(format_id) or format_id
|
||||
|
||||
# 使用注册表进行格式转换 (openai:chat -> 目标基础格式)
|
||||
# 直接使用目标格式进行转换,不再转换为基础格式
|
||||
# 这样 openai:cli 会正确转换为 Responses API 格式
|
||||
return format_conversion_registry.convert_request(
|
||||
source_data,
|
||||
make_signature_key("openai", "chat"),
|
||||
target_format,
|
||||
format_id,
|
||||
target_variant=target_variant,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ class StreamContext:
|
||||
# Provider 信息(在请求执行时填充)
|
||||
provider_name: str | None = None
|
||||
provider_id: str | None = None
|
||||
provider_type: str | None = None # Provider 类型(如 codex),用于元数据采集
|
||||
endpoint_id: str | None = None
|
||||
key_id: str | None = None
|
||||
attempt_id: str | None = None
|
||||
|
||||
@@ -96,6 +96,10 @@ class StreamTelemetryRecorder:
|
||||
bg_db = next(db_gen)
|
||||
|
||||
try:
|
||||
# 采集上游元数据(仅成功请求,放在 writer 获取之前以确保执行)
|
||||
if ctx.is_success():
|
||||
self._collect_upstream_metadata(bg_db, ctx)
|
||||
|
||||
writer = await self._get_telemetry_writer(bg_db, ctx, response_time_ms)
|
||||
if writer is None:
|
||||
return
|
||||
@@ -502,6 +506,19 @@ class StreamTelemetryRecorder:
|
||||
error_message=ctx.error_message or f"HTTP {ctx.status_code}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _collect_upstream_metadata(db: Session, ctx: StreamContext) -> None:
|
||||
"""采集上游元数据并更新 ProviderAPIKey.upstream_metadata(带节流)"""
|
||||
from src.services.provider.metadata_collectors import collect_and_save_upstream_metadata
|
||||
|
||||
collect_and_save_upstream_metadata(
|
||||
db,
|
||||
provider_type=ctx.provider_type or "",
|
||||
key_id=ctx.key_id or "",
|
||||
response_headers=ctx.response_headers or {},
|
||||
request_id=ctx.request_id or "",
|
||||
)
|
||||
|
||||
def _get_status_from_ctx(self, ctx: StreamContext) -> str:
|
||||
"""根据上下文获取状态字符串"""
|
||||
if ctx.is_success():
|
||||
|
||||
@@ -140,9 +140,9 @@ class ClaudeCliAdapter(CliAdapterBase):
|
||||
return config.internal_user_agent_claude_cli
|
||||
|
||||
@classmethod
|
||||
def get_cli_extra_headers(cls) -> dict[str, str]:
|
||||
def get_cli_extra_headers(cls, *, base_url: str | None = None) -> dict[str, str]:
|
||||
"""获取Claude CLI额外请求头,包含 x-app: cli 标识"""
|
||||
headers = super().get_cli_extra_headers()
|
||||
headers = super().get_cli_extra_headers(base_url=base_url)
|
||||
headers["x-app"] = "cli" # 标识 CLI 模式,让上游使用正确的认证方式
|
||||
return headers
|
||||
|
||||
|
||||
@@ -158,9 +158,9 @@ class GeminiCliAdapter(CliAdapterBase):
|
||||
return config.internal_user_agent_gemini_cli
|
||||
|
||||
@classmethod
|
||||
def get_cli_extra_headers(cls) -> dict[str, str]:
|
||||
def get_cli_extra_headers(cls, *, base_url: str | None = None) -> dict[str, str]:
|
||||
"""获取Gemini CLI额外请求头,包含 x-app: cli 标识"""
|
||||
headers = super().get_cli_extra_headers()
|
||||
headers = super().get_cli_extra_headers(base_url=base_url)
|
||||
headers["x-app"] = "cli" # 标识 CLI 模式,让上游使用正确的 adapter
|
||||
return headers
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ OpenAI CLI Adapter - 基于通用 CLI Adapter 基类的简化实现
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -67,20 +68,74 @@ class OpenAICliAdapter(CliAdapterBase):
|
||||
def build_endpoint_url(
|
||||
cls, base_url: str, request_data: dict[str, Any], model_name: str | None = None
|
||||
) -> str:
|
||||
"""构建OpenAI CLI API端点URL"""
|
||||
"""构建OpenAI CLI API端点URL(使用 Responses API)
|
||||
|
||||
对于 Codex OAuth 端点(如 chatgpt.com/backend-api/codex),直接追加 /responses;
|
||||
对于标准 OpenAI API,使用 /v1/responses。
|
||||
"""
|
||||
base_url = base_url.rstrip("/")
|
||||
# Codex OAuth 端点:chatgpt.com/backend-api/codex -> /responses
|
||||
if cls._is_codex_url(base_url):
|
||||
return f"{base_url}/responses"
|
||||
# 标准 OpenAI API
|
||||
if base_url.endswith("/v1"):
|
||||
return f"{base_url}/chat/completions"
|
||||
return f"{base_url}/responses"
|
||||
else:
|
||||
return f"{base_url}/v1/chat/completions"
|
||||
return f"{base_url}/v1/responses"
|
||||
|
||||
@classmethod
|
||||
def _is_codex_url(cls, base_url: str) -> bool:
|
||||
"""判断是否是 Codex OAuth 端点"""
|
||||
return "/backend-api/codex" in base_url or base_url.endswith("/codex")
|
||||
|
||||
# build_request_body 使用基类实现
|
||||
# OPENAI -> OPENAI_CLI 无转换器,会直接透传原始请求
|
||||
# OpenAI CLI normalizer 会自动添加 instructions 字段
|
||||
|
||||
@classmethod
|
||||
def build_request_body(
|
||||
cls,
|
||||
request_data: dict[str, Any] | None = None,
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""构建测试请求体(Codex 端点需要强制 stream=true 等特性)"""
|
||||
from src.api.handlers.base.request_builder import build_test_request_body
|
||||
|
||||
target_variant = "codex" if base_url and cls._is_codex_url(base_url) else None
|
||||
return build_test_request_body(
|
||||
cls.FORMAT_ID,
|
||||
request_data,
|
||||
target_variant=target_variant,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_cli_user_agent(cls) -> str | None:
|
||||
"""获取OpenAI CLI User-Agent"""
|
||||
return config.internal_user_agent_openai_cli
|
||||
|
||||
@classmethod
|
||||
def get_cli_extra_headers(cls, *, base_url: str | None = None) -> dict[str, str]:
|
||||
"""
|
||||
获取额外请求头
|
||||
|
||||
对于 Codex OAuth 端点,添加特定头部(缺少可能导致 Cloudflare 拦截)。
|
||||
对于标准 OpenAI API 端点,仅添加 User-Agent。
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
|
||||
# User-Agent
|
||||
cli_user_agent = cls.get_cli_user_agent()
|
||||
if cli_user_agent:
|
||||
headers["User-Agent"] = cli_user_agent
|
||||
|
||||
# 仅 Codex 端点添加特定头部
|
||||
if base_url and cls._is_codex_url(base_url):
|
||||
headers["x-oai-web-search-eligible"] = "true"
|
||||
headers["session_id"] = str(uuid.uuid4())
|
||||
headers["accept"] = "text/event-stream"
|
||||
headers["originator"] = "codex_cli_rs"
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
__all__ = ["OpenAICliAdapter"]
|
||||
|
||||
@@ -28,8 +28,18 @@ class FormatNormalizer(ABC):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def request_from_internal(self, internal: InternalRequest) -> dict[str, Any]:
|
||||
"""将内部表示转换为格式特定请求"""
|
||||
def request_from_internal(
|
||||
self,
|
||||
internal: InternalRequest,
|
||||
*,
|
||||
target_variant: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""将内部表示转换为格式特定请求
|
||||
|
||||
Args:
|
||||
internal: 内部请求表示
|
||||
target_variant: 目标变体(如 "codex"),用于同格式但有细微差异的上游
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
# ============ 响应转换 ============
|
||||
|
||||
@@ -154,7 +154,12 @@ class ClaudeNormalizer(FormatNormalizer):
|
||||
|
||||
return internal
|
||||
|
||||
def request_from_internal(self, internal: InternalRequest) -> dict[str, Any]:
|
||||
def request_from_internal(
|
||||
self,
|
||||
internal: InternalRequest,
|
||||
*,
|
||||
target_variant: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
system_text = internal.system or self._join_instructions(internal.instructions)
|
||||
|
||||
# Claude Messages API: messages[] 仅允许 user/assistant,且需要交替;这里做最小修复
|
||||
|
||||
@@ -189,7 +189,12 @@ class GeminiNormalizer(FormatNormalizer):
|
||||
|
||||
return internal
|
||||
|
||||
def request_from_internal(self, internal: InternalRequest) -> dict[str, Any]:
|
||||
def request_from_internal(
|
||||
self,
|
||||
internal: InternalRequest,
|
||||
*,
|
||||
target_variant: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
system_text = internal.system or self._join_instructions(internal.instructions)
|
||||
|
||||
# tools/tool_choice
|
||||
|
||||
@@ -200,7 +200,12 @@ class OpenAINormalizer(FormatNormalizer):
|
||||
|
||||
return internal
|
||||
|
||||
def request_from_internal(self, internal: InternalRequest) -> dict[str, Any]:
|
||||
def request_from_internal(
|
||||
self,
|
||||
internal: InternalRequest,
|
||||
*,
|
||||
target_variant: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
out_messages: list[dict[str, Any]] = []
|
||||
|
||||
if internal.instructions:
|
||||
|
||||
@@ -114,38 +114,58 @@ class OpenAICliNormalizer(FormatNormalizer):
|
||||
|
||||
return internal
|
||||
|
||||
def request_from_internal(self, internal: InternalRequest) -> dict[str, Any]:
|
||||
# Codex 需要的 include 项
|
||||
_CODEX_REQUIRED_INCLUDE = "reasoning.encrypted_content"
|
||||
|
||||
def request_from_internal(
|
||||
self,
|
||||
internal: InternalRequest,
|
||||
*,
|
||||
target_variant: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
is_codex = str(target_variant or "").lower() == "codex"
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"model": internal.model,
|
||||
"input": self._internal_messages_to_input(internal.messages),
|
||||
"input": self._internal_messages_to_input(
|
||||
internal.messages, system_to_developer=is_codex
|
||||
),
|
||||
}
|
||||
|
||||
instructions_text = self._join_instructions(internal)
|
||||
if instructions_text:
|
||||
result["instructions"] = instructions_text
|
||||
# 合并 instructions,如果没有则使用 system
|
||||
instructions_text = (
|
||||
self._join_instructions(internal.instructions)
|
||||
if internal.instructions
|
||||
else internal.system
|
||||
)
|
||||
# Responses API 兼容 instructions 字段,Codex 强制要求
|
||||
# 统一添加该字段以确保兼容性
|
||||
result["instructions"] = instructions_text or ""
|
||||
|
||||
# max_output_tokens/temperature/top_p: Codex 不支持,标准 API 可选
|
||||
if not is_codex:
|
||||
if internal.max_tokens is not None:
|
||||
# Responses API 使用 max_output_tokens;兼容层仍可能接受 max_tokens
|
||||
# Responses API 使用 max_output_tokens
|
||||
result["max_output_tokens"] = internal.max_tokens
|
||||
if internal.temperature is not None:
|
||||
result["temperature"] = internal.temperature
|
||||
if internal.top_p is not None:
|
||||
result["top_p"] = internal.top_p
|
||||
|
||||
if internal.stop_sequences:
|
||||
result["stop"] = list(internal.stop_sequences)
|
||||
if internal.stream:
|
||||
result["stream"] = True
|
||||
# Codex 强制要求 stream=true;其他情况尊重客户端请求
|
||||
result["stream"] = True if is_codex else bool(internal.stream)
|
||||
|
||||
if internal.tools:
|
||||
# Responses API 使用扁平结构: {type, name, description, parameters}
|
||||
# 而非 Chat Completions 的嵌套结构: {type, function: {name, ...}}
|
||||
result["tools"] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"description": t.description or "",
|
||||
"parameters": t.parameters or {},
|
||||
**(t.extra.get("openai_function") or {}),
|
||||
},
|
||||
**(t.extra.get("openai_tool") or {}),
|
||||
}
|
||||
for t in internal.tools
|
||||
@@ -154,6 +174,48 @@ class OpenAICliNormalizer(FormatNormalizer):
|
||||
if internal.tool_choice:
|
||||
result["tool_choice"] = self._tool_choice_to_openai(internal.tool_choice)
|
||||
|
||||
# 还原 OpenAI Responses API 的其他字段(黑名单:已单独处理的字段不还原)
|
||||
openai_cli_extra = internal.extra.get("openai_cli", {})
|
||||
handled_keys = {
|
||||
"model",
|
||||
"input",
|
||||
"instructions",
|
||||
"max_output_tokens",
|
||||
"max_tokens",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"stop",
|
||||
"stream",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
}
|
||||
for key, value in openai_cli_extra.items():
|
||||
if key not in handled_keys and key not in result:
|
||||
result[key] = value
|
||||
|
||||
# 统一设置 store=false(Codex 强制要求,标准 API 兼容)
|
||||
if "store" not in result:
|
||||
result["store"] = False
|
||||
|
||||
# Codex 特定设置(覆盖/删除不支持的字段)
|
||||
if is_codex:
|
||||
result["parallel_tool_calls"] = True
|
||||
# 添加 reasoning.encrypted_content 到 include
|
||||
include = result.get("include", [])
|
||||
if not isinstance(include, list):
|
||||
include = []
|
||||
if self._CODEX_REQUIRED_INCLUDE not in include:
|
||||
include.append(self._CODEX_REQUIRED_INCLUDE)
|
||||
result["include"] = include
|
||||
# 删除 Codex 不支持的字段
|
||||
for key in (
|
||||
"previous_response_id",
|
||||
"prompt_cache_key",
|
||||
"service_tier",
|
||||
"max_completion_tokens",
|
||||
):
|
||||
result.pop(key, None)
|
||||
|
||||
return result
|
||||
|
||||
# =========================
|
||||
@@ -922,7 +984,12 @@ class OpenAICliNormalizer(FormatNormalizer):
|
||||
blocks.append(UnknownBlock(raw_type=ptype or "unknown", payload=part))
|
||||
return blocks
|
||||
|
||||
def _internal_messages_to_input(self, messages: list[InternalMessage]) -> list[dict[str, Any]]:
|
||||
def _internal_messages_to_input(
|
||||
self,
|
||||
messages: list[InternalMessage],
|
||||
*,
|
||||
system_to_developer: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
# ToolUseBlock -> function_call
|
||||
@@ -979,6 +1046,9 @@ class OpenAICliNormalizer(FormatNormalizer):
|
||||
|
||||
# 普通 message(TextBlock)
|
||||
role = self._role_to_openai(msg.role)
|
||||
# Codex 不接受 system 角色,需要转换为 developer
|
||||
if system_to_developer and role == "system":
|
||||
role = "developer"
|
||||
content_items: list[dict[str, Any]] = []
|
||||
has_text = False
|
||||
|
||||
@@ -990,7 +1060,9 @@ class OpenAICliNormalizer(FormatNormalizer):
|
||||
if isinstance(block, UnknownBlock):
|
||||
continue # 跳过其他未知块
|
||||
if isinstance(block, TextBlock) and block.text:
|
||||
content_items.append({"type": "input_text", "text": block.text})
|
||||
# assistant 角色使用 output_text,其他角色使用 input_text
|
||||
text_type = "output_text" if role == "assistant" else "input_text"
|
||||
content_items.append({"type": text_type, "text": block.text})
|
||||
has_text = True
|
||||
|
||||
if has_text:
|
||||
@@ -1083,7 +1155,8 @@ class OpenAICliNormalizer(FormatNormalizer):
|
||||
if tool_choice.type == ToolChoiceType.REQUIRED:
|
||||
return "required"
|
||||
if tool_choice.type == ToolChoiceType.TOOL:
|
||||
return {"type": "function", "function": {"name": tool_choice.tool_name or ""}}
|
||||
# Responses API 使用扁平结构: {type, name}
|
||||
return {"type": "function", "name": tool_choice.tool_name or ""}
|
||||
return "auto"
|
||||
|
||||
def _role_from_value(self, role: Any) -> Role:
|
||||
@@ -1148,14 +1221,11 @@ class OpenAICliNormalizer(FormatNormalizer):
|
||||
return {}
|
||||
return {k: v for k, v in payload.items() if k not in keep_keys}
|
||||
|
||||
def _join_instructions(self, internal: InternalRequest) -> str:
|
||||
if internal.instructions:
|
||||
parts: list[str] = []
|
||||
for seg in internal.instructions:
|
||||
if seg.text:
|
||||
parts.append(seg.text)
|
||||
return "\n\n".join(parts)
|
||||
return internal.system or ""
|
||||
def _join_instructions(self, instructions: list[InstructionSegment]) -> str | None:
|
||||
"""合并 instructions 为单一字符串,与其他 normalizer 保持一致"""
|
||||
parts = [seg.text for seg in instructions if seg.text]
|
||||
joined = "\n\n".join(parts)
|
||||
return joined or None
|
||||
|
||||
def _error_type_from_value(self, value: str) -> ErrorType:
|
||||
for t in ErrorType:
|
||||
|
||||
@@ -67,8 +67,10 @@ class FormatConversionRegistry:
|
||||
request: dict[str, Any],
|
||||
source_format: str,
|
||||
target_format: str,
|
||||
*,
|
||||
target_variant: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if str(source_format).upper() == str(target_format).upper():
|
||||
if str(source_format).upper() == str(target_format).upper() and not target_variant:
|
||||
return request
|
||||
|
||||
src = self._require_normalizer(source_format)
|
||||
@@ -79,7 +81,7 @@ class FormatConversionRegistry:
|
||||
):
|
||||
try:
|
||||
internal = src.request_to_internal(request)
|
||||
return tgt.request_from_internal(internal)
|
||||
return tgt.request_from_internal(internal, target_variant=target_variant)
|
||||
except Exception as e:
|
||||
raise FormatConversionError(source_format, target_format, str(e)) from e
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import jwt
|
||||
from src.clients.http_client import HTTPClientPool, build_proxy_url
|
||||
from src.core.logger import logger
|
||||
|
||||
|
||||
_ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
||||
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"
|
||||
|
||||
@@ -206,18 +205,20 @@ async def post_oauth_token(
|
||||
)
|
||||
|
||||
|
||||
def parse_codex_id_token(id_token: str | None) -> tuple[str | None, str | None]:
|
||||
def parse_codex_id_token(id_token: str | None) -> dict[str, Any]:
|
||||
"""Parse Codex id_token WITHOUT signature verification.
|
||||
|
||||
Extract:
|
||||
Extract from claim `https://api.openai.com/auth`:
|
||||
- email: claim `email`
|
||||
- account_id: claim `https://api.openai.com/auth`.`chatgpt_account_id`
|
||||
- account_id: `chatgpt_account_id`
|
||||
- plan_type: `chatgpt_plan_type` (e.g. "plus", "free", "team", "enterprise")
|
||||
- user_id: `chatgpt_user_id`
|
||||
|
||||
Return (email, account_id). On any failure returns (None, None).
|
||||
Return dict with extracted fields. On any failure returns empty dict.
|
||||
"""
|
||||
|
||||
if not id_token:
|
||||
return (None, None)
|
||||
return {}
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
id_token,
|
||||
@@ -226,17 +227,29 @@ def parse_codex_id_token(id_token: str | None) -> tuple[str | None, str | None]:
|
||||
"verify_aud": False,
|
||||
},
|
||||
)
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
email = claims.get("email")
|
||||
if isinstance(email, str) and email:
|
||||
result["email"] = email
|
||||
|
||||
auth_info = claims.get("https://api.openai.com/auth") or {}
|
||||
account_id = None
|
||||
if isinstance(auth_info, dict):
|
||||
account_id = auth_info.get("chatgpt_account_id")
|
||||
return (
|
||||
str(email) if isinstance(email, str) and email else None,
|
||||
str(account_id) if isinstance(account_id, str) and account_id else None,
|
||||
)
|
||||
if isinstance(account_id, str) and account_id:
|
||||
result["account_id"] = account_id
|
||||
|
||||
plan_type = auth_info.get("chatgpt_plan_type")
|
||||
if isinstance(plan_type, str) and plan_type:
|
||||
result["plan_type"] = plan_type
|
||||
|
||||
user_id = auth_info.get("chatgpt_user_id")
|
||||
if isinstance(user_id, str) and user_id:
|
||||
result["user_id"] = user_id
|
||||
|
||||
return result
|
||||
except Exception:
|
||||
return (None, None)
|
||||
return {}
|
||||
|
||||
|
||||
async def fetch_google_email(
|
||||
@@ -306,11 +319,22 @@ async def enrich_auth_config(
|
||||
# Codex
|
||||
if provider_type == "codex":
|
||||
id_token = token_response.get("id_token")
|
||||
email, account_id = parse_codex_id_token(str(id_token) if id_token else None)
|
||||
if email:
|
||||
auth_config["email"] = email
|
||||
if account_id:
|
||||
auth_config["account_id"] = account_id
|
||||
logger.debug(
|
||||
"Codex enrich_auth_config: id_token_present={} token_keys={}",
|
||||
bool(id_token),
|
||||
list(token_response.keys()),
|
||||
)
|
||||
codex_info = parse_codex_id_token(str(id_token) if id_token else None)
|
||||
if codex_info:
|
||||
logger.debug("Codex parsed id_token fields: {}", list(codex_info.keys()))
|
||||
if codex_info.get("email"):
|
||||
auth_config["email"] = codex_info["email"]
|
||||
if codex_info.get("account_id"):
|
||||
auth_config["account_id"] = codex_info["account_id"]
|
||||
if codex_info.get("plan_type"):
|
||||
auth_config["plan_type"] = codex_info["plan_type"]
|
||||
if codex_info.get("user_id"):
|
||||
auth_config["user_id"] = codex_info["user_id"]
|
||||
return auth_config
|
||||
|
||||
# Gemini family (gemini_cli / antigravity)
|
||||
|
||||
@@ -1391,6 +1391,13 @@ class ProviderAPIKey(Base):
|
||||
model_include_patterns = Column(JSON, nullable=True) # 包含规则列表,空表示不过滤(包含所有)
|
||||
model_exclude_patterns = Column(JSON, nullable=True) # 排除规则列表,空表示不排除
|
||||
|
||||
# 上游元数据(由响应头解析器采集,如 Codex 额度信息)
|
||||
upstream_metadata = Column(JSON, nullable=True, default=dict)
|
||||
|
||||
# OAuth 失效状态(账号被封、授权撤销、刷新失败等)
|
||||
oauth_invalid_at = Column(DateTime(timezone=True), nullable=True) # 失效时间
|
||||
oauth_invalid_reason = Column(String(255), nullable=True) # 失效原因
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||
|
||||
@@ -154,9 +154,7 @@ class ProviderEndpointResponse(BaseModel):
|
||||
header_rules: list[HeaderRule] | None = Field(default=None, description="请求头规则列表")
|
||||
|
||||
# 请求体配置
|
||||
body_rules: list[BodyRule] | None = Field(
|
||||
default=None, description="请求体规则列表"
|
||||
)
|
||||
body_rules: list[BodyRule] | None = Field(default=None, description="请求体规则列表")
|
||||
|
||||
max_retries: int
|
||||
|
||||
@@ -514,7 +512,18 @@ class EndpointAPIKeyResponse(BaseModel):
|
||||
capabilities: dict[str, bool] | None = Field(default=None, description="Key 能力标签")
|
||||
|
||||
# OAuth 相关
|
||||
oauth_expires_at: int | None = Field(default=None, description="OAuth Token 过期时间(Unix 时间戳)")
|
||||
oauth_expires_at: int | None = Field(
|
||||
default=None, description="OAuth Token 过期时间(Unix 时间戳)"
|
||||
)
|
||||
oauth_email: str | None = Field(default=None, description="OAuth 账号邮箱")
|
||||
oauth_plan_type: str | None = Field(
|
||||
default=None, description="OAuth 账号套餐类型(如 free/plus/team/enterprise)"
|
||||
)
|
||||
oauth_account_id: str | None = Field(default=None, description="OAuth 账号 ID")
|
||||
oauth_invalid_at: int | None = Field(
|
||||
default=None, description="OAuth Token 失效时间(Unix 时间戳),如账号被封、授权撤销等"
|
||||
)
|
||||
oauth_invalid_reason: str | None = Field(default=None, description="OAuth Token 失效原因")
|
||||
|
||||
# 缓存与熔断配置
|
||||
cache_ttl_minutes: int = Field(default=5, description="缓存 TTL(分钟),0=禁用")
|
||||
@@ -576,6 +585,11 @@ class EndpointAPIKeyResponse(BaseModel):
|
||||
model_include_patterns: list[str] | None = Field(None, description="模型包含规则")
|
||||
model_exclude_patterns: list[str] | None = Field(None, description="模型排除规则")
|
||||
|
||||
# 上游元数据(由响应头采集,如 Codex 额度信息)
|
||||
upstream_metadata: dict[str, Any] | None = Field(
|
||||
None, description="上游元数据(如 Codex 额度信息)"
|
||||
)
|
||||
|
||||
# 时间戳
|
||||
last_used_at: datetime | None = None
|
||||
created_at: datetime
|
||||
@@ -701,7 +715,9 @@ class ProviderWithEndpointsSummary(BaseModel):
|
||||
# Provider 基本信息
|
||||
id: str
|
||||
name: str
|
||||
provider_type: str | None = Field(default=None, description="Provider 类型(custom/claude_code/codex/gemini_cli/antigravity)")
|
||||
provider_type: str | None = Field(
|
||||
default=None, description="Provider 类型(custom/claude_code/codex/gemini_cli/antigravity)"
|
||||
)
|
||||
description: str | None = None
|
||||
website: str | None = None
|
||||
provider_priority: int = Field(default=100, description="提供商优先级(数字越小越优先)")
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"""
|
||||
Codex upstream request compatibility helpers.
|
||||
|
||||
The Codex upstream (https://chatgpt.com/backend-api/codex) is largely compatible with the
|
||||
OpenAI Responses (/responses, aka "openai:cli") schema, but enforces some extra constraints.
|
||||
|
||||
CLIProxyAPI's reference implementation applies a small set of mutations before forwarding.
|
||||
We replicate the same mutations here to keep Aether's routing compatible when
|
||||
Provider.provider_type == "codex".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
_CODEX_REQUIRED_INCLUDE_ITEM = "reasoning.encrypted_content"
|
||||
|
||||
|
||||
def patch_openai_cli_request_for_codex(request_body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Mutate an OpenAI Responses (openai:cli) request into a Codex-compatible payload.
|
||||
|
||||
Notes (based on CLIProxyAPI translators):
|
||||
- `store` must be explicitly set to false.
|
||||
- `instructions` field must exist (Codex rejects missing instructions).
|
||||
- Codex rejects several generation params, so strip them.
|
||||
- Codex does not accept `system` role inside the `input` array.
|
||||
- Enable `parallel_tool_calls` and request encrypted reasoning content.
|
||||
"""
|
||||
if not isinstance(request_body, dict):
|
||||
return request_body
|
||||
|
||||
result: dict[str, Any] = dict(request_body)
|
||||
|
||||
# Required by Codex: explicitly disable storing.
|
||||
result["store"] = False
|
||||
|
||||
# Required by Codex: ensure instructions exists (can be empty).
|
||||
instructions = result.get("instructions")
|
||||
if instructions is None:
|
||||
result["instructions"] = ""
|
||||
elif not isinstance(instructions, str):
|
||||
result["instructions"] = str(instructions)
|
||||
|
||||
# Codex defaults/tooling expectations
|
||||
result["parallel_tool_calls"] = True
|
||||
|
||||
include_value = result.get("include")
|
||||
include: list[str] = []
|
||||
if isinstance(include_value, list):
|
||||
include = [v for v in include_value if isinstance(v, str) and v]
|
||||
if _CODEX_REQUIRED_INCLUDE_ITEM not in include:
|
||||
include.append(_CODEX_REQUIRED_INCLUDE_ITEM)
|
||||
result["include"] = include
|
||||
|
||||
# Codex Responses rejects token limit fields and some sampling params.
|
||||
for key in (
|
||||
"max_output_tokens",
|
||||
"max_completion_tokens",
|
||||
"max_tokens",
|
||||
"temperature",
|
||||
"top_p",
|
||||
"service_tier",
|
||||
):
|
||||
result.pop(key, None)
|
||||
|
||||
# Convert role "system" to "developer" in input array to comply with Codex API requirements.
|
||||
input_value = result.get("input")
|
||||
if isinstance(input_value, list):
|
||||
patched_input: list[Any] = []
|
||||
for item in input_value:
|
||||
if (
|
||||
isinstance(item, dict)
|
||||
and item.get("type") == "message"
|
||||
and item.get("role") == "system"
|
||||
):
|
||||
item = dict(item)
|
||||
item["role"] = "developer"
|
||||
patched_input.append(item)
|
||||
result["input"] = patched_input
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def maybe_patch_request_for_codex(
|
||||
*,
|
||||
provider_type: str | None,
|
||||
provider_api_format: str | None,
|
||||
request_body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Apply Codex compatibility patches only when the selected upstream is Codex and the
|
||||
endpoint uses the OpenAI Responses schema ("openai:cli").
|
||||
"""
|
||||
if str(provider_type or "").strip().lower() != "codex":
|
||||
return request_body
|
||||
if str(provider_api_format or "").strip().lower() != "openai:cli":
|
||||
return request_body
|
||||
return patch_openai_cli_request_for_codex(request_body)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"maybe_patch_request_for_codex",
|
||||
"patch_openai_cli_request_for_codex",
|
||||
]
|
||||
|
||||
150
src/services/provider/metadata_collectors/__init__.py
Normal file
150
src/services/provider/metadata_collectors/__init__.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
上游元数据采集器(MetadataCollector)
|
||||
|
||||
可扩展注册表模式:
|
||||
- 每个 Provider 类型可注册一个 MetadataCollector
|
||||
- 从响应头解析有价值的元数据(额度、限流等)
|
||||
- 解析结果存入 ProviderAPIKey.upstream_metadata
|
||||
|
||||
扩展方式:
|
||||
1. 创建新文件实现 MetadataCollector
|
||||
2. 在本文件底部注册
|
||||
"""
|
||||
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.logger import logger
|
||||
|
||||
# 节流:每个 key_id 至少间隔 _THROTTLE_SECONDS 秒才写入一次
|
||||
_THROTTLE_SECONDS = 30
|
||||
_last_write_ts: dict[str, float] = {}
|
||||
|
||||
|
||||
class MetadataCollector(ABC):
|
||||
"""元数据采集器基类"""
|
||||
|
||||
# 支持的 provider_type 列表(小写)
|
||||
PROVIDER_TYPES: ClassVar[list[str]] = []
|
||||
|
||||
@abstractmethod
|
||||
def parse_headers(self, headers: dict[str, str]) -> dict[str, Any] | None:
|
||||
"""解析响应头,返回结构化元数据。返回 None 表示无可用数据。"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class MetadataCollectorRegistry:
|
||||
"""元数据采集器注册表"""
|
||||
|
||||
_collectors: ClassVar[list[MetadataCollector]] = []
|
||||
_type_index: ClassVar[dict[str, MetadataCollector]] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, collector: MetadataCollector) -> None:
|
||||
cls._collectors.append(collector)
|
||||
for pt in collector.PROVIDER_TYPES:
|
||||
cls._type_index[pt.lower()] = collector
|
||||
logger.info(
|
||||
"[MetadataCollectorRegistry] 注册: {} -> {}",
|
||||
collector.__class__.__name__,
|
||||
collector.PROVIDER_TYPES,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def collect(cls, provider_type: str, headers: dict[str, str]) -> dict[str, Any] | None:
|
||||
"""根据 provider_type 查找采集器并解析响应头"""
|
||||
collector = cls._type_index.get(provider_type.lower())
|
||||
if collector is None:
|
||||
return None
|
||||
try:
|
||||
return collector.parse_headers(headers)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"[MetadataCollectorRegistry] {} 解析失败", collector.__class__.__name__
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
_initialized = False
|
||||
|
||||
|
||||
def _ensure_collectors_registered() -> None:
|
||||
"""惰性注册所有采集器(首次调用时执行,避免循环导入)"""
|
||||
global _initialized
|
||||
if _initialized:
|
||||
return
|
||||
_initialized = True
|
||||
|
||||
# 延迟导入,避免模块加载时的循环依赖
|
||||
from src.services.provider.metadata_collectors.codex import CodexMetadataCollector
|
||||
|
||||
MetadataCollectorRegistry.register(CodexMetadataCollector())
|
||||
|
||||
|
||||
def collect_and_save_upstream_metadata(
|
||||
db: Session,
|
||||
*,
|
||||
provider_type: str,
|
||||
key_id: str,
|
||||
response_headers: dict[str, str],
|
||||
request_id: str,
|
||||
) -> None:
|
||||
"""采集上游元数据并更新 ProviderAPIKey.upstream_metadata(带节流)
|
||||
|
||||
每个 key_id 至少间隔 _THROTTLE_SECONDS 秒才执行一次数据库写入,
|
||||
避免高并发时频繁更新同一行。
|
||||
|
||||
Args:
|
||||
db: 数据库 Session
|
||||
provider_type: Provider 类型(如 "codex")
|
||||
key_id: ProviderAPIKey.id
|
||||
response_headers: 上游响应头
|
||||
request_id: 请求 ID(用于日志)
|
||||
"""
|
||||
if not provider_type or not key_id or not response_headers:
|
||||
return
|
||||
|
||||
# 确保采集器已注册
|
||||
_ensure_collectors_registered()
|
||||
|
||||
# 节流检查
|
||||
now = time.monotonic()
|
||||
last_ts = _last_write_ts.get(key_id, 0.0)
|
||||
if now - last_ts < _THROTTLE_SECONDS:
|
||||
return
|
||||
|
||||
try:
|
||||
metadata = MetadataCollectorRegistry.collect(provider_type, response_headers)
|
||||
if metadata is None:
|
||||
return
|
||||
|
||||
from src.models.database import ProviderAPIKey
|
||||
|
||||
key = db.query(ProviderAPIKey).filter(ProviderAPIKey.id == key_id).first()
|
||||
if key is None:
|
||||
return
|
||||
|
||||
key.upstream_metadata = metadata
|
||||
db.commit()
|
||||
_last_write_ts[key_id] = now
|
||||
logger.debug(
|
||||
"[{}] 已更新 ProviderAPIKey({}) upstream_metadata",
|
||||
request_id,
|
||||
key_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[{}] 采集上游元数据失败", request_id)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MetadataCollector",
|
||||
"MetadataCollectorRegistry",
|
||||
"collect_and_save_upstream_metadata",
|
||||
]
|
||||
95
src/services/provider/metadata_collectors/codex.py
Normal file
95
src/services/provider/metadata_collectors/codex.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Codex Provider 元数据采集器
|
||||
|
||||
从响应头解析 Codex 额度/限流信息:
|
||||
- x-codex-plan-type: 套餐类型
|
||||
- x-codex-primary-*: 主限额窗口(通常 7 天)
|
||||
- x-codex-secondary-*: 次级限额窗口(通常 5 小时)
|
||||
- x-codex-credits-*: 积分信息
|
||||
"""
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from src.services.provider.metadata_collectors import MetadataCollector
|
||||
|
||||
|
||||
def _safe_float(value: str | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _safe_int(value: str | None) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(float(value))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _safe_bool(value: str | None) -> bool | None:
|
||||
if value is None:
|
||||
return None
|
||||
return value.lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
class CodexMetadataCollector(MetadataCollector):
|
||||
"""Codex 额度/限流元数据采集器"""
|
||||
|
||||
# 支持 codex 类型,通过响应头判断是否是 Codex
|
||||
PROVIDER_TYPES: ClassVar[list[str]] = ["codex"]
|
||||
|
||||
def parse_headers(self, headers: dict[str, str]) -> dict[str, Any] | None:
|
||||
# 大小写不敏感查找
|
||||
lower_headers = {k.lower(): v for k, v in headers.items()}
|
||||
|
||||
plan_type = lower_headers.get("x-codex-plan-type")
|
||||
if plan_type is None:
|
||||
# 没有 Codex 特征头,跳过
|
||||
return None
|
||||
|
||||
result: dict[str, Any] = {"plan_type": plan_type}
|
||||
|
||||
# 主限额窗口(7 天)
|
||||
primary_used = _safe_float(lower_headers.get("x-codex-primary-used-percent"))
|
||||
if primary_used is not None:
|
||||
result["primary_used_percent"] = primary_used
|
||||
primary_reset_seconds = _safe_int(lower_headers.get("x-codex-primary-reset-after-seconds"))
|
||||
if primary_reset_seconds is not None:
|
||||
result["primary_reset_seconds"] = primary_reset_seconds
|
||||
primary_reset_at = _safe_int(lower_headers.get("x-codex-primary-reset-at"))
|
||||
if primary_reset_at is not None:
|
||||
result["primary_reset_at"] = primary_reset_at
|
||||
primary_window = _safe_int(lower_headers.get("x-codex-primary-window-minutes"))
|
||||
if primary_window is not None:
|
||||
result["primary_window_minutes"] = primary_window
|
||||
|
||||
# 次级限额窗口(5 小时)
|
||||
secondary_used = _safe_float(lower_headers.get("x-codex-secondary-used-percent"))
|
||||
if secondary_used is not None:
|
||||
result["secondary_used_percent"] = secondary_used
|
||||
secondary_reset_seconds = _safe_int(
|
||||
lower_headers.get("x-codex-secondary-reset-after-seconds")
|
||||
)
|
||||
if secondary_reset_seconds is not None:
|
||||
result["secondary_reset_seconds"] = secondary_reset_seconds
|
||||
secondary_reset_at = _safe_int(lower_headers.get("x-codex-secondary-reset-at"))
|
||||
if secondary_reset_at is not None:
|
||||
result["secondary_reset_at"] = secondary_reset_at
|
||||
secondary_window = _safe_int(lower_headers.get("x-codex-secondary-window-minutes"))
|
||||
if secondary_window is not None:
|
||||
result["secondary_window_minutes"] = secondary_window
|
||||
|
||||
# 积分信息
|
||||
has_credits = _safe_bool(lower_headers.get("x-codex-credits-has-credits"))
|
||||
if has_credits is not None:
|
||||
result["has_credits"] = has_credits
|
||||
credits_balance = _safe_float(lower_headers.get("x-codex-credits-balance"))
|
||||
if credits_balance is not None:
|
||||
result["credits_balance"] = credits_balance
|
||||
|
||||
return result
|
||||
@@ -189,6 +189,11 @@ class SystemConfigService:
|
||||
"value": "Aether",
|
||||
"description": "发件人名称",
|
||||
},
|
||||
# OAuth Token 刷新配置
|
||||
"enable_oauth_token_refresh": {
|
||||
"value": True,
|
||||
"description": "是否启用 OAuth Token 自动刷新任务,主动刷新即将过期的 OAuth token",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- 连接池监控:定期检查数据库连接池状态
|
||||
- Pending 状态清理:清理异常的 Pending 状态记录
|
||||
- Gemini 文件映射清理:清理过期的 Gemini 文件→Key 映射
|
||||
- OAuth Token 刷新:主动刷新即将过期的 OAuth token
|
||||
|
||||
使用 APScheduler 进行任务调度,支持时区配置。
|
||||
"""
|
||||
@@ -38,12 +39,26 @@ class MaintenanceScheduler:
|
||||
|
||||
# 签到任务的 job_id
|
||||
CHECKIN_JOB_ID = "provider_checkin"
|
||||
# OAuth 刷新任务的 job_id
|
||||
OAUTH_REFRESH_JOB_ID = "oauth_token_refresh"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.running = False
|
||||
self._interval_tasks = []
|
||||
self._stats_aggregation_lock = asyncio.Lock()
|
||||
|
||||
def trigger_oauth_refresh_check(self) -> None:
|
||||
"""
|
||||
触发 OAuth Token 刷新检查
|
||||
|
||||
当新增 OAuth Key 时调用此方法,重新调度刷新任务。
|
||||
会取消当前的调度,并立即重新计算下次执行时间。
|
||||
"""
|
||||
if not self.running:
|
||||
return
|
||||
|
||||
asyncio.create_task(self._schedule_next_oauth_refresh())
|
||||
|
||||
def _get_checkin_time(self) -> tuple[int, int]:
|
||||
"""获取签到任务的执行时间
|
||||
|
||||
@@ -203,6 +218,11 @@ class MaintenanceScheduler:
|
||||
name="Provider签到",
|
||||
)
|
||||
|
||||
# OAuth Token 刷新任务 - 动态调度
|
||||
# 根据最近即将过期的 token 时间来调度,避免固定间隔频繁查询
|
||||
# 启动时先执行一次,计算下次执行时间
|
||||
asyncio.create_task(self._schedule_next_oauth_refresh())
|
||||
|
||||
# 启动时执行一次初始化任务
|
||||
asyncio.create_task(self._run_startup_tasks())
|
||||
|
||||
@@ -274,6 +294,152 @@ class MaintenanceScheduler:
|
||||
"""Provider 签到任务(定时调用)"""
|
||||
await self._perform_provider_checkin()
|
||||
|
||||
async def _scheduled_oauth_token_refresh(self) -> None:
|
||||
"""OAuth Token 刷新任务(定时调用)"""
|
||||
await self._perform_oauth_token_refresh()
|
||||
# 执行完成后,调度下次执行
|
||||
await self._schedule_next_oauth_refresh()
|
||||
|
||||
async def _schedule_next_oauth_refresh(self) -> None:
|
||||
"""
|
||||
动态调度下次 OAuth Token 刷新任务
|
||||
|
||||
策略:
|
||||
- 查询所有 OAuth Key 的 expires_at
|
||||
- 找到最近即将过期的 token(在 refresh_threshold 内)
|
||||
- 设置下次执行时间为:最近过期时间 - 提前量(如提前 1 小时刷新)
|
||||
- 如果没有即将过期的 token,设置默认间隔(如 6 小时后)
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
|
||||
from src.core.crypto import crypto_service
|
||||
from src.models.database import ProviderAPIKey
|
||||
|
||||
# 延迟启动,等待系统初始化
|
||||
await asyncio.sleep(5)
|
||||
|
||||
scheduler = get_scheduler()
|
||||
job_id = "oauth_token_refresh"
|
||||
|
||||
try:
|
||||
db = create_session()
|
||||
try:
|
||||
# 检查配置开关
|
||||
if not SystemConfigService.get_config(db, "enable_oauth_token_refresh", True):
|
||||
logger.info("OAuth Token 自动刷新已禁用,不调度任务")
|
||||
return
|
||||
# 查找所有活跃的 OAuth 类型 Key
|
||||
oauth_keys = (
|
||||
db.query(ProviderAPIKey)
|
||||
.filter(
|
||||
ProviderAPIKey.auth_type == "oauth",
|
||||
ProviderAPIKey.is_active == True, # noqa: E712
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not oauth_keys:
|
||||
# 没有 OAuth Key,6 小时后再检查
|
||||
next_run = datetime.now(timezone.utc) + timedelta(hours=6)
|
||||
scheduler.add_date_job(
|
||||
self._scheduled_oauth_token_refresh,
|
||||
run_date=next_run,
|
||||
job_id=job_id,
|
||||
name="OAuth Token刷新",
|
||||
)
|
||||
logger.info("没有 OAuth Key,下次检查时间: {}", next_run.isoformat())
|
||||
return
|
||||
|
||||
now = int(time.time())
|
||||
# 24 小时内过期的都需要刷新(含提前量)
|
||||
refresh_window = 24 * 3600
|
||||
# 提前 1 小时执行刷新
|
||||
refresh_advance = 1 * 3600
|
||||
refresh_threshold_seconds = refresh_window + refresh_advance
|
||||
refresh_threshold = now + refresh_threshold_seconds
|
||||
|
||||
earliest_expires_at: int | None = None
|
||||
|
||||
for key in oauth_keys:
|
||||
if not key.auth_config:
|
||||
continue
|
||||
|
||||
try:
|
||||
decrypted_config = crypto_service.decrypt(key.auth_config)
|
||||
token_meta = json.loads(decrypted_config)
|
||||
expires_at = token_meta.get("expires_at")
|
||||
|
||||
if expires_at is None:
|
||||
continue
|
||||
|
||||
expires_at_int = int(expires_at)
|
||||
|
||||
# 已经过期或在阈值内,需要立即刷新
|
||||
if expires_at_int <= refresh_threshold:
|
||||
# 立即执行
|
||||
next_run = datetime.now(timezone.utc) + timedelta(seconds=10)
|
||||
scheduler.add_date_job(
|
||||
self._scheduled_oauth_token_refresh,
|
||||
run_date=next_run,
|
||||
job_id=job_id,
|
||||
name="OAuth Token刷新",
|
||||
)
|
||||
logger.info(
|
||||
"发现即将过期的 OAuth Token,立即执行刷新: {}",
|
||||
next_run.isoformat(),
|
||||
)
|
||||
return
|
||||
|
||||
# 记录最近的过期时间
|
||||
if earliest_expires_at is None or expires_at_int < earliest_expires_at:
|
||||
earliest_expires_at = expires_at_int
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 计算下次执行时间
|
||||
if earliest_expires_at is not None:
|
||||
# 在最近过期时间前 24 小时 + 提前量执行
|
||||
next_run_ts = earliest_expires_at - refresh_threshold_seconds
|
||||
# 确保不会是过去的时间
|
||||
if next_run_ts <= now:
|
||||
next_run_ts = now + 60 # 1 分钟后
|
||||
next_run = datetime.fromtimestamp(next_run_ts, tz=timezone.utc)
|
||||
else:
|
||||
# 没有有效的过期时间,6 小时后再检查
|
||||
next_run = datetime.now(timezone.utc) + timedelta(hours=6)
|
||||
|
||||
# 限制最大间隔为 24 小时
|
||||
max_next_run = datetime.now(timezone.utc) + timedelta(hours=24)
|
||||
if next_run > max_next_run:
|
||||
next_run = max_next_run
|
||||
|
||||
scheduler.add_date_job(
|
||||
self._scheduled_oauth_token_refresh,
|
||||
run_date=next_run,
|
||||
job_id=job_id,
|
||||
name="OAuth Token刷新",
|
||||
)
|
||||
logger.info("OAuth Token 刷新任务已调度,下次执行时间: {}", next_run.isoformat())
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("调度 OAuth Token 刷新任务失败: {}", e)
|
||||
# 出错时 1 小时后重试
|
||||
next_run = datetime.now(timezone.utc) + timedelta(hours=1)
|
||||
try:
|
||||
scheduler.add_date_job(
|
||||
self._scheduled_oauth_token_refresh,
|
||||
run_date=next_run,
|
||||
job_id=job_id,
|
||||
name="OAuth Token刷新",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ========== 实际任务实现 ==========
|
||||
|
||||
async def _perform_stats_aggregation(self, backfill: bool = False) -> None:
|
||||
@@ -1031,6 +1197,294 @@ class MaintenanceScheduler:
|
||||
|
||||
return total_deleted
|
||||
|
||||
async def _perform_oauth_token_refresh(self) -> None:
|
||||
"""
|
||||
主动刷新即将过期的 OAuth token
|
||||
|
||||
策略:
|
||||
- 查找所有 auth_type='oauth' 且 is_active=True 的 Key
|
||||
- 检查 auth_config 中的 expires_at,如果在 24 小时内过期则刷新
|
||||
- 使用 refresh_token 换取新的 access_token
|
||||
- 更新数据库中的 token 信息
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.provider_oauth_utils import enrich_auth_config, post_oauth_token
|
||||
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
|
||||
from src.core.provider_templates.types import ProviderType
|
||||
from src.models.database import ProviderAPIKey
|
||||
|
||||
# 检查配置开关
|
||||
check_db = create_session()
|
||||
try:
|
||||
if not SystemConfigService.get_config(check_db, "enable_oauth_token_refresh", True):
|
||||
logger.info("OAuth Token 自动刷新已禁用,跳过任务")
|
||||
return
|
||||
finally:
|
||||
check_db.close()
|
||||
|
||||
logger.info("开始执行 OAuth Token 刷新任务...")
|
||||
|
||||
db = create_session()
|
||||
refreshed_count = 0
|
||||
failed_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
try:
|
||||
# 查找所有活跃的 OAuth 类型 Key
|
||||
oauth_keys = (
|
||||
db.query(ProviderAPIKey)
|
||||
.filter(
|
||||
ProviderAPIKey.auth_type == "oauth",
|
||||
ProviderAPIKey.is_active == True, # noqa: E712
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not oauth_keys:
|
||||
logger.info("没有找到需要刷新的 OAuth Key")
|
||||
return
|
||||
|
||||
logger.info("找到 {} 个 OAuth Key,开始检查过期状态...", len(oauth_keys))
|
||||
|
||||
now = int(time.time())
|
||||
# 24 小时内过期的都刷新(含提前量)
|
||||
refresh_window = 24 * 3600
|
||||
# 提前 1 小时执行刷新
|
||||
refresh_advance = 1 * 3600
|
||||
refresh_threshold = now + refresh_window + refresh_advance
|
||||
|
||||
for key in oauth_keys:
|
||||
try:
|
||||
# 解密 auth_config
|
||||
if not key.auth_config:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
decrypted_config = crypto_service.decrypt(key.auth_config)
|
||||
token_meta = json.loads(decrypted_config)
|
||||
except Exception:
|
||||
logger.warning("Key {} auth_config 解密失败,跳过", key.id)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
expires_at = token_meta.get("expires_at")
|
||||
refresh_token = token_meta.get("refresh_token")
|
||||
provider_type = str(token_meta.get("provider_type") or "")
|
||||
|
||||
# 检查是否需要刷新
|
||||
if expires_at is None:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
expires_at_int = int(expires_at)
|
||||
except (ValueError, TypeError):
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
if expires_at_int > refresh_threshold:
|
||||
# 还没到刷新时间
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
if not refresh_token or not provider_type:
|
||||
logger.warning(
|
||||
"Key {} 缺少 refresh_token 或 provider_type,无法刷新", key.id
|
||||
)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# 获取 provider 模板
|
||||
try:
|
||||
provider_type_enum = ProviderType(provider_type)
|
||||
except ValueError:
|
||||
logger.warning("Key {} 未知的 provider_type: {}", key.id, provider_type)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
template = FIXED_PROVIDERS.get(provider_type_enum)
|
||||
if not template or not template.oauth:
|
||||
logger.warning("Key {} provider {} 不支持 OAuth", key.id, provider_type)
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# 获取代理配置
|
||||
proxy_config = None
|
||||
if key.provider and key.provider.endpoints:
|
||||
for endpoint in key.provider.endpoints:
|
||||
if endpoint.proxy:
|
||||
proxy_config = endpoint.proxy
|
||||
break
|
||||
|
||||
# 执行刷新
|
||||
token_url = template.oauth.token_url
|
||||
is_json = "anthropic.com" in token_url
|
||||
|
||||
if is_json:
|
||||
body = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": template.oauth.client_id,
|
||||
"refresh_token": str(refresh_token),
|
||||
}
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
data = None
|
||||
json_body = body
|
||||
else:
|
||||
form = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": template.oauth.client_id,
|
||||
"refresh_token": str(refresh_token),
|
||||
}
|
||||
if template.oauth.client_secret:
|
||||
form["client_secret"] = template.oauth.client_secret
|
||||
headers = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
data = form
|
||||
json_body = None
|
||||
|
||||
logger.info(
|
||||
"刷新 Key {} ({}) 的 OAuth token,当前过期时间: {}",
|
||||
key.id,
|
||||
key.name,
|
||||
datetime.fromtimestamp(expires_at_int, tz=timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
resp = await post_oauth_token(
|
||||
provider_type=provider_type,
|
||||
token_url=token_url,
|
||||
headers=headers,
|
||||
data=data,
|
||||
json_body=json_body,
|
||||
proxy_config=proxy_config,
|
||||
timeout_seconds=30.0,
|
||||
)
|
||||
|
||||
if 200 <= resp.status_code < 300:
|
||||
token = resp.json()
|
||||
access_token = str(token.get("access_token") or "")
|
||||
new_refresh_token = str(token.get("refresh_token") or "")
|
||||
expires_in = token.get("expires_in")
|
||||
new_expires_at = None
|
||||
|
||||
try:
|
||||
if expires_in is not None:
|
||||
new_expires_at = int(time.time()) + int(expires_in)
|
||||
except Exception:
|
||||
new_expires_at = None
|
||||
|
||||
if access_token:
|
||||
# 更新 token_meta
|
||||
token_meta["token_type"] = token.get("token_type")
|
||||
if new_refresh_token:
|
||||
token_meta["refresh_token"] = new_refresh_token
|
||||
token_meta["expires_at"] = new_expires_at
|
||||
token_meta["scope"] = token.get("scope")
|
||||
token_meta["updated_at"] = int(time.time())
|
||||
|
||||
# 提取额外信息
|
||||
token_meta = await enrich_auth_config(
|
||||
provider_type=provider_type,
|
||||
auth_config=token_meta,
|
||||
token_response=token,
|
||||
access_token=access_token,
|
||||
proxy_config=proxy_config,
|
||||
)
|
||||
|
||||
# 更新数据库
|
||||
encrypted_token = crypto_service.encrypt(access_token)
|
||||
encrypted_config = crypto_service.encrypt(json.dumps(token_meta))
|
||||
|
||||
key.api_key = encrypted_token
|
||||
key.auth_config = encrypted_config
|
||||
# 刷新成功,清除失效标记
|
||||
key.oauth_invalid_at = None
|
||||
key.oauth_invalid_reason = None
|
||||
db.commit()
|
||||
|
||||
refreshed_count += 1
|
||||
new_expires_str = (
|
||||
datetime.fromtimestamp(new_expires_at, tz=timezone.utc).isoformat()
|
||||
if new_expires_at
|
||||
else "unknown"
|
||||
)
|
||||
logger.info(
|
||||
"Key {} ({}) OAuth token 刷新成功,新过期时间: {}",
|
||||
key.id,
|
||||
key.name,
|
||||
new_expires_str,
|
||||
)
|
||||
else:
|
||||
failed_count += 1
|
||||
logger.warning(
|
||||
"Key {} ({}) 刷新响应中没有 access_token", key.id, key.name
|
||||
)
|
||||
else:
|
||||
failed_count += 1
|
||||
# 解析错误原因
|
||||
error_reason = "HTTP {}".format(resp.status_code)
|
||||
try:
|
||||
error_body = resp.json()
|
||||
if "error" in error_body:
|
||||
error_reason = str(
|
||||
error_body.get("error_description") or error_body.get("error")
|
||||
)
|
||||
except Exception:
|
||||
error_reason = (
|
||||
resp.text[:100] if resp.text else "HTTP {}".format(resp.status_code)
|
||||
)
|
||||
|
||||
# 标记为失效(400/401/403 通常表示永久性错误)
|
||||
if resp.status_code in (400, 401, 403):
|
||||
key.oauth_invalid_at = datetime.now(timezone.utc)
|
||||
key.oauth_invalid_reason = error_reason
|
||||
db.commit()
|
||||
logger.warning(
|
||||
"Key {} ({}) OAuth token 刷新失败,已标记为失效: {}",
|
||||
key.id,
|
||||
key.name,
|
||||
error_reason,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Key {} ({}) OAuth token 刷新失败,状态码: {},响应: {}",
|
||||
key.id,
|
||||
key.name,
|
||||
resp.status_code,
|
||||
resp.text[:200],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logger.exception("Key {} OAuth token 刷新出错: {}", key.id, e)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 避免请求过于频繁
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("OAuth Token 刷新任务执行出错: {}", e)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
logger.info(
|
||||
"OAuth Token 刷新任务完成: 刷新 {} 个,失败 {} 个,跳过 {} 个",
|
||||
refreshed_count,
|
||||
failed_count,
|
||||
skipped_count,
|
||||
)
|
||||
|
||||
|
||||
# 全局单例
|
||||
_maintenance_scheduler = None
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import Any, Callable
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.date import DateTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
from src.core.logger import logger
|
||||
@@ -136,6 +137,40 @@ class TaskScheduler:
|
||||
|
||||
logger.info(f"已注册间隔任务: {display_name}, 执行间隔: {interval_desc}")
|
||||
|
||||
def add_date_job(
|
||||
self,
|
||||
func: Callable[..., Any],
|
||||
run_date: datetime,
|
||||
job_id: str | None = None,
|
||||
name: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
"""
|
||||
添加一次性定时任务(在指定时间执行一次)
|
||||
|
||||
Args:
|
||||
func: 要执行的函数
|
||||
run_date: 执行时间(datetime 对象)
|
||||
job_id: 任务ID
|
||||
name: 任务名称(用于日志)
|
||||
**kwargs: 传递给任务函数的参数
|
||||
"""
|
||||
trigger = DateTrigger(run_date=run_date)
|
||||
|
||||
job_id = job_id or func.__name__
|
||||
display_name = name or job_id
|
||||
|
||||
self.scheduler.add_job(
|
||||
func,
|
||||
trigger,
|
||||
id=job_id,
|
||||
name=display_name,
|
||||
replace_existing=True,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
logger.info("已注册一次性任务: {}, 执行时间: {}", display_name, run_date.isoformat())
|
||||
|
||||
def start(self) -> Any:
|
||||
"""启动调度器"""
|
||||
if self._started:
|
||||
|
||||
Reference in New Issue
Block a user