feat: 添加格式转换追踪、模型过滤规则和 Provider 超时配置

1. Usage 格式转换追踪
   - 新增 endpoint_api_format 和 has_format_conversion 字段
   - 在用量记录中显示格式转换信息(请求格式 → 端点格式)
   - 兼容历史数据的回填逻辑

2. Provider API Key 模型过滤规则
   - 新增 model_include_patterns 和 model_exclude_patterns 字段
   - 支持 * 和 ? 通配符,不区分大小写
   - 自动获取模型时应用过滤规则

3. Provider 超时配置
   - 新增 stream_first_byte_timeout 和 request_timeout 字段
   - 支持每个 Provider 单独配置超时时间
   - 优先使用 Provider 配置,否则回退到全局配置

Close #122, Close #123

Co-Authored-By: AAEE86 <33052466+AAEE86@users.noreply.github.com>
This commit is contained in:
fawney19
2026-01-28 00:04:47 +08:00
parent 3c0bf5fdae
commit c732a03263
23 changed files with 652 additions and 97 deletions

View File

@@ -0,0 +1,114 @@
"""add_format_conversion_tracking_and_model_filter_patterns_and_provider_timeout
Revision ID: f7c8d9e0a1b2
Revises: 4b4c7b0df1a2
Create Date: 2026-01-27 10:00:00+00:00
Changes:
1. usage 表: 添加 endpoint_api_format 和 has_format_conversion 字段
2. provider_api_keys 表: 添加 model_include_patterns 和 model_exclude_patterns 字段
- 支持通配符规则自动过滤从上游获取的模型列表
- 包含规则和排除规则(支持 * 和 ? 通配符)
3. providers 表: 添加 stream_first_byte_timeout 和 request_timeout 字段
- 允许每个提供商单独配置超时时间
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = "f7c8d9e0a1b2"
down_revision = "4b4c7b0df1a2"
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
# === usage 表: 格式转换追踪 ===
if table_exists("usage"):
# 添加 endpoint_api_format 字段(端点原生 API 格式)
if not column_exists("usage", "endpoint_api_format"):
op.add_column(
"usage",
sa.Column("endpoint_api_format", sa.String(50), nullable=True),
)
# 添加 has_format_conversion 字段(是否发生了格式转换)
if not column_exists("usage", "has_format_conversion"):
op.add_column(
"usage",
sa.Column("has_format_conversion", sa.Boolean(), nullable=True, server_default="false"),
)
# === provider_api_keys 表: 模型过滤规则 ===
if table_exists("provider_api_keys"):
# 添加 model_include_patterns 字段(包含规则,支持 * 和 ? 通配符)
if not column_exists("provider_api_keys", "model_include_patterns"):
op.add_column(
"provider_api_keys",
sa.Column("model_include_patterns", sa.JSON(), nullable=True),
)
# 添加 model_exclude_patterns 字段(排除规则,支持 * 和 ? 通配符)
if not column_exists("provider_api_keys", "model_exclude_patterns"):
op.add_column(
"provider_api_keys",
sa.Column("model_exclude_patterns", sa.JSON(), nullable=True),
)
# === providers 表: 超时配置 ===
if table_exists("providers"):
# 添加 stream_first_byte_timeout 字段(流式请求首字节超时)
if not column_exists("providers", "stream_first_byte_timeout"):
op.add_column(
"providers",
sa.Column("stream_first_byte_timeout", sa.Float(), nullable=True),
)
# 添加 request_timeout 字段(非流式请求整体超时)
if not column_exists("providers", "request_timeout"):
op.add_column(
"providers",
sa.Column("request_timeout", sa.Float(), nullable=True),
)
def downgrade() -> None:
# === providers 表: 移除超时配置 ===
if table_exists("providers"):
if column_exists("providers", "request_timeout"):
op.drop_column("providers", "request_timeout")
if column_exists("providers", "stream_first_byte_timeout"):
op.drop_column("providers", "stream_first_byte_timeout")
# === provider_api_keys 表: 移除模型过滤规则 ===
if table_exists("provider_api_keys"):
if column_exists("provider_api_keys", "model_exclude_patterns"):
op.drop_column("provider_api_keys", "model_exclude_patterns")
if column_exists("provider_api_keys", "model_include_patterns"):
op.drop_column("provider_api_keys", "model_include_patterns")
# === usage 表: 移除格式转换追踪 ===
if table_exists("usage"):
if column_exists("usage", "has_format_conversion"):
op.drop_column("usage", "has_format_conversion")
if column_exists("usage", "endpoint_api_format"):
op.drop_column("usage", "endpoint_api_format")

View File

@@ -98,6 +98,8 @@ export async function addProviderKey(
capabilities?: Record<string, boolean> capabilities?: Record<string, boolean>
note?: string note?: string
auto_fetch_models?: boolean // 是否启用自动获取模型 auto_fetch_models?: boolean // 是否启用自动获取模型
model_include_patterns?: string[] // 模型包含规则
model_exclude_patterns?: string[] // 模型排除规则
} }
): Promise<EndpointAPIKey> { ): Promise<EndpointAPIKey> {
const response = await client.post(`/api/admin/endpoints/providers/${providerId}/keys`, data) const response = await client.post(`/api/admin/endpoints/providers/${providerId}/keys`, data)
@@ -125,6 +127,8 @@ export async function updateProviderKey(
is_active: boolean is_active: boolean
note: string note: string
auto_fetch_models: boolean // 是否启用自动获取模型 auto_fetch_models: boolean // 是否启用自动获取模型
model_include_patterns: string[] // 模型包含规则
model_exclude_patterns: string[] // 模型排除规则
}> }>
): Promise<EndpointAPIKey> { ): Promise<EndpointAPIKey> {
const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data) const response = await client.put(`/api/admin/endpoints/keys/${keyId}`, data)

View File

@@ -189,6 +189,9 @@ export interface EndpointAPIKey {
last_models_fetch_at?: string // 最后获取模型时间 last_models_fetch_at?: string // 最后获取模型时间
last_models_fetch_error?: string // 最后获取模型错误信息 last_models_fetch_error?: string // 最后获取模型错误信息
locked_models?: string[] // 被锁定的模型列表 locked_models?: string[] // 被锁定的模型列表
// 模型过滤规则(仅当 auto_fetch_models=true 时生效)
model_include_patterns?: string[] // 模型包含规则(支持 * 和 ? 通配符)
model_exclude_patterns?: string[] // 模型排除规则(支持 * 和 ? 通配符)
} }
// 按格式的健康度数据 // 按格式的健康度数据
@@ -227,6 +230,9 @@ export interface EndpointAPIKeyUpdate {
is_active?: boolean is_active?: boolean
auto_fetch_models?: boolean // 是否启用自动获取模型 auto_fetch_models?: boolean // 是否启用自动获取模型
locked_models?: string[] // 被锁定的模型列表 locked_models?: string[] // 被锁定的模型列表
// 模型过滤规则(仅当 auto_fetch_models=true 时生效)
model_include_patterns?: string[] // 模型包含规则(支持 * 和 ? 通配符)
model_exclude_patterns?: string[] // 模型排除规则(支持 * 和 ? 通配符)
} }
export interface EndpointHealthDetail { export interface EndpointHealthDetail {
@@ -312,6 +318,9 @@ export interface ProviderWithEndpointsSummary {
// 请求配置(从 Endpoint 迁移) // 请求配置(从 Endpoint 迁移)
max_retries?: number // 最大重试次数 max_retries?: number // 最大重试次数
proxy?: ProxyConfig | null // 代理配置 proxy?: ProxyConfig | null // 代理配置
// 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout?: number // 流式请求首字节超时
request_timeout?: number // 非流式请求整体超时
is_active: boolean is_active: boolean
total_endpoints: number total_endpoints: number
active_endpoints: number active_endpoints: number

View File

@@ -754,14 +754,12 @@ watch(() => props.open, async (open) => {
// 加载全局模型 // 加载全局模型
await loadGlobalModels() await loadGlobalModels()
// 自动获取模式下,获取上游模型并刷新(保留锁定的模型 // 自动获取模式下,获取上游模型用于显示(但选中状态使用已保存的 allowed_models
if (props.apiKey.auto_fetch_models) { if (props.apiKey.auto_fetch_models) {
await fetchUpstreamModels() await fetchUpstreamModels()
// 锁定的模型 + 最新上游模型(去重) // 注意:不再将所有上游模型自动标记为选中
const newSelected = new Set(lockedModels.value) // 因为后端有过滤规则,实际保存的 allowed_models 是过滤后的结果
upstreamModelNames.value.forEach(m => newSelected.add(m)) // selectedModels 已在上面从 props.apiKey.allowed_models 初始化
selectedModels.value = Array.from(newSelected)
initialSelectedModels.value = [...selectedModels.value]
} }
// 提取自定义模型(不在全局模型和上游模型中的) // 提取自定义模型(不在全局模型和上游模型中的)

View File

@@ -211,20 +211,48 @@
</div> </div>
<!-- 自动获取模型 --> <!-- 自动获取模型 -->
<div class="flex items-center justify-between py-2 px-3 rounded-md border border-border/60 bg-muted/30"> <div class="space-y-3 py-2 px-3 rounded-md border border-border/60 bg-muted/30">
<div class="space-y-0.5"> <div class="flex items-center justify-between">
<Label class="text-sm font-medium">自动获取上游可用模型</Label> <div class="space-y-0.5">
<Label class="text-sm font-medium">自动获取上游可用模型</Label>
<p class="text-xs text-muted-foreground">
定时更新上游模型, 配合模型映射使用
</p>
<p
v-if="showAutoFetchWarning"
class="text-xs text-amber-600 dark:text-amber-400"
>
已配置的模型权限将在下次获取时被覆盖
</p>
</div>
<Switch v-model="form.auto_fetch_models" />
</div>
<!-- 模型过滤规则(仅当开启自动获取时显示) -->
<div
v-if="form.auto_fetch_models"
class="space-y-2 pt-2 border-t border-border/40"
>
<div>
<Label class="text-xs">包含规则</Label>
<Input
v-model="form.model_include_patterns_text"
placeholder="gpt-*, claude-*, 留空包含全部"
class="h-8 text-sm"
/>
</div>
<div>
<Label class="text-xs">排除规则</Label>
<Input
v-model="form.model_exclude_patterns_text"
placeholder="*-preview, *-beta"
class="h-8 text-sm"
/>
</div>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
定时更新上游模型, 配合模型映射使用 逗号分隔,支持 * ? 通配符,不区分大小写
</p>
<p
v-if="showAutoFetchWarning"
class="text-xs text-amber-600 dark:text-amber-400"
>
已配置的模型权限将在下次获取时被覆盖
</p> </p>
</div> </div>
<Switch v-model="form.auto_fetch_models" />
</div> </div>
</form> </form>
@@ -335,7 +363,9 @@ const form = ref({
note: '', note: '',
is_active: true, is_active: true,
capabilities: {} as Record<string, boolean>, capabilities: {} as Record<string, boolean>,
auto_fetch_models: false auto_fetch_models: false,
model_include_patterns_text: '', // 包含规则文本(逗号分隔)
model_exclude_patterns_text: '' // 排除规则文本(逗号分隔)
}) })
// 加载能力列表 // 加载能力列表
@@ -419,7 +449,9 @@ function resetForm() {
note: '', note: '',
is_active: true, is_active: true,
capabilities: {}, capabilities: {},
auto_fetch_models: false auto_fetch_models: false,
model_include_patterns_text: '',
model_exclude_patterns_text: ''
} }
} }
@@ -449,7 +481,9 @@ function loadKeyData() {
note: props.editingKey.note || '', note: props.editingKey.note || '',
is_active: props.editingKey.is_active, is_active: props.editingKey.is_active,
capabilities: { ...(props.editingKey.capabilities || {}) }, capabilities: { ...(props.editingKey.capabilities || {}) },
auto_fetch_models: props.editingKey.auto_fetch_models ?? false auto_fetch_models: props.editingKey.auto_fetch_models ?? false,
model_include_patterns_text: (props.editingKey.model_include_patterns || []).join(', '),
model_exclude_patterns_text: (props.editingKey.model_exclude_patterns || []).join(', ')
} }
} }
@@ -467,6 +501,17 @@ function createFieldNonce(): string {
return Math.random().toString(36).slice(2, 10) return Math.random().toString(36).slice(2, 10)
} }
// 将逗号分隔的文本解析为数组(去空、去重)
// 返回空数组而非 undefined以便后端能正确清除已有规则
function parsePatternText(text: string): string[] {
if (!text.trim()) return []
const patterns = text
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0)
return [...new Set(patterns)]
}
async function handleSave() { async function handleSave() {
// 必须有 providerId // 必须有 providerId
if (!props.providerId) { if (!props.providerId) {
@@ -529,7 +574,9 @@ async function handleSave() {
note: form.value.note, note: form.value.note,
is_active: form.value.is_active, is_active: form.value.is_active,
capabilities: capabilitiesData, capabilities: capabilitiesData,
auto_fetch_models: form.value.auto_fetch_models auto_fetch_models: form.value.auto_fetch_models,
model_include_patterns: parsePatternText(form.value.model_include_patterns_text),
model_exclude_patterns: parsePatternText(form.value.model_exclude_patterns_text)
} }
if (form.value.api_key.trim()) { if (form.value.api_key.trim()) {
@@ -551,7 +598,9 @@ async function handleSave() {
max_probe_interval_minutes: form.value.max_probe_interval_minutes, max_probe_interval_minutes: form.value.max_probe_interval_minutes,
note: form.value.note, note: form.value.note,
capabilities: capabilitiesData || undefined, capabilities: capabilitiesData || undefined,
auto_fetch_models: form.value.auto_fetch_models auto_fetch_models: form.value.auto_fetch_models,
model_include_patterns: parsePatternText(form.value.model_include_patterns_text),
model_exclude_patterns: parsePatternText(form.value.model_exclude_patterns_text)
}) })
success('密钥已添加', '成功') success('密钥已添加', '成功')
// 添加模式:不关闭对话框,只清除名称和密钥以便继续添加 // 添加模式:不关闭对话框,只清除名称和密钥以便继续添加

View File

@@ -93,6 +93,40 @@
</div> </div>
</div> </div>
<!-- 超时配置 -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label>
流式首字节超时
<span class="text-xs text-muted-foreground">(秒)</span>
</Label>
<Input
:model-value="form.stream_first_byte_timeout ?? ''"
type="number"
min="1"
max="300"
step="1"
placeholder="30"
@update:model-value="(v) => form.stream_first_byte_timeout = parseNumberInput(v)"
/>
</div>
<div class="space-y-1.5">
<Label>
非流式请求超时
<span class="text-xs text-muted-foreground">(秒)</span>
</Label>
<Input
:model-value="form.request_timeout ?? ''"
type="number"
min="1"
max="600"
step="1"
placeholder="300"
@update:model-value="(v) => form.request_timeout = parseNumberInput(v)"
/>
</div>
</div>
<!-- 月卡配置 --> <!-- 月卡配置 -->
<div <div
v-if="form.billing_type === 'monthly_quota'" v-if="form.billing_type === 'monthly_quota'"
@@ -267,6 +301,9 @@ const form = ref({
concurrent_limit: undefined as number | undefined, concurrent_limit: undefined as number | undefined,
// 请求配置 // 请求配置
max_retries: undefined as number | undefined, max_retries: undefined as number | undefined,
// 超时配置(秒)
stream_first_byte_timeout: undefined as number | undefined,
request_timeout: undefined as number | undefined,
// 代理配置(扁平化便于表单绑定) // 代理配置(扁平化便于表单绑定)
proxy_enabled: false, proxy_enabled: false,
proxy_url: '', proxy_url: '',
@@ -291,6 +328,9 @@ function resetForm() {
concurrent_limit: undefined, concurrent_limit: undefined,
// 请求配置 // 请求配置
max_retries: undefined, max_retries: undefined,
// 超时配置
stream_first_byte_timeout: undefined,
request_timeout: undefined,
// 代理配置 // 代理配置
proxy_enabled: false, proxy_enabled: false,
proxy_url: '', proxy_url: '',
@@ -321,6 +361,9 @@ function loadProviderData() {
concurrent_limit: undefined, concurrent_limit: undefined,
// 请求配置 // 请求配置
max_retries: props.provider.max_retries ?? undefined, max_retries: props.provider.max_retries ?? undefined,
// 超时配置
stream_first_byte_timeout: props.provider.stream_first_byte_timeout ?? undefined,
request_timeout: props.provider.request_timeout ?? undefined,
// 代理配置 // 代理配置
proxy_enabled: proxy?.enabled ?? false, proxy_enabled: proxy?.enabled ?? false,
proxy_url: proxy?.url || '', proxy_url: proxy?.url || '',
@@ -376,6 +419,9 @@ const handleSubmit = async () => {
is_active: form.value.is_active, is_active: form.value.is_active,
// 请求配置 // 请求配置
max_retries: form.value.max_retries ?? undefined, max_retries: form.value.max_retries ?? undefined,
// 超时配置null 表示清除,使用全局配置)
stream_first_byte_timeout: form.value.stream_first_byte_timeout ?? null,
request_timeout: form.value.request_timeout ?? null,
proxy, proxy,
} }

View File

@@ -299,75 +299,90 @@
v-if="isAdmin" v-if="isAdmin"
class="py-4 w-[60px]" class="py-4 w-[60px]"
> >
<div class="flex flex-col text-xs gap-0.5"> <div class="flex items-center gap-1">
<div class="flex items-center gap-1"> <div class="flex flex-col text-xs gap-0.5">
<span>{{ record.provider }}</span> <span>{{ record.provider }}</span>
<!-- 故障转移图标优先显示 -->
<span <span
v-if="record.has_fallback" v-if="record.api_key_name"
class="inline-flex items-center justify-center w-4 h-4 text-xs text-amber-600 dark:text-amber-400" class="text-muted-foreground truncate"
title="此请求发生了 Provider 故障转移" :title="record.api_key_name"
> >
<svg {{ record.api_key_name }}
xmlns="http://www.w3.org/2000/svg" <span
viewBox="0 0 24 24" v-if="record.rate_multiplier && record.rate_multiplier !== 1.0"
fill="none" class="text-foreground/60"
stroke="currentColor" >({{ record.rate_multiplier }}x)</span>
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-3.5 h-3.5"
>
<path d="m16 3 4 4-4 4" />
<path d="M20 7H4" />
<path d="m8 21-4-4 4-4" />
<path d="M4 17h16" />
</svg>
</span>
<!-- 重试图标仅在无故障转移时显示 -->
<span
v-else-if="record.has_retry"
class="inline-flex items-center justify-center w-4 h-4 text-xs text-blue-600 dark:text-blue-400"
title="此请求发生了亲和缓存重试"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-3.5 h-3.5"
>
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M21 21v-5h-5" />
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
</span> </span>
</div> </div>
<span <!-- 故障转移图标优先显示 -->
v-if="record.api_key_name" <svg
class="text-muted-foreground truncate" v-if="record.has_fallback"
:title="record.api_key_name" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-3.5 h-3.5 text-amber-600 dark:text-amber-400 flex-shrink-0"
title="此请求发生了 Provider 故障转移"
> >
{{ record.api_key_name }} <path d="m16 3 4 4-4 4" />
<span <path d="M20 7H4" />
v-if="record.rate_multiplier && record.rate_multiplier !== 1.0" <path d="m8 21-4-4 4-4" />
class="text-foreground/60" <path d="M4 17h16" />
>({{ record.rate_multiplier }}x)</span> </svg>
</span> <!-- 重试图标仅在无故障转移时显示 -->
<svg
v-else-if="record.has_retry"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-3.5 h-3.5 text-blue-600 dark:text-blue-400 flex-shrink-0"
title="此请求发生了亲和缓存重试"
>
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M21 21v-5h-5" />
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
</div> </div>
</TableCell> </TableCell>
<TableCell class="py-4 w-[80px]"> <TableCell
<span class="py-4 w-[80px]"
v-if="record.api_format" :title="getApiFormatTooltip(record)"
class="inline-flex items-center px-2 py-0.5 rounded-full border border-border/60 text-[10px] font-medium whitespace-nowrap text-muted-foreground" >
:title="record.api_format" <!-- 有格式转换两行显示 -->
<div
v-if="record.api_format && record.has_format_conversion && record.endpoint_api_format"
class="flex flex-col text-xs gap-0.5"
> >
{{ formatApiFormat(record.api_format) }} <div class="flex items-center gap-1">
</span> <span>{{ formatApiFormat(record.api_format) }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3 text-muted-foreground flex-shrink-0"
>
<path
fill-rule="evenodd"
d="M3 10a.75.75 0 01.75-.75h10.638L10.23 5.29a.75.75 0 111.04-1.08l5.5 5.25a.75.75 0 010 1.08l-5.5 5.25a.75.75 0 11-1.04-1.08l4.158-3.96H3.75A.75.75 0 013 10z"
clip-rule="evenodd"
/>
</svg>
</div>
<span class="text-muted-foreground">{{ formatApiFormat(record.endpoint_api_format) }}</span>
</div>
<!-- 无格式转换单行显示 -->
<span
v-else-if="record.api_format"
class="text-xs"
>{{ formatApiFormat(record.api_format) }}</span>
<span <span
v-else v-else
class="text-muted-foreground text-xs" class="text-muted-foreground text-xs"
@@ -685,6 +700,22 @@ function formatApiFormat(format: string): string {
return formatMap[format.toUpperCase()] || format return formatMap[format.toUpperCase()] || format
} }
// 获取 API 格式的 tooltip包含转换信息
function getApiFormatTooltip(record: UsageRecord): string {
if (!record.api_format) {
return ''
}
const displayFormat = formatApiFormat(record.api_format)
// 如果发生了格式转换,显示详细信息
if (record.has_format_conversion && record.endpoint_api_format) {
const endpointDisplayFormat = formatApiFormat(record.endpoint_api_format)
return `用户请求格式: ${displayFormat}\n端点原生格式: ${endpointDisplayFormat}\n系统进行了 ${displayFormat}${endpointDisplayFormat} 格式转换`
}
return record.api_format
}
// 获取实际使用的模型(优先 target_model其次 model_version // 获取实际使用的模型(优先 target_model其次 model_version
// 只有当实际模型与请求模型不同时才返回,用于显示映射箭头 // 只有当实际模型与请求模型不同时才返回,用于显示映射箭头
function getActualModel(record: UsageRecord): string | null { function getActualModel(record: UsageRecord): string | null {

View File

@@ -72,6 +72,8 @@ export interface UsageRecord {
model: string model: string
target_model?: string | null // 映射后的目标模型名(若无映射则为空) target_model?: string | null // 映射后的目标模型名(若无映射则为空)
api_format?: string api_format?: string
endpoint_api_format?: string // 端点原生格式
has_format_conversion?: boolean // 是否发生了格式转换
input_tokens: number input_tokens: number
output_tokens: number output_tokens: number
cache_creation_input_tokens?: number cache_creation_input_tokens?: number

View File

@@ -224,6 +224,10 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
# 记录 allowed_models 变化前的值 # 记录 allowed_models 变化前的值
allowed_models_before = set(key.allowed_models or []) allowed_models_before = set(key.allowed_models or [])
# 记录过滤规则变化前的值(用于检测是否需要重新应用过滤)
include_patterns_before = key.model_include_patterns
exclude_patterns_before = key.model_exclude_patterns
update_data = self.key_data.model_dump(exclude_unset=True) update_data = self.key_data.model_dump(exclude_unset=True)
if "api_key" in update_data: if "api_key" in update_data:
update_data["api_key"] = crypto_service.encrypt(update_data["api_key"]) update_data["api_key"] = crypto_service.encrypt(update_data["api_key"])
@@ -247,6 +251,17 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
if isinstance(lm, list) and len(lm) == 0: if isinstance(lm, list) and len(lm) == 0:
update_data["locked_models"] = None update_data["locked_models"] = None
# 处理模型过滤规则:空字符串 -> None
if "model_include_patterns" in update_data:
patterns = update_data["model_include_patterns"]
if isinstance(patterns, list) and len(patterns) == 0:
update_data["model_include_patterns"] = None
if "model_exclude_patterns" in update_data:
patterns = update_data["model_exclude_patterns"]
if isinstance(patterns, list) and len(patterns) == 0:
update_data["model_exclude_patterns"] = None
for field, value in update_data.items(): for field, value in update_data.items():
setattr(key, field, value) setattr(key, field, value)
key.updated_at = datetime.now(timezone.utc) key.updated_at = datetime.now(timezone.utc)
@@ -285,6 +300,27 @@ class AdminUpdateEndpointKeyAdapter(AdminApiAdapter):
) )
db.commit() db.commit()
db.refresh(key) db.refresh(key)
elif auto_fetch_enabled_after:
# auto_fetch_models 保持开启状态,检查过滤规则是否变更
include_patterns_after = key.model_include_patterns
exclude_patterns_after = key.model_exclude_patterns
patterns_changed = (
include_patterns_before != include_patterns_after
or exclude_patterns_before != exclude_patterns_after
)
if patterns_changed:
# 过滤规则变更,重新应用过滤(使用缓存的上游模型数据)
logger.info(
"[AUTO_FETCH] Key %s 过滤规则变更,重新应用过滤",
self.key_id,
)
try:
from src.services.model.fetch_scheduler import get_model_fetch_scheduler
scheduler = get_model_fetch_scheduler()
await scheduler._fetch_models_for_key_by_id(self.key_id)
except Exception as e:
logger.error(f"重新应用过滤规则失败: {e}")
# 任何字段更新都清除缓存,确保缓存一致性 # 任何字段更新都清除缓存,确保缓存一致性
# 包括 is_active、allowed_models、capabilities 等影响权限和行为的字段 # 包括 is_active、allowed_models、capabilities 等影响权限和行为的字段
@@ -622,6 +658,12 @@ class AdminCreateProviderKeyAdapter(AdminApiAdapter):
max_probe_interval_minutes=self.key_data.max_probe_interval_minutes, max_probe_interval_minutes=self.key_data.max_probe_interval_minutes,
auto_fetch_models=self.key_data.auto_fetch_models, auto_fetch_models=self.key_data.auto_fetch_models,
locked_models=self.key_data.locked_models if self.key_data.locked_models else None, locked_models=self.key_data.locked_models if self.key_data.locked_models else None,
model_include_patterns=(
self.key_data.model_include_patterns if self.key_data.model_include_patterns else None
),
model_exclude_patterns=(
self.key_data.model_exclude_patterns if self.key_data.model_exclude_patterns else None
),
request_count=0, request_count=0,
success_count=0, success_count=0,
error_count=0, error_count=0,

View File

@@ -295,6 +295,9 @@ class AdminCreateProviderAdapter(AdminApiAdapter):
concurrent_limit=validated_data.concurrent_limit, concurrent_limit=validated_data.concurrent_limit,
max_retries=validated_data.max_retries, max_retries=validated_data.max_retries,
proxy=validated_data.proxy.model_dump() if validated_data.proxy else None, proxy=validated_data.proxy.model_dump() if validated_data.proxy else None,
# 超时配置
stream_first_byte_timeout=validated_data.stream_first_byte_timeout,
request_timeout=validated_data.request_timeout,
config=validated_data.config, config=validated_data.config,
) )

View File

@@ -310,6 +310,8 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
quota_expires_at=provider.quota_expires_at, quota_expires_at=provider.quota_expires_at,
max_retries=provider.max_retries, max_retries=provider.max_retries,
proxy=provider.proxy, proxy=provider.proxy,
stream_first_byte_timeout=provider.stream_first_byte_timeout,
request_timeout=provider.request_timeout,
total_endpoints=total_endpoints, total_endpoints=total_endpoints,
active_endpoints=active_endpoints, active_endpoints=active_endpoints,
total_keys=total_keys, total_keys=total_keys,

View File

@@ -802,6 +802,22 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
if usage.provider_id and str(usage.provider_id) in provider_map: if usage.provider_id and str(usage.provider_id) in provider_map:
provider_name = provider_map[str(usage.provider_id)] provider_name = provider_map[str(usage.provider_id)]
# 格式转换追踪(兼容历史数据:尽量回填可展示信息)
api_format = usage.api_format or (
endpoint.api_format if endpoint and endpoint.api_format else None
)
endpoint_api_format = usage.endpoint_api_format or (
endpoint.api_format if endpoint else None
)
has_format_conversion = usage.has_format_conversion
if has_format_conversion is None:
client_fmt = str(api_format or "").upper()
endpoint_fmt = str(endpoint_api_format or "").upper()
has_format_conversion = bool(
client_fmt and endpoint_fmt and client_fmt != endpoint_fmt
)
data.append( data.append(
{ {
"id": usage.id, "id": usage.id,
@@ -842,8 +858,9 @@ class AdminUsageRecordsAdapter(AdminApiAdapter):
"has_fallback": fallback_map.get(usage.request_id, False), "has_fallback": fallback_map.get(usage.request_id, False),
"has_retry": retry_map.get(usage.request_id, False), "has_retry": retry_map.get(usage.request_id, False),
"has_rectified": rectified_map.get(usage.request_id, False), "has_rectified": rectified_map.get(usage.request_id, False),
"api_format": usage.api_format "api_format": api_format,
or (endpoint.api_format if endpoint and endpoint.api_format else None), "endpoint_api_format": endpoint_api_format,
"has_format_conversion": bool(has_format_conversion),
"api_key_name": provider_api_key.name if provider_api_key else None, "api_key_name": provider_api_key.name if provider_api_key else None,
"request_metadata": usage.request_metadata, # Provider 响应元数据 "request_metadata": usage.request_metadata, # Provider 响应元数据
} }

View File

@@ -109,6 +109,9 @@ class MessageTelemetry:
provider_endpoint_id: Optional[str] = None, provider_endpoint_id: Optional[str] = None,
provider_api_key_id: Optional[str] = None, provider_api_key_id: Optional[str] = None,
api_format: Optional[str] = None, api_format: Optional[str] = None,
# 格式转换追踪
endpoint_api_format: Optional[str] = None, # 端点原生 API 格式
has_format_conversion: bool = False, # 是否发生了格式转换
# 模型映射信息 # 模型映射信息
target_model: Optional[str] = None, target_model: Optional[str] = None,
# Provider 响应元数据(如 Gemini 的 modelVersion # Provider 响应元数据(如 Gemini 的 modelVersion
@@ -135,6 +138,8 @@ class MessageTelemetry:
cache_read_input_tokens=cache_read_tokens, cache_read_input_tokens=cache_read_tokens,
request_type="chat", request_type="chat",
api_format=api_format, api_format=api_format,
endpoint_api_format=endpoint_api_format,
has_format_conversion=has_format_conversion,
is_stream=is_stream, is_stream=is_stream,
response_time_ms=response_time_ms, response_time_ms=response_time_ms,
first_byte_time_ms=first_byte_time_ms, # 传递首字时间 first_byte_time_ms=first_byte_time_ms, # 传递首字时间
@@ -195,6 +200,9 @@ class MessageTelemetry:
response_body: Optional[Dict[str, Any]] = None, response_body: Optional[Dict[str, Any]] = None,
response_headers: Optional[Dict[str, Any]] = None, response_headers: Optional[Dict[str, Any]] = None,
client_response_headers: Optional[Dict[str, Any]] = None, client_response_headers: Optional[Dict[str, Any]] = None,
# 格式转换追踪
endpoint_api_format: Optional[str] = None,
has_format_conversion: bool = False,
# 模型映射信息 # 模型映射信息
target_model: Optional[str] = None, target_model: Optional[str] = None,
) -> None: ) -> None:
@@ -230,6 +238,8 @@ class MessageTelemetry:
cache_read_input_tokens=cache_read_tokens, cache_read_input_tokens=cache_read_tokens,
request_type="chat", request_type="chat",
api_format=api_format, api_format=api_format,
endpoint_api_format=endpoint_api_format,
has_format_conversion=has_format_conversion,
is_stream=is_stream, is_stream=is_stream,
response_time_ms=response_time_ms, response_time_ms=response_time_ms,
status_code=status_code, status_code=status_code,
@@ -265,6 +275,9 @@ class MessageTelemetry:
response_body: Optional[Dict[str, Any]] = None, response_body: Optional[Dict[str, Any]] = None,
response_headers: Optional[Dict[str, Any]] = None, response_headers: Optional[Dict[str, Any]] = None,
client_response_headers: Optional[Dict[str, Any]] = None, client_response_headers: Optional[Dict[str, Any]] = None,
# 格式转换追踪
endpoint_api_format: Optional[str] = None,
has_format_conversion: bool = False,
target_model: Optional[str] = None, target_model: Optional[str] = None,
) -> None: ) -> None:
""" """
@@ -286,6 +299,8 @@ class MessageTelemetry:
cache_read_input_tokens=cache_read_tokens, cache_read_input_tokens=cache_read_tokens,
request_type="chat", request_type="chat",
api_format=api_format, api_format=api_format,
endpoint_api_format=endpoint_api_format,
has_format_conversion=has_format_conversion,
is_stream=is_stream, is_stream=is_stream,
response_time_ms=response_time_ms, response_time_ms=response_time_ms,
first_byte_time_ms=first_byte_time_ms, first_byte_time_ms=first_byte_time_ms,
@@ -488,6 +503,9 @@ class BaseMessageHandler:
key_id = ctx.key_id key_id = ctx.key_id
first_byte_time_ms = ctx.first_byte_time_ms first_byte_time_ms = ctx.first_byte_time_ms
api_format = ctx.api_format api_format = ctx.api_format
# 格式转换追踪
endpoint_api_format = ctx.provider_api_format or None
has_format_conversion = ctx.needs_conversion
# 如果 provider 为空,记录警告(不应该发生,但用于调试) # 如果 provider 为空,记录警告(不应该发生,但用于调试)
if not provider: if not provider:
@@ -512,6 +530,8 @@ class BaseMessageHandler:
provider_api_key_id=key_id, provider_api_key_id=key_id,
first_byte_time_ms=first_byte_time_ms, first_byte_time_ms=first_byte_time_ms,
api_format=api_format, api_format=api_format,
endpoint_api_format=endpoint_api_format,
has_format_conversion=has_format_conversion,
) )
finally: finally:
db.close() db.close()

View File

@@ -713,7 +713,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
) )
# 流式请求使用 stream_first_byte_timeout 作为首字节超时 # 流式请求使用 stream_first_byte_timeout 作为首字节超时
request_timeout = config.stream_first_byte_timeout # 优先使用 Provider 配置,否则使用全局配置
request_timeout = provider.stream_first_byte_timeout or config.stream_first_byte_timeout
# 创建 HTTP 客户端(支持代理配置,从 Provider 读取) # 创建 HTTP 客户端(支持代理配置,从 Provider 读取)
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
@@ -851,6 +852,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
provider_request_headers=ctx.provider_request_headers, provider_request_headers=ctx.provider_request_headers,
response_headers=ctx.response_headers, response_headers=ctx.response_headers,
client_response_headers=client_response_headers, client_response_headers=client_response_headers,
# 格式转换追踪
endpoint_api_format=ctx.provider_api_format or None,
has_format_conversion=ctx.needs_conversion,
target_model=ctx.mapped_model, target_model=ctx.mapped_model,
) )
@@ -987,7 +991,8 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
# 非流式请求使用 http_request_timeout 作为整体超时 # 非流式请求使用 http_request_timeout 作为整体超时
request_timeout = config.http_request_timeout # 优先使用 Provider 配置,否则使用全局配置
request_timeout = provider.request_timeout or config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client( http_client = await HTTPClientPool.get_proxy_client(
proxy_config=provider.proxy, proxy_config=provider.proxy,
) )
@@ -1140,6 +1145,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
is_stream=False, is_stream=False,
provider_request_headers=provider_request_headers, provider_request_headers=provider_request_headers,
api_format=api_format, api_format=api_format,
# 格式转换追踪
endpoint_api_format=provider_api_format_for_error or None,
has_format_conversion=needs_conversion_for_error,
provider_id=provider_id, provider_id=provider_id,
provider_endpoint_id=endpoint_id, provider_endpoint_id=endpoint_id,
provider_api_key_id=key_id, provider_api_key_id=key_id,
@@ -1203,6 +1211,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
provider_request_headers=provider_request_headers, provider_request_headers=provider_request_headers,
response_headers=response_headers, response_headers=response_headers,
client_response_headers={"content-type": "application/json"}, client_response_headers={"content-type": "application/json"},
# 格式转换追踪
endpoint_api_format=provider_api_format_for_error or None,
has_format_conversion=needs_conversion_for_error,
target_model=mapped_model_result, target_model=mapped_model_result,
) )
client_format = (client_api_format_for_error or "").upper() client_format = (client_api_format_for_error or "").upper()
@@ -1249,6 +1260,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
response_headers=error_response_headers, response_headers=error_response_headers,
# 非流式失败返回给客户端的是 JSON 错误响应 # 非流式失败返回给客户端的是 JSON 错误响应
client_response_headers={"content-type": "application/json"}, client_response_headers={"content-type": "application/json"},
# 格式转换追踪
endpoint_api_format=provider_api_format_for_error or None,
has_format_conversion=needs_conversion_for_error,
# 模型映射信息 # 模型映射信息
target_model=mapped_model_result, target_model=mapped_model_result,
) )

View File

@@ -739,7 +739,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
) )
# 流式请求使用 stream_first_byte_timeout 作为首字节超时 # 流式请求使用 stream_first_byte_timeout 作为首字节超时
request_timeout = config.stream_first_byte_timeout # 优先使用 Provider 配置,否则使用全局配置
request_timeout = provider.stream_first_byte_timeout or config.stream_first_byte_timeout
logger.debug( logger.debug(
f" └─ [{self.request_id}] 发送流式请求: " f" └─ [{self.request_id}] 发送流式请求: "
@@ -1068,7 +1069,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
provider_parser = self.parser provider_parser = self.parser
# 使用共享的 TTFB 超时函数读取首字节 # 使用共享的 TTFB 超时函数读取首字节
ttfb_timeout = config.stream_first_byte_timeout # 优先使用 Provider 配置,否则使用全局配置
ttfb_timeout = provider.stream_first_byte_timeout or config.stream_first_byte_timeout
first_chunk, aiter = await read_first_chunk_with_ttfb_timeout( first_chunk, aiter = await read_first_chunk_with_ttfb_timeout(
byte_iterator, byte_iterator,
timeout=ttfb_timeout, timeout=ttfb_timeout,
@@ -1804,6 +1806,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
response_body=response_body, response_body=response_body,
response_headers=ctx.response_headers, response_headers=ctx.response_headers,
client_response_headers=client_response_headers, client_response_headers=client_response_headers,
# 格式转换追踪
endpoint_api_format=ctx.provider_api_format or None,
has_format_conversion=ctx.needs_conversion,
# 模型映射信息 # 模型映射信息
target_model=ctx.mapped_model, target_model=ctx.mapped_model,
) )
@@ -1845,6 +1850,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
is_stream=True, is_stream=True,
provider_request_headers=ctx.provider_request_headers, provider_request_headers=ctx.provider_request_headers,
api_format=ctx.api_format, api_format=ctx.api_format,
# 格式转换追踪
endpoint_api_format=ctx.provider_api_format or None,
has_format_conversion=ctx.needs_conversion,
# Provider 侧追踪信息(用于记录真实成本) # Provider 侧追踪信息(用于记录真实成本)
provider_id=ctx.provider_id, provider_id=ctx.provider_id,
provider_endpoint_id=ctx.endpoint_id, provider_endpoint_id=ctx.endpoint_id,
@@ -1977,6 +1985,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
provider_request_headers=ctx.provider_request_headers, provider_request_headers=ctx.provider_request_headers,
response_headers=ctx.response_headers, response_headers=ctx.response_headers,
client_response_headers=client_response_headers, client_response_headers=client_response_headers,
# 格式转换追踪
endpoint_api_format=ctx.provider_api_format or None,
has_format_conversion=ctx.needs_conversion,
# 模型映射信息 # 模型映射信息
target_model=ctx.mapped_model, target_model=ctx.mapped_model,
) )
@@ -2101,7 +2112,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
# 非流式请求使用 http_request_timeout 作为整体超时 # 非流式请求使用 http_request_timeout 作为整体超时
request_timeout = config.http_request_timeout # 优先使用 Provider 配置,否则使用全局配置
request_timeout = provider.request_timeout or config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client( http_client = await HTTPClientPool.get_proxy_client(
proxy_config=provider.proxy, proxy_config=provider.proxy,
) )
@@ -2274,6 +2286,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
is_stream=False, is_stream=False,
provider_request_headers=provider_request_headers, provider_request_headers=provider_request_headers,
api_format=api_format, api_format=api_format,
# 格式转换追踪
endpoint_api_format=provider_api_format or None,
has_format_conversion=needs_conversion,
# Provider 侧追踪信息(用于记录真实成本) # Provider 侧追踪信息(用于记录真实成本)
provider_id=provider_id, provider_id=provider_id,
provider_endpoint_id=endpoint_id, provider_endpoint_id=endpoint_id,
@@ -2346,6 +2361,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
response_headers=error_response_headers, response_headers=error_response_headers,
# 非流式失败返回给客户端的是 JSON 错误响应 # 非流式失败返回给客户端的是 JSON 错误响应
client_response_headers={"content-type": "application/json"}, client_response_headers={"content-type": "application/json"},
# 格式转换追踪
endpoint_api_format=provider_api_format or None,
has_format_conversion=needs_conversion,
# 模型映射信息 # 模型映射信息
target_model=mapped_model_result, target_model=mapped_model_result,
) )

View File

@@ -213,7 +213,8 @@ class StreamProcessor:
try: try:
# 使用共享的 TTFB 超时函数读取首字节 # 使用共享的 TTFB 超时函数读取首字节
ttfb_timeout = config.stream_first_byte_timeout # 优先使用 Provider 配置,否则使用全局配置
ttfb_timeout = provider.stream_first_byte_timeout or config.stream_first_byte_timeout
first_chunk, aiter = await read_first_chunk_with_ttfb_timeout( first_chunk, aiter = await read_first_chunk_with_ttfb_timeout(
byte_iterator, byte_iterator,
timeout=ttfb_timeout, timeout=ttfb_timeout,
@@ -422,6 +423,8 @@ class StreamProcessor:
f"[{self.request_id}] needs_conversion=True 但 provider_format 为空,回退到透传模式" f"[{self.request_id}] needs_conversion=True 但 provider_format 为空,回退到透传模式"
) )
needs_conversion = False needs_conversion = False
# 保持 ctx 与实际行为一致,避免 Usage 记录误标记为转换
ctx.needs_conversion = False
def _mark_stream_started() -> None: def _mark_stream_started() -> None:
nonlocal start_time, streaming_started nonlocal start_time, streaming_started

View File

@@ -91,6 +91,9 @@ class CreateProviderRequest(BaseModel):
# 请求配置(从 Endpoint 迁移) # 请求配置(从 Endpoint 迁移)
max_retries: Optional[int] = Field(2, ge=0, le=10, description="最大重试次数") max_retries: Optional[int] = Field(2, ge=0, le=10, description="最大重试次数")
proxy: Optional[ProxyConfig] = Field(None, description="代理配置") proxy: Optional[ProxyConfig] = Field(None, description="代理配置")
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: Optional[float] = Field(None, ge=1, le=300, description="流式请求首字节超时(秒)")
request_timeout: Optional[float] = Field(None, ge=1, le=600, description="非流式请求整体超时(秒)")
config: Optional[Dict[str, Any]] = Field(None, description="其他配置") config: Optional[Dict[str, Any]] = Field(None, description="其他配置")
@field_validator("name", "description") @field_validator("name", "description")
@@ -161,6 +164,9 @@ class UpdateProviderRequest(BaseModel):
# 请求配置(从 Endpoint 迁移) # 请求配置(从 Endpoint 迁移)
max_retries: Optional[int] = Field(None, ge=0, le=10, description="最大重试次数") max_retries: Optional[int] = Field(None, ge=0, le=10, description="最大重试次数")
proxy: Optional[ProxyConfig] = Field(None, description="代理配置") proxy: Optional[ProxyConfig] = Field(None, description="代理配置")
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: Optional[float] = Field(None, ge=1, le=300, description="流式请求首字节超时(秒)")
request_timeout: Optional[float] = Field(None, ge=1, le=600, description="非流式请求整体超时(秒)")
config: Optional[Dict[str, Any]] = None config: Optional[Dict[str, Any]] = None
# 复用相同的验证器 # 复用相同的验证器

View File

@@ -405,6 +405,10 @@ class ProviderCreate(BaseModel):
config: Optional[dict] = Field(None, description="额外配置") config: Optional[dict] = Field(None, description="额外配置")
is_active: bool = Field(False, description="是否启用默认false需要配置API密钥后才能启用") is_active: bool = Field(False, description="是否启用默认false需要配置API密钥后才能启用")
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: Optional[float] = Field(None, ge=1, le=300, description="流式请求首字节超时(秒)")
request_timeout: Optional[float] = Field(None, ge=1, le=600, description="非流式请求整体超时(秒)")
class ProviderUpdate(BaseModel): class ProviderUpdate(BaseModel):
"""更新提供商请求""" """更新提供商请求"""
@@ -423,6 +427,10 @@ class ProviderUpdate(BaseModel):
config: Optional[dict] = None config: Optional[dict] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: Optional[float] = Field(None, ge=1, le=300, description="流式请求首字节超时(秒)")
request_timeout: Optional[float] = Field(None, ge=1, le=600, description="非流式请求整体超时(秒)")
class ProviderResponse(BaseModel): class ProviderResponse(BaseModel):
"""提供商响应""" """提供商响应"""
@@ -447,6 +455,10 @@ class ProviderResponse(BaseModel):
active_models_count: int = 0 active_models_count: int = 0
api_keys_count: int = 0 api_keys_count: int = 0
# 超时配置
stream_first_byte_timeout: Optional[float] = None
request_timeout: Optional[float] = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@@ -327,7 +327,9 @@ class Usage(Base):
# 请求详情 # 请求详情
request_type = Column(String(50)) # chat, completion, embedding等 request_type = Column(String(50)) # chat, completion, embedding等
api_format = Column(String(50), nullable=True) # API 格式: CLAUDE, OPENAI 等 api_format = Column(String(50), nullable=True) # API 格式: CLAUDE, OPENAI 等(用户请求格式)
endpoint_api_format = Column(String(50), nullable=True) # 端点原生 API 格式
has_format_conversion = Column(Boolean, nullable=True, default=False) # 是否发生了格式转换
is_stream = Column(Boolean, default=False) # 是否为流式请求 is_stream = Column(Boolean, default=False) # 是否为流式请求
status_code = Column(Integer) status_code = Column(Integer)
error_message = Column(Text, nullable=True) error_message = Column(Text, nullable=True)
@@ -653,6 +655,10 @@ class Provider(Base):
max_retries = Column(Integer, default=2, nullable=True) # 最大重试次数 max_retries = Column(Integer, default=2, nullable=True) # 最大重试次数
proxy = Column(JSONB, nullable=True) # 代理配置: {url, username, password, enabled} proxy = Column(JSONB, nullable=True) # 代理配置: {url, username, password, enabled}
# 超时配置(秒),为 None 时使用全局配置
stream_first_byte_timeout = Column(Float, nullable=True) # 流式请求首字节超时
request_timeout = Column(Float, nullable=True) # 非流式请求整体超时
# 配置 # 配置
config = Column(JSON, nullable=True) # 额外配置如Azure deployment name等 config = Column(JSON, nullable=True) # 额外配置如Azure deployment name等
@@ -1178,6 +1184,9 @@ class ProviderAPIKey(Base):
last_models_fetch_at = Column(DateTime(timezone=True), nullable=True) # 最后获取时间 last_models_fetch_at = Column(DateTime(timezone=True), nullable=True) # 最后获取时间
last_models_fetch_error = Column(Text, nullable=True) # 最后获取错误信息 last_models_fetch_error = Column(Text, nullable=True) # 最后获取错误信息
locked_models = Column(JSON, nullable=True) # 被锁定的模型列表(刷新时不会被删除) locked_models = Column(JSON, nullable=True) # 被锁定的模型列表(刷新时不会被删除)
# 模型过滤规则(支持 * 和 ? 通配符,如 "gpt-*", "claude-?-sonnet"
model_include_patterns = Column(JSON, nullable=True) # 包含规则列表,空表示不过滤(包含所有)
model_exclude_patterns = Column(JSON, nullable=True) # 排除规则列表,空表示不排除
# 时间戳 # 时间戳
created_at = Column( created_at = Column(

View File

@@ -211,6 +211,14 @@ class EndpointAPIKeyCreate(BaseModel):
default=None, description="被锁定的模型列表(刷新时不会被删除)" default=None, description="被锁定的模型列表(刷新时不会被删除)"
) )
# 模型过滤规则(仅当 auto_fetch_models=True 时生效)
model_include_patterns: Optional[List[str]] = Field(
default=None, description="模型包含规则(支持 * 和 ? 通配符),空表示包含所有"
)
model_exclude_patterns: Optional[List[str]] = Field(
default=None, description="模型排除规则(支持 * 和 ? 通配符),空表示不排除"
)
@field_validator("api_formats") @field_validator("api_formats")
@classmethod @classmethod
def validate_api_formats(cls, v: Optional[List[str]]) -> Optional[List[str]]: def validate_api_formats(cls, v: Optional[List[str]]) -> Optional[List[str]]:
@@ -345,6 +353,13 @@ class EndpointAPIKeyUpdate(BaseModel):
locked_models: Optional[List[str]] = Field( locked_models: Optional[List[str]] = Field(
default=None, description="被锁定的模型列表(刷新时不会被删除)" default=None, description="被锁定的模型列表(刷新时不会被删除)"
) )
# 模型过滤规则(仅当 auto_fetch_models=True 时生效)
model_include_patterns: Optional[List[str]] = Field(
default=None, description="模型包含规则(支持 * 和 ? 通配符),空表示包含所有"
)
model_exclude_patterns: Optional[List[str]] = Field(
default=None, description="模型排除规则(支持 * 和 ? 通配符),空表示不排除"
)
@field_validator("api_formats") @field_validator("api_formats")
@classmethod @classmethod
@@ -502,6 +517,9 @@ class EndpointAPIKeyResponse(BaseModel):
last_models_fetch_at: Optional[datetime] = Field(None, description="最后获取模型时间") last_models_fetch_at: Optional[datetime] = Field(None, description="最后获取模型时间")
last_models_fetch_error: Optional[str] = Field(None, description="最后获取模型错误信息") last_models_fetch_error: Optional[str] = Field(None, description="最后获取模型错误信息")
locked_models: Optional[List[str]] = Field(None, description="被锁定的模型列表") locked_models: Optional[List[str]] = Field(None, description="被锁定的模型列表")
# 模型过滤规则
model_include_patterns: Optional[List[str]] = Field(None, description="模型包含规则")
model_exclude_patterns: Optional[List[str]] = Field(None, description="模型排除规则")
# 时间戳 # 时间戳
last_used_at: Optional[datetime] = None last_used_at: Optional[datetime] = None
@@ -605,6 +623,9 @@ class ProviderUpdateRequest(BaseModel):
# 请求配置(从 Endpoint 迁移) # 请求配置(从 Endpoint 迁移)
max_retries: Optional[int] = Field(None, ge=0, le=10, description="最大重试次数") max_retries: Optional[int] = Field(None, ge=0, le=10, description="最大重试次数")
proxy: Optional[Dict[str, Any]] = Field(None, description="代理配置") proxy: Optional[Dict[str, Any]] = Field(None, description="代理配置")
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: Optional[float] = Field(None, ge=1, le=300, description="流式请求首字节超时(秒)")
request_timeout: Optional[float] = Field(None, ge=1, le=600, description="非流式请求整体超时(秒)")
class ProviderWithEndpointsSummary(BaseModel): class ProviderWithEndpointsSummary(BaseModel):
@@ -629,6 +650,9 @@ class ProviderWithEndpointsSummary(BaseModel):
# 请求配置(从 Endpoint 迁移) # 请求配置(从 Endpoint 迁移)
max_retries: Optional[int] = Field(default=2, description="最大重试次数") max_retries: Optional[int] = Field(default=2, description="最大重试次数")
proxy: Optional[Dict[str, Any]] = Field(default=None, description="代理配置") proxy: Optional[Dict[str, Any]] = Field(default=None, description="代理配置")
# 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout: Optional[float] = Field(default=None, description="流式请求首字节超时(秒)")
request_timeout: Optional[float] = Field(default=None, description="非流式请求整体超时(秒)")
# Endpoint 统计 # Endpoint 统计
total_endpoints: int = Field(default=0, description="总 Endpoint 数量") total_endpoints: int = Field(default=0, description="总 Endpoint 数量")

View File

@@ -7,13 +7,15 @@
- 扫描所有启用了 auto_fetch_models 的 ProviderAPIKey - 扫描所有启用了 auto_fetch_models 的 ProviderAPIKey
- 调用 Adapter.fetch_models() 获取模型列表 - 调用 Adapter.fetch_models() 获取模型列表
- 更新 Key 的 allowed_models保留 locked_models 中的模型) - 更新 Key 的 allowed_models保留 locked_models 中的模型)
- 支持包含/排除规则过滤模型
- 记录获取结果和错误信息 - 记录获取结果和错误信息
""" """
import asyncio import asyncio
import fnmatch
import os import os
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import List, Optional, Set
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
@@ -42,6 +44,68 @@ KEY_FETCH_TIMEOUT_SECONDS = 120
UPSTREAM_MODELS_CACHE_TTL_SECONDS = MODEL_FETCH_INTERVAL_MINUTES * 60 UPSTREAM_MODELS_CACHE_TTL_SECONDS = MODEL_FETCH_INTERVAL_MINUTES * 60
def _match_pattern(model_id: str, pattern: str) -> bool:
"""
检查模型 ID 是否匹配模式
支持的通配符:
- * 匹配任意字符(包括空)
- ? 匹配单个字符
Args:
model_id: 模型 ID
pattern: 匹配模式
Returns:
是否匹配
"""
return fnmatch.fnmatch(model_id.lower(), pattern.lower())
def _filter_models_by_patterns(
model_ids: Set[str],
include_patterns: Optional[List[str]],
exclude_patterns: Optional[List[str]],
) -> Set[str]:
"""
根据包含/排除规则过滤模型列表
规则优先级:
1. 如果 include_patterns 为空或 None则包含所有模型
2. 如果 include_patterns 不为空,则只包含匹配的模型
3. exclude_patterns 总是会排除匹配的模型(优先级高于 include
Args:
model_ids: 原始模型 ID 集合
include_patterns: 包含规则列表(支持 * 和 ? 通配符)
exclude_patterns: 排除规则列表(支持 * 和 ? 通配符)
Returns:
过滤后的模型 ID 集合
"""
result = set()
for model_id in model_ids:
# 步骤1: 检查是否应该包含
should_include = True
if include_patterns:
# 有包含规则时,必须匹配至少一个规则
should_include = any(_match_pattern(model_id, p) for p in include_patterns)
if not should_include:
continue
# 步骤2: 检查是否应该排除
should_exclude = False
if exclude_patterns:
should_exclude = any(_match_pattern(model_id, p) for p in exclude_patterns)
if not should_exclude:
result.add(model_id)
return result
def _get_upstream_models_cache_key(provider_id: str, api_key_id: str) -> str: def _get_upstream_models_cache_key(provider_id: str, api_key_id: str) -> str:
"""生成上游模型缓存的 key""" """生成上游模型缓存的 key"""
return f"upstream_models:{provider_id}:{api_key_id}" return f"upstream_models:{provider_id}:{api_key_id}"
@@ -341,7 +405,7 @@ class ModelFetchScheduler:
def _update_key_allowed_models(self, key: ProviderAPIKey, fetched_model_ids: set[str]) -> bool: def _update_key_allowed_models(self, key: ProviderAPIKey, fetched_model_ids: set[str]) -> bool:
""" """
更新 Key 的 allowed_models保留 locked_models 更新 Key 的 allowed_models保留 locked_models,应用过滤规则
Returns: Returns:
bool: 是否有变化 bool: 是否有变化
@@ -349,9 +413,26 @@ class ModelFetchScheduler:
# 获取当前锁定的模型 # 获取当前锁定的模型
locked_models = set(key.locked_models or []) locked_models = set(key.locked_models or [])
# 新的 allowed_models = 获取到的模型 + 锁定的模型 # 应用包含/排除过滤规则
include_patterns = key.model_include_patterns
exclude_patterns = key.model_exclude_patterns
filtered_model_ids = _filter_models_by_patterns(
fetched_model_ids, include_patterns, exclude_patterns
)
# 记录过滤结果
if include_patterns or exclude_patterns:
filtered_count = len(fetched_model_ids) - len(filtered_model_ids)
if filtered_count > 0:
logger.info(
f"Key {key.id} 过滤规则生效: 原始 {len(fetched_model_ids)} 个模型, "
f"过滤后 {len(filtered_model_ids)} 个 (排除 {filtered_count} 个)"
)
# 新的 allowed_models = 过滤后的模型 + 锁定的模型
# 锁定模型无论上游是否返回都会保留 # 锁定模型无论上游是否返回都会保留
new_allowed_models = list(fetched_model_ids | locked_models) new_allowed_models = list(filtered_model_ids | locked_models)
new_allowed_models.sort() # 保持顺序稳定 new_allowed_models.sort() # 保持顺序稳定
# 检查是否有变化 # 检查是否有变化

View File

@@ -30,6 +30,8 @@ class UsageRecordParams:
cache_read_input_tokens: int cache_read_input_tokens: int
request_type: str request_type: str
api_format: Optional[str] api_format: Optional[str]
endpoint_api_format: Optional[str] # 端点原生 API 格式
has_format_conversion: bool # 是否发生了格式转换
is_stream: bool is_stream: bool
response_time_ms: Optional[int] response_time_ms: Optional[int]
first_byte_time_ms: Optional[int] first_byte_time_ms: Optional[int]
@@ -214,6 +216,8 @@ class UsageService:
cache_read_input_tokens: int, cache_read_input_tokens: int,
request_type: str, request_type: str,
api_format: Optional[str], api_format: Optional[str],
endpoint_api_format: Optional[str],
has_format_conversion: bool,
is_stream: bool, is_stream: bool,
response_time_ms: Optional[int], response_time_ms: Optional[int],
first_byte_time_ms: Optional[int], first_byte_time_ms: Optional[int],
@@ -349,6 +353,8 @@ class UsageService:
"price_per_request": request_price, "price_per_request": request_price,
"request_type": request_type, "request_type": request_type,
"api_format": api_format, "api_format": api_format,
"endpoint_api_format": endpoint_api_format,
"has_format_conversion": has_format_conversion,
"is_stream": is_stream, "is_stream": is_stream,
"status_code": status_code, "status_code": status_code,
"error_message": error_message, "error_message": error_message,
@@ -706,6 +712,8 @@ class UsageService:
cache_read_input_tokens=params.cache_read_input_tokens, cache_read_input_tokens=params.cache_read_input_tokens,
request_type=params.request_type, request_type=params.request_type,
api_format=params.api_format, api_format=params.api_format,
endpoint_api_format=params.endpoint_api_format,
has_format_conversion=params.has_format_conversion,
is_stream=params.is_stream, is_stream=params.is_stream,
response_time_ms=params.response_time_ms, response_time_ms=params.response_time_ms,
first_byte_time_ms=params.first_byte_time_ms, first_byte_time_ms=params.first_byte_time_ms,
@@ -756,6 +764,8 @@ class UsageService:
cache_read_input_tokens: int = 0, cache_read_input_tokens: int = 0,
request_type: str = "chat", request_type: str = "chat",
api_format: Optional[str] = None, api_format: Optional[str] = None,
endpoint_api_format: Optional[str] = None,
has_format_conversion: bool = False,
is_stream: bool = False, is_stream: bool = False,
response_time_ms: Optional[int] = None, response_time_ms: Optional[int] = None,
first_byte_time_ms: Optional[int] = None, first_byte_time_ms: Optional[int] = None,
@@ -794,7 +804,9 @@ class UsageService:
input_tokens=input_tokens, output_tokens=output_tokens, input_tokens=input_tokens, output_tokens=output_tokens,
cache_creation_input_tokens=cache_creation_input_tokens, cache_creation_input_tokens=cache_creation_input_tokens,
cache_read_input_tokens=cache_read_input_tokens, cache_read_input_tokens=cache_read_input_tokens,
request_type=request_type, api_format=api_format, is_stream=is_stream, request_type=request_type, api_format=api_format,
endpoint_api_format=endpoint_api_format, has_format_conversion=has_format_conversion,
is_stream=is_stream,
response_time_ms=response_time_ms, first_byte_time_ms=first_byte_time_ms, response_time_ms=response_time_ms, first_byte_time_ms=first_byte_time_ms,
status_code=status_code, error_message=error_message, metadata=metadata, status_code=status_code, error_message=error_message, metadata=metadata,
request_headers=request_headers, request_body=request_body, request_headers=request_headers, request_body=request_body,
@@ -849,6 +861,8 @@ class UsageService:
cache_read_input_tokens: int = 0, cache_read_input_tokens: int = 0,
request_type: str = "chat", request_type: str = "chat",
api_format: Optional[str] = None, api_format: Optional[str] = None,
endpoint_api_format: Optional[str] = None,
has_format_conversion: bool = False,
is_stream: bool = False, is_stream: bool = False,
response_time_ms: Optional[int] = None, response_time_ms: Optional[int] = None,
first_byte_time_ms: Optional[int] = None, first_byte_time_ms: Optional[int] = None,
@@ -889,7 +903,9 @@ class UsageService:
input_tokens=input_tokens, output_tokens=output_tokens, input_tokens=input_tokens, output_tokens=output_tokens,
cache_creation_input_tokens=cache_creation_input_tokens, cache_creation_input_tokens=cache_creation_input_tokens,
cache_read_input_tokens=cache_read_input_tokens, cache_read_input_tokens=cache_read_input_tokens,
request_type=request_type, api_format=api_format, is_stream=is_stream, request_type=request_type, api_format=api_format,
endpoint_api_format=endpoint_api_format, has_format_conversion=has_format_conversion,
is_stream=is_stream,
response_time_ms=response_time_ms, first_byte_time_ms=first_byte_time_ms, response_time_ms=response_time_ms, first_byte_time_ms=first_byte_time_ms,
status_code=status_code, error_message=error_message, metadata=metadata, status_code=status_code, error_message=error_message, metadata=metadata,
request_headers=request_headers, request_body=request_body, request_headers=request_headers, request_body=request_body,
@@ -1486,6 +1502,8 @@ class UsageService:
provider_endpoint_id: Optional[str] = None, provider_endpoint_id: Optional[str] = None,
provider_api_key_id: Optional[str] = None, provider_api_key_id: Optional[str] = None,
api_format: Optional[str] = None, api_format: Optional[str] = None,
endpoint_api_format: Optional[str] = None,
has_format_conversion: Optional[bool] = None,
) -> Optional[Usage]: ) -> Optional[Usage]:
""" """
快速更新使用记录状态 快速更新使用记录状态
@@ -1502,6 +1520,8 @@ class UsageService:
provider_endpoint_id: Endpoint ID可选streaming 状态时更新) provider_endpoint_id: Endpoint ID可选streaming 状态时更新)
provider_api_key_id: Provider API Key ID可选streaming 状态时更新) provider_api_key_id: Provider API Key ID可选streaming 状态时更新)
api_format: API 格式(可选,用于获取按格式配置的倍率) api_format: API 格式(可选,用于获取按格式配置的倍率)
endpoint_api_format: 端点原生 API 格式(可选)
has_format_conversion: 是否发生了格式转换(可选)
Returns: Returns:
更新后的 Usage 记录,如果未找到则返回 None 更新后的 Usage 记录,如果未找到则返回 None
@@ -1540,6 +1560,10 @@ class UsageService:
) )
if rate_multiplier is not None: if rate_multiplier is not None:
usage.rate_multiplier = rate_multiplier usage.rate_multiplier = rate_multiplier
if endpoint_api_format is not None:
usage.endpoint_api_format = endpoint_api_format
if has_format_conversion is not None:
usage.has_format_conversion = has_format_conversion
db.commit() db.commit()

View File

@@ -41,6 +41,9 @@ class StreamUsageTracker:
provider_api_key_id: Optional[str] = None, provider_api_key_id: Optional[str] = None,
# API 格式(用于选择正确的响应解析器) # API 格式(用于选择正确的响应解析器)
api_format: Optional[str] = None, api_format: Optional[str] = None,
# 格式转换信息
endpoint_api_format: Optional[str] = None,
has_format_conversion: bool = False,
): ):
""" """
初始化流式用量跟踪器 初始化流式用量跟踪器
@@ -60,6 +63,8 @@ class StreamUsageTracker:
provider_endpoint_id: Endpoint ID用于记录真实成本 provider_endpoint_id: Endpoint ID用于记录真实成本
provider_api_key_id: API Key ID用于记录真实成本 provider_api_key_id: API Key ID用于记录真实成本
api_format: API 格式CLAUDE, CLAUDE_CLI, OPENAI, OPENAI_CLI api_format: API 格式CLAUDE, CLAUDE_CLI, OPENAI, OPENAI_CLI
endpoint_api_format: 端点原生 API 格式
has_format_conversion: 是否发生了格式转换
""" """
self.db = db self.db = db
# 只存储ID避免会话绑定问题 # 只存储ID避免会话绑定问题
@@ -79,6 +84,8 @@ class StreamUsageTracker:
# API 格式和响应解析器 # API 格式和响应解析器
self.api_format = api_format or "CLAUDE" self.api_format = api_format or "CLAUDE"
self.endpoint_api_format = endpoint_api_format
self.has_format_conversion = has_format_conversion
self.response_parser = get_parser_for_format(self.api_format) self.response_parser = get_parser_for_format(self.api_format)
self.stream_stats = StreamStats() # 解析器统计信息 self.stream_stats = StreamStats() # 解析器统计信息
@@ -488,6 +495,8 @@ class StreamUsageTracker:
provider_endpoint_id=self.provider_endpoint_id, provider_endpoint_id=self.provider_endpoint_id,
provider_api_key_id=self.provider_api_key_id, provider_api_key_id=self.provider_api_key_id,
api_format=self.api_format, api_format=self.api_format,
endpoint_api_format=self.endpoint_api_format,
has_format_conversion=self.has_format_conversion,
) )
except Exception as e: except Exception as e:
logger.warning(f"更新使用记录状态为 streaming 失败: {e}") logger.warning(f"更新使用记录状态为 streaming 失败: {e}")
@@ -710,6 +719,8 @@ class StreamUsageTracker:
cache_read_input_tokens=self.cache_read_input_tokens, cache_read_input_tokens=self.cache_read_input_tokens,
request_type="chat", request_type="chat",
api_format=self.api_format, api_format=self.api_format,
endpoint_api_format=self.endpoint_api_format,
has_format_conversion=self.has_format_conversion,
is_stream=True, is_stream=True,
response_time_ms=response_time_ms, response_time_ms=response_time_ms,
status_code=self.status_code, # 使用实际的状态码 status_code=self.status_code, # 使用实际的状态码
@@ -801,6 +812,9 @@ class EnhancedStreamUsageTracker(StreamUsageTracker):
provider_api_key_id: Optional[str] = None, provider_api_key_id: Optional[str] = None,
# API 格式(用于选择正确的响应解析器) # API 格式(用于选择正确的响应解析器)
api_format: Optional[str] = None, api_format: Optional[str] = None,
# 格式转换信息
endpoint_api_format: Optional[str] = None,
has_format_conversion: bool = False,
): ):
super().__init__( super().__init__(
db, db,
@@ -817,6 +831,8 @@ class EnhancedStreamUsageTracker(StreamUsageTracker):
provider_endpoint_id, provider_endpoint_id,
provider_api_key_id, provider_api_key_id,
api_format, api_format,
endpoint_api_format,
has_format_conversion,
) )
# 用于更准确的token计算 # 用于更准确的token计算
self._init_tokenizer() self._init_tokenizer()
@@ -950,6 +966,8 @@ class EnhancedStreamUsageTracker(StreamUsageTracker):
provider_endpoint_id=self.provider_endpoint_id, provider_endpoint_id=self.provider_endpoint_id,
provider_api_key_id=self.provider_api_key_id, provider_api_key_id=self.provider_api_key_id,
api_format=self.api_format, api_format=self.api_format,
endpoint_api_format=self.endpoint_api_format,
has_format_conversion=self.has_format_conversion,
) )
except Exception as e: except Exception as e:
logger.warning(f"更新使用记录状态为 streaming 失败: {e}") logger.warning(f"更新使用记录状态为 streaming 失败: {e}")
@@ -1054,6 +1072,9 @@ def create_stream_tracker(
provider_api_key_id: Optional[str] = None, provider_api_key_id: Optional[str] = None,
# API 格式(用于选择正确的响应解析器) # API 格式(用于选择正确的响应解析器)
api_format: Optional[str] = None, api_format: Optional[str] = None,
# 格式转换信息
endpoint_api_format: Optional[str] = None,
has_format_conversion: bool = False,
) -> StreamUsageTracker: ) -> StreamUsageTracker:
""" """
创建流式用量跟踪器 创建流式用量跟踪器
@@ -1074,6 +1095,8 @@ def create_stream_tracker(
provider_endpoint_id: Endpoint ID用于记录真实成本 provider_endpoint_id: Endpoint ID用于记录真实成本
provider_api_key_id: API Key ID用于记录真实成本 provider_api_key_id: API Key ID用于记录真实成本
api_format: API 格式CLAUDE, CLAUDE_CLI, OPENAI, OPENAI_CLI api_format: API 格式CLAUDE, CLAUDE_CLI, OPENAI, OPENAI_CLI
endpoint_api_format: 端点原生 API 格式
has_format_conversion: 是否发生了格式转换
Returns: Returns:
流式用量跟踪器实例 流式用量跟踪器实例
@@ -1094,6 +1117,8 @@ def create_stream_tracker(
provider_endpoint_id, provider_endpoint_id,
provider_api_key_id, provider_api_key_id,
api_format, api_format,
endpoint_api_format,
has_format_conversion,
) )
else: else:
return StreamUsageTracker( return StreamUsageTracker(
@@ -1111,4 +1136,6 @@ def create_stream_tracker(
provider_endpoint_id, provider_endpoint_id,
provider_api_key_id, provider_api_key_id,
api_format, api_format,
endpoint_api_format,
has_format_conversion,
) )