refactor: 拆分职责、引入 dataclass 封装并增强缓存健壮性

- ErrorClassifier 副作用操作分离为 ErrorHandlerService(缓存失效、健康记录、RPM 调整)
- chat_handler_base 提取 ProviderRequestResult dataclass 和 _prepare_provider_request 方法
- failover 提取 AttemptErrorOutcome dataclass 和辅助方法
- formula_engine 拆分 _resolve_mapping 为子方法,增加求值异常日志
- usage recording 引入 UsageCostInfo dataclass 封装成本参数
- 前端 types.ts 拆分为 types/ 子模块
- cache backend 工厂函数加锁防止并发重复创建,LocalCache 容量检查修正
- CacheSync 监听增加断线重连机制,publish 增加重试
- guide 页面修正 useSiteInfo() 调用顺序
This commit is contained in:
fawney19
2026-02-14 16:34:52 +08:00
parent 26ede849e2
commit 6ea33c6bb8
24 changed files with 2005 additions and 1767 deletions

View File

@@ -1,979 +1,2 @@
// API 格式常量 // Barrel re-export: 按功能域拆分到 types/ 目录,此文件保持向后兼容
export const API_FORMATS = { export * from './types/index'
// 新模式endpoint signature keyfamily:kind全小写
CLAUDE: 'claude:chat',
CLAUDE_CLI: 'claude:cli',
OPENAI: 'openai:chat',
OPENAI_CLI: 'openai:cli',
OPENAI_VIDEO: 'openai:video',
GEMINI: 'gemini:chat',
GEMINI_CLI: 'gemini:cli',
GEMINI_VIDEO: 'gemini:video',
} as const
export type APIFormat = typeof API_FORMATS[keyof typeof API_FORMATS]
// API 格式显示名称映射按品牌分组Chat 在前CLI/Video 在后)
export const API_FORMAT_LABELS: Record<string, string> = {
[API_FORMATS.CLAUDE]: 'Claude Chat',
[API_FORMATS.CLAUDE_CLI]: 'Claude CLI',
[API_FORMATS.OPENAI]: 'OpenAI Chat',
[API_FORMATS.OPENAI_CLI]: 'OpenAI CLI',
[API_FORMATS.OPENAI_VIDEO]: 'OpenAI Video',
[API_FORMATS.GEMINI]: 'Gemini Chat',
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
[API_FORMATS.GEMINI_VIDEO]: 'Gemini Video',
// legacy 兼容(仅用于展示历史数据)
CLAUDE: 'Claude Chat',
CLAUDE_CLI: 'Claude CLI',
OPENAI: 'OpenAI Chat',
OPENAI_CLI: 'OpenAI CLI',
OPENAI_VIDEO: 'OpenAI Video',
GEMINI: 'Gemini Chat',
GEMINI_CLI: 'Gemini CLI',
GEMINI_VIDEO: 'Gemini Video',
}
// API 格式缩写映射(用于空间紧凑的显示场景)
export const API_FORMAT_SHORT: Record<string, string> = {
[API_FORMATS.OPENAI]: 'O',
[API_FORMATS.OPENAI_CLI]: 'OC',
[API_FORMATS.OPENAI_VIDEO]: 'OV',
[API_FORMATS.CLAUDE]: 'C',
[API_FORMATS.CLAUDE_CLI]: 'CC',
[API_FORMATS.GEMINI]: 'G',
[API_FORMATS.GEMINI_CLI]: 'GC',
[API_FORMATS.GEMINI_VIDEO]: 'GV',
// legacy 兼容(仅用于展示历史数据)
OPENAI: 'O',
OPENAI_CLI: 'OC',
OPENAI_VIDEO: 'OV',
CLAUDE: 'C',
CLAUDE_CLI: 'CC',
GEMINI: 'G',
GEMINI_CLI: 'GC',
GEMINI_VIDEO: 'GV',
}
// API 格式排序顺序(统一的显示顺序)
export const API_FORMAT_ORDER: string[] = [
API_FORMATS.OPENAI,
API_FORMATS.OPENAI_CLI,
API_FORMATS.OPENAI_VIDEO,
API_FORMATS.CLAUDE,
API_FORMATS.CLAUDE_CLI,
API_FORMATS.GEMINI,
API_FORMATS.GEMINI_CLI,
API_FORMATS.GEMINI_VIDEO,
]
// 工具函数:按标准顺序排序 API 格式数组
export function sortApiFormats(formats: string[]): string[] {
return [...formats].sort((a, b) => {
const aIdx = API_FORMAT_ORDER.indexOf(a)
const bIdx = API_FORMAT_ORDER.indexOf(b)
if (aIdx === -1 && bIdx === -1) return 0
if (aIdx === -1) return 1
if (bIdx === -1) return -1
return aIdx - bIdx
})
}
/**
* 代理配置类型
* 支持两种模式:
* - 手动配置:设置 url/username/password
* - 代理节点:设置 node_id与 url 互斥)
*/
export interface ProxyConfig {
url?: string
username?: string
password?: string
node_id?: string // 代理节点 IDaether-proxy 注册的节点,与 url 互斥)
enabled?: boolean // 是否启用代理false 时保留配置但不使用)
}
/**
* 请求头规则类型
* - set: 设置/覆盖请求头
* - drop: 删除请求头
* - rename: 重命名请求头(保留原值)
*/
export interface HeaderRuleSet {
action: 'set'
key: string
value: string
}
export interface HeaderRuleDrop {
action: 'drop'
key: string
}
export interface HeaderRuleRename {
action: 'rename'
from: string
to: string
}
export type HeaderRule = HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename
/**
* 请求体规则类型
* - set: 设置/覆盖字段
* - drop: 删除字段
* - rename: 重命名字段(保留原值)
*/
/**
* 请求体规则 - 覆写字段
*
* - path 支持嵌套路径,如 "metadata.user.name"
* - 使用 "\." 转义字面量点号,如 "config\.v1.enabled"
*/
export interface BodyRuleSet {
action: 'set'
path: string
value: any
}
/**
* 请求体规则 - 删除字段
*
* - path 支持嵌套路径,如 "metadata.internal_flag"
* - 使用 "\." 转义字面量点号,如 "config\.v1.enabled"
*/
export interface BodyRuleDrop {
action: 'drop'
path: string
}
/**
* 请求体规则 - 重命名/移动字段
*
* - from/to 支持嵌套路径,如 "extra.old_config" -> "settings.new_config"
* - 使用 "\." 转义字面量点号,如 "config\.v1.enabled"
*/
export interface BodyRuleRename {
action: 'rename'
from: string
to: string
}
/**
* 请求体规则 - 向数组追加元素
*
* - path 指向目标数组,如 "messages"
* - value 为要追加的元素
*/
export interface BodyRuleAppend {
action: 'append'
path: string
value: any
}
/**
* 请求体规则 - 在数组指定位置插入元素
*
* - path 指向目标数组,如 "messages"
* - index 为插入位置(支持负数)
* - value 为要插入的元素
*/
export interface BodyRuleInsert {
action: 'insert'
path: string
index: number
value: any
}
/**
* 请求体规则 - 正则替换字符串值
*
* - path 指向目标字符串字段,如 "messages[0].content"
* - pattern 为正则表达式
* - replacement 为替换字符串
* - flags 可选,支持 i(忽略大小写)/m(多行)/s(dotall)
* - count 替换次数0=全部替换(默认)
*/
export interface BodyRuleRegexReplace {
action: 'regex_replace'
path: string
pattern: string
replacement: string
flags?: string
count?: number
}
export type BodyRuleConditionOp =
| 'eq' | 'neq'
| 'gt' | 'lt' | 'gte' | 'lte'
| 'starts_with' | 'ends_with' | 'contains' | 'matches'
| 'exists' | 'not_exists'
| 'in' | 'type_is'
export interface BodyRuleCondition {
path: string
op: BodyRuleConditionOp
value?: any // exists / not_exists 不需要 value
}
export type BodyRule = (BodyRuleSet | BodyRuleDrop | BodyRuleRename | BodyRuleAppend | BodyRuleInsert | BodyRuleRegexReplace) & {
condition?: BodyRuleCondition
}
/**
* 格式接受策略配置
* 用于控制端点是否接受来自不同 API 格式的请求,并自动进行格式转换
*/
export interface FormatAcceptanceConfig {
enabled: boolean // 是否启用格式转换
accept_formats?: string[] // 白名单:接受哪些格式的请求
reject_formats?: string[] // 黑名单:拒绝哪些格式(优先级高于白名单)
}
export interface ProviderEndpoint {
id: string
provider_id: string
provider_name: string
api_format: string
base_url: string
custom_path?: string // 自定义请求路径(可选,为空则使用 API 格式默认路径)
// 请求头配置
header_rules?: HeaderRule[] // 请求头规则列表,支持 set/drop/rename 操作
// 请求体配置
body_rules?: BodyRule[] // 请求体规则列表,支持 set/drop/rename 操作
max_retries: number
is_active: boolean
config?: Record<string, any>
proxy?: ProxyConfig | null
// 格式转换配置
format_acceptance_config?: FormatAcceptanceConfig | null
total_keys: number
active_keys: number
created_at: string
updated_at: string
}
/**
* 模型权限配置类型
*
* 使用示例:
* 1. 不限制(允许所有模型): null
* 2. 白名单模式: ["gpt-4", "claude-3-opus"]
*/
export type AllowedModels = string[] | null
// AllowedModels 类型守卫函数
export function isAllowedModelsList(value: AllowedModels): value is string[] {
return Array.isArray(value)
}
export interface EndpointAPIKey {
id: string
provider_id: string
api_formats: string[] // 支持的 endpoint signature 列表(如 "openai:chat"
api_key_masked: string
api_key_plain?: string | null
auth_type: 'api_key' | 'vertex_ai' | 'oauth' // 认证类型(必返回)
name: string // 密钥名称(必填,用于识别)
rate_multipliers?: Record<string, number> | null // 按 endpoint signature 的成本倍率
internal_priority: number // Key 内部优先级
global_priority_by_format?: Record<string, number> | null // 按 endpoint signature 的全局优先级
rpm_limit?: number | null // RPM 速率限制 (1-10000)null 表示自适应模式
allowed_models?: AllowedModels // 允许使用的模型列表null=不限制)
capabilities?: Record<string, boolean> | null // 能力标签配置(如 cache_1h, context_1m
// 缓存与熔断配置
cache_ttl_minutes: number // 缓存 TTL分钟0=禁用
max_probe_interval_minutes: number // 熔断探测间隔(分钟)
// 按 endpoint signature 的健康度数据
health_by_format?: Record<string, FormatHealthData>
circuit_breaker_by_format?: Record<string, FormatCircuitBreakerData>
// 聚合字段(从 health_by_format 计算,用于列表显示)
health_score: number
circuit_breaker_open?: boolean
consecutive_failures: number
last_failure_at?: string
request_count: number
success_count: number
error_count: number
success_rate: number
avg_response_time_ms: number
is_active: boolean
note?: string // 备注说明(可选)
last_used_at?: string
created_at: string
updated_at: string
// 自适应 RPM 字段
is_adaptive?: boolean // 是否为自适应模式rpm_limit=NULL
effective_limit?: number | null // 当前有效 RPM 限制(自适应使用学习值,固定使用配置值,未学习时为 null
learned_rpm_limit?: number | null // 学习到的 RPM 限制
// 滑动窗口利用率采样
utilization_samples?: Array<{ ts: number; util: number }> // 利用率采样窗口
last_probe_increase_at?: string // 上次探测性扩容时间
concurrent_429_count?: number
rpm_429_count?: number
last_429_at?: string
last_429_type?: string
// 单格式场景的熔断器字段
circuit_breaker_open_at?: string
next_probe_at?: string
half_open_until?: string
half_open_successes?: number
half_open_failures?: number
request_results_window?: Array<{ ts: number; ok: boolean }> // 请求结果滑动窗口
// 自动获取模型
auto_fetch_models?: boolean // 是否启用自动获取模型
last_models_fetch_at?: string // 最后获取模型时间
last_models_fetch_error?: string // 最后获取模型错误信息
locked_models?: string[] // 被锁定的模型列表
// 模型过滤规则(仅当 auto_fetch_models=true 时生效)
model_include_patterns?: string[] // 模型包含规则(支持 * 和 ? 通配符)
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 额度信息 / Antigravity 配额信息)
upstream_metadata?: UpstreamMetadata | null
// Key 级别代理配置(覆盖 Provider 级别代理)
proxy?: ProxyConfig | null
}
// Codex 上游元数据类型
export interface CodexUpstreamMetadata {
updated_at?: number // 更新时间Unix 时间戳)
plan_type?: string // 套餐类型
primary_used_percent?: number // 周限额窗口使用百分比
primary_reset_seconds?: number // 周限额重置剩余秒数
primary_reset_at?: number // 周限额重置时间Unix 时间戳)
primary_window_minutes?: number // 周限额窗口大小(分钟)
secondary_used_percent?: number // 5H限额窗口使用百分比
secondary_reset_seconds?: number // 5H限额重置剩余秒数
secondary_reset_at?: number // 5H限额重置时间Unix 时间戳)
secondary_window_minutes?: number // 5H限额窗口大小分钟
code_review_used_percent?: number // 代码审查限额使用百分比
code_review_reset_seconds?: number // 代码审查限额重置剩余秒数
code_review_reset_at?: number // 代码审查限额重置时间Unix 时间戳)
code_review_window_minutes?: number // 代码审查限额窗口大小(分钟)
has_credits?: boolean // 是否有积分
credits_balance?: number // 积分余额
}
export interface AntigravityModelQuota {
remaining_fraction: number // 剩余比例 (0.0-1.0)
used_percent: number // 已用百分比 (0.0-100.0)
reset_time?: string // RFC3339
}
export interface AntigravityUpstreamMetadata {
updated_at?: number // Unix 时间戳(秒)
quota_by_model?: Record<string, AntigravityModelQuota>
is_forbidden?: boolean // 账户是否被禁止访问
forbidden_reason?: string // 禁止访问原因
forbidden_at?: number // 禁止时间Unix 时间戳,秒)
}
// Kiro 上游配额信息
export interface KiroUpstreamMetadata {
subscription_title?: string // 订阅类型 (如 "KIRO PRO+")
current_usage?: number // 当前使用量
usage_limit?: number // 使用限额
remaining?: number // 剩余额度
usage_percentage?: number // 使用百分比 (0-100)
next_reset_at?: number // 下次重置时间Unix 时间戳,毫秒)
email?: string // 用户邮箱
updated_at?: number // Unix 时间戳(秒)
is_banned?: boolean // 账户是否被封禁
ban_reason?: string // 封禁原因
banned_at?: number // 封禁时间Unix 时间戳,秒)
}
export interface UpstreamMetadata {
codex?: CodexUpstreamMetadata
antigravity?: AntigravityUpstreamMetadata
kiro?: KiroUpstreamMetadata
}
// 按格式的健康度数据
export interface FormatHealthData {
health_score: number
error_rate: number
window_size: number
consecutive_failures: number
last_failure_at?: string | null
circuit_breaker: FormatCircuitBreakerData
}
// 按格式的熔断器数据
export interface FormatCircuitBreakerData {
open: boolean
open_at?: string | null
next_probe_at?: string | null
half_open_until?: string | null
half_open_successes: number
half_open_failures: number
}
export interface EndpointAPIKeyUpdate {
api_formats?: string[] // 支持的 API 格式列表
name?: string
api_key?: string // 仅在需要更新时提供
auth_type?: 'api_key' | 'vertex_ai' | 'oauth' // 认证类型
auth_config?: Record<string, any> // 认证配置Vertex AI Service Account JSON
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
internal_priority?: number
global_priority_by_format?: Record<string, number> | null // 按 API 格式的全局优先级
rpm_limit?: number | null // RPM 速率限制 (1-10000)null 表示切换为自适应模式
allowed_models?: AllowedModels
capabilities?: Record<string, boolean> | null
cache_ttl_minutes?: number
max_probe_interval_minutes?: number
note?: string
is_active?: boolean
auto_fetch_models?: boolean // 是否启用自动获取模型
locked_models?: string[] // 被锁定的模型列表
// 模型过滤规则(仅当 auto_fetch_models=true 时生效)
model_include_patterns?: string[] // 模型包含规则(支持 * 和 ? 通配符)
model_exclude_patterns?: string[] // 模型排除规则(支持 * 和 ? 通配符)
// Key 级别代理配置(覆盖 Provider 级别代理null=清除
proxy?: ProxyConfig | null
}
export interface EndpointHealthDetail {
api_format: string
health_score: number
is_active: boolean
total_keys?: number
active_keys?: number
}
export interface EndpointHealthEvent {
timestamp: string
status: 'success' | 'failed' | 'skipped' | 'started'
status_code?: number | null
latency_ms?: number | null
error_type?: string | null
error_message?: string | null
}
export interface EndpointStatusMonitor {
api_format: string
total_attempts: number
success_count: number
failed_count: number
skipped_count: number
success_rate: number
provider_count: number
key_count: number
last_event_at?: string | null
events: EndpointHealthEvent[]
timeline?: string[]
time_range_start?: string | null
time_range_end?: string | null
}
export interface EndpointStatusMonitorResponse {
generated_at: string
formats: EndpointStatusMonitor[]
}
// 公开版事件(不含敏感信息如 provider_id, key_id
export interface PublicHealthEvent {
timestamp: string
status: string
status_code?: number | null
latency_ms?: number | null
error_type?: string | null
}
// 公开版端点状态监控类型(返回 events前端复用 EndpointHealthTimeline 组件)
export interface PublicEndpointStatusMonitor {
api_format: string
api_path: string // 本站入口路径
total_attempts: number
success_count: number
failed_count: number
skipped_count: number
success_rate: number
last_event_at?: string | null
events: PublicHealthEvent[]
timeline?: string[]
time_range_start?: string | null
time_range_end?: string | null
}
export interface PublicEndpointStatusMonitorResponse {
generated_at: string
formats: PublicEndpointStatusMonitor[]
}
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro'
export interface ProviderWithEndpointsSummary {
id: string
name: string
provider_type?: ProviderType
description?: string
website?: string
provider_priority: number
keep_priority_on_conversion: boolean // 格式转换时是否保持优先级
enable_format_conversion: boolean // 是否允许格式转换(提供商级别开关)
billing_type?: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'
monthly_quota_usd?: number
monthly_used_usd?: number
quota_reset_day?: number
quota_last_reset_at?: string // 当前周期开始时间
quota_expires_at?: string
// 请求配置(从 Endpoint 迁移)
max_retries?: number // 最大重试次数
proxy?: ProxyConfig | null // 代理配置
// 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout?: number // 流式请求首字节超时
request_timeout?: number // 非流式请求整体超时
is_active: boolean
total_endpoints: number
active_endpoints: number
total_keys: number
active_keys: number
total_models: number
active_models: number
global_model_ids: string[]
avg_health_score: number
unhealthy_endpoints: number
api_formats: string[]
endpoint_health_details: EndpointHealthDetail[]
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
ops_architecture_id?: string // 扩展操作使用的架构 ID如 cubence, anyrouter
created_at: string
updated_at: string
}
export interface HealthStatus {
endpoint_id?: string
endpoint_health_score?: number
endpoint_consecutive_failures?: number
endpoint_last_failure_at?: string
endpoint_is_active?: boolean
key_id?: string
key_health_score?: number
key_consecutive_failures?: number
key_last_failure_at?: string
key_is_active?: boolean
key_statistics?: Record<string, any>
}
export interface HealthSummary {
endpoints: {
total: number
active: number
unhealthy: number
}
keys: {
total: number
active: number
unhealthy: number
}
}
export interface KeyRpmStatus {
key_id: string
current_rpm: number
rpm_limit?: number
}
export interface ProviderModelMapping {
name: string
priority: number // 优先级(数字越小优先级越高)
api_formats?: string[] // 作用域(适用的 API 格式),为空表示对所有格式生效
}
// 保留别名以保持向后兼容
export type ProviderModelAlias = ProviderModelMapping
export interface Model {
id: string
provider_id: string
global_model_id?: string // 关联的 GlobalModel ID
provider_model_name: string // Provider 侧的主模型名称
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
config?: Record<string, any> | null // 额外配置(如 billing/video 等)
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
price_per_request?: number | null // 按次计费价格
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
supports_vision?: boolean | null
supports_function_calling?: boolean | null
supports_streaming?: boolean | null
supports_extended_thinking?: boolean | null
supports_image_generation?: boolean | null
// 有效值(合并 Model 和 GlobalModel 默认值后的结果)
effective_tiered_pricing?: TieredPricingConfig | null // 有效阶梯计费配置
effective_input_price?: number | null
effective_output_price?: number | null
effective_price_per_request?: number | null // 有效按次计费价格
effective_supports_vision?: boolean | null
effective_supports_function_calling?: boolean | null
effective_supports_streaming?: boolean | null
effective_supports_extended_thinking?: boolean | null
effective_supports_image_generation?: boolean | null
is_active: boolean
is_available: boolean
created_at: string
updated_at: string
// GlobalModel 信息(从后端 join 获取)
global_model_name?: string
global_model_display_name?: string
// 有效配置(合并 Model 和 GlobalModel 的 config
effective_config?: Record<string, any> | null
}
export interface ModelCreate {
provider_model_name: string // Provider 侧的主模型名称
provider_model_mappings?: ProviderModelMapping[] // 模型名称映射列表(带优先级)
global_model_id: string // 关联的 GlobalModel ID必填
// 计费配置(可选,为空时使用 GlobalModel 默认值)
price_per_request?: number // 按次计费价格
tiered_pricing?: TieredPricingConfig // 阶梯计费配置
// 能力配置(可选,为空时使用 GlobalModel 默认值)
supports_vision?: boolean
supports_function_calling?: boolean
supports_streaming?: boolean
supports_extended_thinking?: boolean
supports_image_generation?: boolean
is_active?: boolean
config?: Record<string, any>
}
export interface ModelUpdate {
provider_model_name?: string
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
global_model_id?: string
price_per_request?: number | null // 按次计费价格null 表示清空/使用默认值)
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
supports_vision?: boolean
supports_function_calling?: boolean
supports_streaming?: boolean
supports_extended_thinking?: boolean
supports_image_generation?: boolean
is_active?: boolean
is_available?: boolean
config?: Record<string, any> | null
}
export interface ModelCapabilities {
supports_vision: boolean
supports_function_calling: boolean
supports_streaming: boolean
[key: string]: boolean
}
export interface ProviderModelPriceInfo {
input_price_per_1m?: number | null
output_price_per_1m?: number | null
cache_creation_price_per_1m?: number | null
cache_read_price_per_1m?: number | null
price_per_request?: number | null // 按次计费价格
}
export interface ModelPriceRange {
min_input: number | null
max_input: number | null
min_output: number | null
max_output: number | null
}
export interface ModelCatalogProviderDetail {
provider_id: string
provider_name: string
model_id?: string | null
target_model: string
input_price_per_1m?: number | null
output_price_per_1m?: number | null
cache_creation_price_per_1m?: number | null
cache_read_price_per_1m?: number | null
cache_1h_creation_price_per_1m?: number | null // 1h 缓存创建价格
price_per_request?: number | null // 按次计费价格
effective_tiered_pricing?: TieredPricingConfig | null // 有效阶梯计费配置(含继承)
tier_count?: number // 阶梯数量
supports_vision?: boolean | null
supports_function_calling?: boolean | null
supports_streaming?: boolean | null
is_active: boolean
mapping_id?: string | null
}
export interface ModelCatalogItem {
global_model_name: string // GlobalModel.name原 source_model
display_name: string // GlobalModel.display_name
description?: string | null // GlobalModel.description
providers: ModelCatalogProviderDetail[] // 支持该模型的 Provider 列表
price_range: ModelPriceRange // 价格区间
total_providers: number
capabilities: ModelCapabilities // 能力聚合
}
export interface ModelCatalogResponse {
models: ModelCatalogItem[]
total: number
}
export interface ProviderAvailableSourceModel {
global_model_name: string // GlobalModel.name原 source_model
display_name: string // GlobalModel.display_name
provider_model_name: string // Model.provider_model_nameProvider 侧的模型名)
model_id?: string | null // Model.id
price: ProviderModelPriceInfo
capabilities: ModelCapabilities
is_active: boolean
}
export interface ProviderAvailableSourceModelsResponse {
models: ProviderAvailableSourceModel[]
total: number
}
export interface BatchAssignProviderConfig {
provider_id: string
create_model?: boolean
model_config?: ModelCreate
model_id?: string
}
export interface AdaptiveStatsResponse {
adaptive_mode: boolean
current_limit: number | null
learned_limit: number | null
concurrent_429_count: number
rpm_429_count: number
last_429_at: string | null
last_429_type: string | null
adjustment_count: number
recent_adjustments: Array<{
timestamp: string
old_limit: number
new_limit: number
reason: string
[key: string]: any
}>
}
// ========== 阶梯计费类型 ==========
/** 缓存时长定价配置 */
export interface CacheTTLPricing {
ttl_minutes: number
cache_creation_price_per_1m: number
}
/** 单个价格阶梯配置 */
export interface PricingTier {
up_to: number | null // null 表示无上限(最后一个阶梯)
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: CacheTTLPricing[]
}
/** 阶梯计费配置 */
export interface TieredPricingConfig {
tiers: PricingTier[]
}
// ========== GlobalModel 类型 ==========
export interface GlobalModelCreate {
name: string
display_name: string
// 按次计费配置(可选,与阶梯计费叠加)
default_price_per_request?: number
// 阶梯计费配置(必填,固定价格用单阶梯表示)
default_tiered_pricing: TieredPricingConfig
// Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[]
// 模型配置JSON格式- 包含能力、规格、元信息等
config?: Record<string, any>
is_active?: boolean
}
export interface GlobalModelUpdate {
display_name?: string
is_active?: boolean
// 按次计费配置
default_price_per_request?: number | null // null 表示清空
// 阶梯计费配置
default_tiered_pricing?: TieredPricingConfig
// Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[] | null
// 模型配置JSON格式- 包含能力、规格、元信息等
config?: Record<string, any> | null
}
export interface GlobalModelResponse {
id: string
name: string
display_name: string
is_active: boolean
// 按次计费配置
default_price_per_request?: number
// 阶梯计费配置(必填)
default_tiered_pricing: TieredPricingConfig
// Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[] | null
// 模型配置JSON格式
config?: Record<string, any> | null
// 统计数据
provider_count?: number
active_provider_count?: number
usage_count?: number
created_at: string
updated_at?: string
}
export interface GlobalModelWithStats extends GlobalModelResponse {
total_models: number
total_providers: number
price_range: ModelPriceRange
}
export interface GlobalModelListResponse {
models: GlobalModelResponse[]
total: number
}
// ==================== 上游模型导入相关 ====================
/**
* 上游模型(从提供商 API 获取的原始模型)
* 后端已按 model id 聚合api_formats 包含该模型支持的所有 API 格式
*/
export interface UpstreamModel {
id: string
owned_by?: string
display_name?: string
api_formats: string[] // 该模型支持的所有 API 格式(后端保证返回数组)
}
/**
* 导入成功的模型信息
*/
export interface ImportFromUpstreamSuccessItem {
model_id: string
provider_model_id: string
global_model_id?: string // 可选,未关联时为空字符串
global_model_name?: string // 可选,未关联时为空字符串
created_global_model: boolean // 始终为 false不再自动创建 GlobalModel
}
/**
* 导入失败的模型信息
*/
export interface ImportFromUpstreamErrorItem {
model_id: string
error: string
}
/**
* 从上游提供商导入模型响应
*/
export interface ImportFromUpstreamResponse {
success: ImportFromUpstreamSuccessItem[]
errors: ImportFromUpstreamErrorItem[]
}
// ========== 路由预览相关类型 ==========
/**
* Key 路由信息
*/
export interface RoutingKeyInfo {
id: string
name: string
masked_key: string
internal_priority: number
global_priority_by_format?: Record<string, number> | null // 按 API 格式的全局优先级
rpm_limit?: number | null
is_adaptive: boolean
effective_rpm?: number | null
cache_ttl_minutes: number
health_score: number // 0-1 小数格式
is_active: boolean
api_formats: string[]
allowed_models?: string[] | null // 允许的模型列表null 表示不限制
circuit_breaker_open: boolean
circuit_breaker_formats: string[]
next_probe_at?: string | null // 下次探测时间ISO格式
}
/**
* Endpoint 路由信息
*/
export interface RoutingEndpointInfo {
id: string
api_format: string
base_url: string
custom_path?: string | null
is_active: boolean
keys: RoutingKeyInfo[]
total_keys: number
active_keys: number
}
/**
* 模型名称映射信息
*/
export interface RoutingModelMapping {
name: string
priority: number
api_formats?: string[] | null
}
/**
* Provider 路由信息
*/
export interface RoutingProviderInfo {
id: string
name: string
model_id: string
provider_priority: number
billing_type?: string | null
monthly_quota_usd?: number | null
monthly_used_usd?: number | null
is_active: boolean
provider_model_name: string
model_mappings: RoutingModelMapping[]
model_is_active: boolean
endpoints: RoutingEndpointInfo[]
total_endpoints: number
active_endpoints: number
}
/**
* 全局 Key 白名单项(用于前端实时匹配)
*/
export interface GlobalKeyWhitelistItem {
key_id: string
key_name: string
masked_key: string
provider_id: string
provider_name: string
allowed_models: string[]
}
/**
* 模型请求链路预览响应
*/
export interface ModelRoutingPreviewResponse {
global_model_id: string
global_model_name: string
display_name: string
is_active: boolean
global_model_mappings: string[] // GlobalModel 的模型映射规则(正则模式)
providers: RoutingProviderInfo[]
total_providers: number
active_providers: number
scheduling_mode: string
priority_mode: string
all_keys_whitelist: GlobalKeyWhitelistItem[]
}

View File

@@ -0,0 +1,80 @@
// API 格式常量
export const API_FORMATS = {
// 新模式endpoint signature keyfamily:kind全小写
CLAUDE: 'claude:chat',
CLAUDE_CLI: 'claude:cli',
OPENAI: 'openai:chat',
OPENAI_CLI: 'openai:cli',
OPENAI_VIDEO: 'openai:video',
GEMINI: 'gemini:chat',
GEMINI_CLI: 'gemini:cli',
GEMINI_VIDEO: 'gemini:video',
} as const
export type APIFormat = typeof API_FORMATS[keyof typeof API_FORMATS]
// API 格式显示名称映射按品牌分组Chat 在前CLI/Video 在后)
export const API_FORMAT_LABELS: Record<string, string> = {
[API_FORMATS.CLAUDE]: 'Claude Chat',
[API_FORMATS.CLAUDE_CLI]: 'Claude CLI',
[API_FORMATS.OPENAI]: 'OpenAI Chat',
[API_FORMATS.OPENAI_CLI]: 'OpenAI CLI',
[API_FORMATS.OPENAI_VIDEO]: 'OpenAI Video',
[API_FORMATS.GEMINI]: 'Gemini Chat',
[API_FORMATS.GEMINI_CLI]: 'Gemini CLI',
[API_FORMATS.GEMINI_VIDEO]: 'Gemini Video',
// legacy 兼容(仅用于展示历史数据)
CLAUDE: 'Claude Chat',
CLAUDE_CLI: 'Claude CLI',
OPENAI: 'OpenAI Chat',
OPENAI_CLI: 'OpenAI CLI',
OPENAI_VIDEO: 'OpenAI Video',
GEMINI: 'Gemini Chat',
GEMINI_CLI: 'Gemini CLI',
GEMINI_VIDEO: 'Gemini Video',
}
// API 格式缩写映射(用于空间紧凑的显示场景)
export const API_FORMAT_SHORT: Record<string, string> = {
[API_FORMATS.OPENAI]: 'O',
[API_FORMATS.OPENAI_CLI]: 'OC',
[API_FORMATS.OPENAI_VIDEO]: 'OV',
[API_FORMATS.CLAUDE]: 'C',
[API_FORMATS.CLAUDE_CLI]: 'CC',
[API_FORMATS.GEMINI]: 'G',
[API_FORMATS.GEMINI_CLI]: 'GC',
[API_FORMATS.GEMINI_VIDEO]: 'GV',
// legacy 兼容(仅用于展示历史数据)
OPENAI: 'O',
OPENAI_CLI: 'OC',
OPENAI_VIDEO: 'OV',
CLAUDE: 'C',
CLAUDE_CLI: 'CC',
GEMINI: 'G',
GEMINI_CLI: 'GC',
GEMINI_VIDEO: 'GV',
}
// API 格式排序顺序(统一的显示顺序)
export const API_FORMAT_ORDER: string[] = [
API_FORMATS.OPENAI,
API_FORMATS.OPENAI_CLI,
API_FORMATS.OPENAI_VIDEO,
API_FORMATS.CLAUDE,
API_FORMATS.CLAUDE_CLI,
API_FORMATS.GEMINI,
API_FORMATS.GEMINI_CLI,
API_FORMATS.GEMINI_VIDEO,
]
// 工具函数:按标准顺序排序 API 格式数组
export function sortApiFormats(formats: string[]): string[] {
return [...formats].sort((a, b) => {
const aIdx = API_FORMAT_ORDER.indexOf(a)
const bIdx = API_FORMAT_ORDER.indexOf(b)
if (aIdx === -1 && bIdx === -1) return 0
if (aIdx === -1) return 1
if (bIdx === -1) return -1
return aIdx - bIdx
})
}

View File

@@ -0,0 +1,4 @@
export * from './api-format'
export * from './provider'
export * from './model'
export * from './routing'

View File

@@ -0,0 +1,273 @@
import type { ProviderModelMapping } from './provider'
// ========== 阶梯计费类型 ==========
/** 缓存时长定价配置 */
export interface CacheTTLPricing {
ttl_minutes: number
cache_creation_price_per_1m: number
}
/** 单个价格阶梯配置 */
export interface PricingTier {
up_to: number | null // null 表示无上限(最后一个阶梯)
input_price_per_1m: number
output_price_per_1m: number
cache_creation_price_per_1m?: number
cache_read_price_per_1m?: number
cache_ttl_pricing?: CacheTTLPricing[]
}
/** 阶梯计费配置 */
export interface TieredPricingConfig {
tiers: PricingTier[]
}
export interface Model {
id: string
provider_id: string
global_model_id?: string // 关联的 GlobalModel ID
provider_model_name: string // Provider 侧的主模型名称
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
config?: Record<string, any> | null // 额外配置(如 billing/video 等)
// 原始配置值(可能为空,为空时使用 GlobalModel 默认值)
price_per_request?: number | null // 按次计费价格
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
supports_vision?: boolean | null
supports_function_calling?: boolean | null
supports_streaming?: boolean | null
supports_extended_thinking?: boolean | null
supports_image_generation?: boolean | null
// 有效值(合并 Model 和 GlobalModel 默认值后的结果)
effective_tiered_pricing?: TieredPricingConfig | null // 有效阶梯计费配置
effective_input_price?: number | null
effective_output_price?: number | null
effective_price_per_request?: number | null // 有效按次计费价格
effective_supports_vision?: boolean | null
effective_supports_function_calling?: boolean | null
effective_supports_streaming?: boolean | null
effective_supports_extended_thinking?: boolean | null
effective_supports_image_generation?: boolean | null
is_active: boolean
is_available: boolean
created_at: string
updated_at: string
// GlobalModel 信息(从后端 join 获取)
global_model_name?: string
global_model_display_name?: string
// 有效配置(合并 Model 和 GlobalModel 的 config
effective_config?: Record<string, any> | null
}
export interface ModelCreate {
provider_model_name: string // Provider 侧的主模型名称
provider_model_mappings?: ProviderModelMapping[] // 模型名称映射列表(带优先级)
global_model_id: string // 关联的 GlobalModel ID必填
// 计费配置(可选,为空时使用 GlobalModel 默认值)
price_per_request?: number // 按次计费价格
tiered_pricing?: TieredPricingConfig // 阶梯计费配置
// 能力配置(可选,为空时使用 GlobalModel 默认值)
supports_vision?: boolean
supports_function_calling?: boolean
supports_streaming?: boolean
supports_extended_thinking?: boolean
supports_image_generation?: boolean
is_active?: boolean
config?: Record<string, any>
}
export interface ModelUpdate {
provider_model_name?: string
provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级)
global_model_id?: string
price_per_request?: number | null // 按次计费价格null 表示清空/使用默认值)
tiered_pricing?: TieredPricingConfig | null // 阶梯计费配置
supports_vision?: boolean
supports_function_calling?: boolean
supports_streaming?: boolean
supports_extended_thinking?: boolean
supports_image_generation?: boolean
is_active?: boolean
is_available?: boolean
config?: Record<string, any> | null
}
export interface ModelCapabilities {
supports_vision: boolean
supports_function_calling: boolean
supports_streaming: boolean
[key: string]: boolean
}
export interface ProviderModelPriceInfo {
input_price_per_1m?: number | null
output_price_per_1m?: number | null
cache_creation_price_per_1m?: number | null
cache_read_price_per_1m?: number | null
price_per_request?: number | null // 按次计费价格
}
export interface ModelPriceRange {
min_input: number | null
max_input: number | null
min_output: number | null
max_output: number | null
}
export interface ModelCatalogProviderDetail {
provider_id: string
provider_name: string
model_id?: string | null
target_model: string
input_price_per_1m?: number | null
output_price_per_1m?: number | null
cache_creation_price_per_1m?: number | null
cache_read_price_per_1m?: number | null
cache_1h_creation_price_per_1m?: number | null // 1h 缓存创建价格
price_per_request?: number | null // 按次计费价格
effective_tiered_pricing?: TieredPricingConfig | null // 有效阶梯计费配置(含继承)
tier_count?: number // 阶梯数量
supports_vision?: boolean | null
supports_function_calling?: boolean | null
supports_streaming?: boolean | null
is_active: boolean
mapping_id?: string | null
}
export interface ModelCatalogItem {
global_model_name: string // GlobalModel.name原 source_model
display_name: string // GlobalModel.display_name
description?: string | null // GlobalModel.description
providers: ModelCatalogProviderDetail[] // 支持该模型的 Provider 列表
price_range: ModelPriceRange // 价格区间
total_providers: number
capabilities: ModelCapabilities // 能力聚合
}
export interface ModelCatalogResponse {
models: ModelCatalogItem[]
total: number
}
export interface ProviderAvailableSourceModel {
global_model_name: string // GlobalModel.name原 source_model
display_name: string // GlobalModel.display_name
provider_model_name: string // Model.provider_model_nameProvider 侧的模型名)
model_id?: string | null // Model.id
price: ProviderModelPriceInfo
capabilities: ModelCapabilities
is_active: boolean
}
export interface ProviderAvailableSourceModelsResponse {
models: ProviderAvailableSourceModel[]
total: number
}
export interface BatchAssignProviderConfig {
provider_id: string
create_model?: boolean
model_config?: ModelCreate
model_id?: string
}
// ========== GlobalModel 类型 ==========
export interface GlobalModelCreate {
name: string
display_name: string
// 按次计费配置(可选,与阶梯计费叠加)
default_price_per_request?: number
// 阶梯计费配置(必填,固定价格用单阶梯表示)
default_tiered_pricing: TieredPricingConfig
// Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[]
// 模型配置JSON格式- 包含能力、规格、元信息等
config?: Record<string, any>
is_active?: boolean
}
export interface GlobalModelUpdate {
display_name?: string
is_active?: boolean
// 按次计费配置
default_price_per_request?: number | null // null 表示清空
// 阶梯计费配置
default_tiered_pricing?: TieredPricingConfig
// Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[] | null
// 模型配置JSON格式- 包含能力、规格、元信息等
config?: Record<string, any> | null
}
export interface GlobalModelResponse {
id: string
name: string
display_name: string
is_active: boolean
// 按次计费配置
default_price_per_request?: number
// 阶梯计费配置(必填)
default_tiered_pricing: TieredPricingConfig
// Key 能力配置 - 模型支持的能力列表
supported_capabilities?: string[] | null
// 模型配置JSON格式
config?: Record<string, any> | null
// 统计数据
provider_count?: number
active_provider_count?: number
usage_count?: number
created_at: string
updated_at?: string
}
export interface GlobalModelWithStats extends GlobalModelResponse {
total_models: number
total_providers: number
price_range: ModelPriceRange
}
export interface GlobalModelListResponse {
models: GlobalModelResponse[]
total: number
}
// ==================== 上游模型导入相关 ====================
/**
* 上游模型(从提供商 API 获取的原始模型)
* 后端已按 model id 聚合api_formats 包含该模型支持的所有 API 格式
*/
export interface UpstreamModel {
id: string
owned_by?: string
display_name?: string
api_formats: string[] // 该模型支持的所有 API 格式(后端保证返回数组)
}
/**
* 导入成功的模型信息
*/
export interface ImportFromUpstreamSuccessItem {
model_id: string
provider_model_id: string
global_model_id?: string // 可选,未关联时为空字符串
global_model_name?: string // 可选,未关联时为空字符串
created_global_model: boolean // 始终为 false不再自动创建 GlobalModel
}
/**
* 导入失败的模型信息
*/
export interface ImportFromUpstreamErrorItem {
model_id: string
error: string
}
/**
* 从上游提供商导入模型响应
*/
export interface ImportFromUpstreamResponse {
success: ImportFromUpstreamSuccessItem[]
errors: ImportFromUpstreamErrorItem[]
}

View File

@@ -0,0 +1,530 @@
/**
* 代理配置类型
* 支持两种模式:
* - 手动配置:设置 url/username/password
* - 代理节点:设置 node_id与 url 互斥)
*/
export interface ProxyConfig {
url?: string
username?: string
password?: string
node_id?: string // 代理节点 IDaether-proxy 注册的节点,与 url 互斥)
enabled?: boolean // 是否启用代理false 时保留配置但不使用)
}
/**
* 请求头规则类型
* - set: 设置/覆盖请求头
* - drop: 删除请求头
* - rename: 重命名请求头(保留原值)
*/
export interface HeaderRuleSet {
action: 'set'
key: string
value: string
}
export interface HeaderRuleDrop {
action: 'drop'
key: string
}
export interface HeaderRuleRename {
action: 'rename'
from: string
to: string
}
export type HeaderRule = HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename
/**
* 请求体规则类型
* - set: 设置/覆盖字段
* - drop: 删除字段
* - rename: 重命名字段(保留原值)
*/
/**
* 请求体规则 - 覆写字段
*
* - path 支持嵌套路径,如 "metadata.user.name"
* - 使用 "\." 转义字面量点号,如 "config\.v1.enabled"
*/
export interface BodyRuleSet {
action: 'set'
path: string
value: any
}
/**
* 请求体规则 - 删除字段
*
* - path 支持嵌套路径,如 "metadata.internal_flag"
* - 使用 "\." 转义字面量点号,如 "config\.v1.enabled"
*/
export interface BodyRuleDrop {
action: 'drop'
path: string
}
/**
* 请求体规则 - 重命名/移动字段
*
* - from/to 支持嵌套路径,如 "extra.old_config" -> "settings.new_config"
* - 使用 "\." 转义字面量点号,如 "config\.v1.enabled"
*/
export interface BodyRuleRename {
action: 'rename'
from: string
to: string
}
/**
* 请求体规则 - 向数组追加元素
*
* - path 指向目标数组,如 "messages"
* - value 为要追加的元素
*/
export interface BodyRuleAppend {
action: 'append'
path: string
value: any
}
/**
* 请求体规则 - 在数组指定位置插入元素
*
* - path 指向目标数组,如 "messages"
* - index 为插入位置(支持负数)
* - value 为要插入的元素
*/
export interface BodyRuleInsert {
action: 'insert'
path: string
index: number
value: any
}
/**
* 请求体规则 - 正则替换字符串值
*
* - path 指向目标字符串字段,如 "messages[0].content"
* - pattern 为正则表达式
* - replacement 为替换字符串
* - flags 可选,支持 i(忽略大小写)/m(多行)/s(dotall)
* - count 替换次数0=全部替换(默认)
*/
export interface BodyRuleRegexReplace {
action: 'regex_replace'
path: string
pattern: string
replacement: string
flags?: string
count?: number
}
export type BodyRuleConditionOp =
| 'eq' | 'neq'
| 'gt' | 'lt' | 'gte' | 'lte'
| 'starts_with' | 'ends_with' | 'contains' | 'matches'
| 'exists' | 'not_exists'
| 'in' | 'type_is'
export interface BodyRuleCondition {
path: string
op: BodyRuleConditionOp
value?: any // exists / not_exists 不需要 value
}
export type BodyRule = (BodyRuleSet | BodyRuleDrop | BodyRuleRename | BodyRuleAppend | BodyRuleInsert | BodyRuleRegexReplace) & {
condition?: BodyRuleCondition
}
/**
* 格式接受策略配置
* 用于控制端点是否接受来自不同 API 格式的请求,并自动进行格式转换
*/
export interface FormatAcceptanceConfig {
enabled: boolean // 是否启用格式转换
accept_formats?: string[] // 白名单:接受哪些格式的请求
reject_formats?: string[] // 黑名单:拒绝哪些格式(优先级高于白名单)
}
export interface ProviderEndpoint {
id: string
provider_id: string
provider_name: string
api_format: string
base_url: string
custom_path?: string // 自定义请求路径(可选,为空则使用 API 格式默认路径)
// 请求头配置
header_rules?: HeaderRule[] // 请求头规则列表,支持 set/drop/rename 操作
// 请求体配置
body_rules?: BodyRule[] // 请求体规则列表,支持 set/drop/rename 操作
max_retries: number
is_active: boolean
config?: Record<string, any>
proxy?: ProxyConfig | null
// 格式转换配置
format_acceptance_config?: FormatAcceptanceConfig | null
total_keys: number
active_keys: number
created_at: string
updated_at: string
}
/**
* 模型权限配置类型
*
* 使用示例:
* 1. 不限制(允许所有模型): null
* 2. 白名单模式: ["gpt-4", "claude-3-opus"]
*/
export type AllowedModels = string[] | null
// AllowedModels 类型守卫函数
export function isAllowedModelsList(value: AllowedModels): value is string[] {
return Array.isArray(value)
}
export interface EndpointAPIKey {
id: string
provider_id: string
api_formats: string[] // 支持的 endpoint signature 列表(如 "openai:chat"
api_key_masked: string
api_key_plain?: string | null
auth_type: 'api_key' | 'vertex_ai' | 'oauth' // 认证类型(必返回)
name: string // 密钥名称(必填,用于识别)
rate_multipliers?: Record<string, number> | null // 按 endpoint signature 的成本倍率
internal_priority: number // Key 内部优先级
global_priority_by_format?: Record<string, number> | null // 按 endpoint signature 的全局优先级
rpm_limit?: number | null // RPM 速率限制 (1-10000)null 表示自适应模式
allowed_models?: AllowedModels // 允许使用的模型列表null=不限制)
capabilities?: Record<string, boolean> | null // 能力标签配置(如 cache_1h, context_1m
// 缓存与熔断配置
cache_ttl_minutes: number // 缓存 TTL分钟0=禁用
max_probe_interval_minutes: number // 熔断探测间隔(分钟)
// 按 endpoint signature 的健康度数据
health_by_format?: Record<string, FormatHealthData>
circuit_breaker_by_format?: Record<string, FormatCircuitBreakerData>
// 聚合字段(从 health_by_format 计算,用于列表显示)
health_score: number
circuit_breaker_open?: boolean
consecutive_failures: number
last_failure_at?: string
request_count: number
success_count: number
error_count: number
success_rate: number
avg_response_time_ms: number
is_active: boolean
note?: string // 备注说明(可选)
last_used_at?: string
created_at: string
updated_at: string
// 自适应 RPM 字段
is_adaptive?: boolean // 是否为自适应模式rpm_limit=NULL
effective_limit?: number | null // 当前有效 RPM 限制(自适应使用学习值,固定使用配置值,未学习时为 null
learned_rpm_limit?: number | null // 学习到的 RPM 限制
// 滑动窗口利用率采样
utilization_samples?: Array<{ ts: number; util: number }> // 利用率采样窗口
last_probe_increase_at?: string // 上次探测性扩容时间
concurrent_429_count?: number
rpm_429_count?: number
last_429_at?: string
last_429_type?: string
// 单格式场景的熔断器字段
circuit_breaker_open_at?: string
next_probe_at?: string
half_open_until?: string
half_open_successes?: number
half_open_failures?: number
request_results_window?: Array<{ ts: number; ok: boolean }> // 请求结果滑动窗口
// 自动获取模型
auto_fetch_models?: boolean // 是否启用自动获取模型
last_models_fetch_at?: string // 最后获取模型时间
last_models_fetch_error?: string // 最后获取模型错误信息
locked_models?: string[] // 被锁定的模型列表
// 模型过滤规则(仅当 auto_fetch_models=true 时生效)
model_include_patterns?: string[] // 模型包含规则(支持 * 和 ? 通配符)
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 额度信息 / Antigravity 配额信息)
upstream_metadata?: UpstreamMetadata | null
// Key 级别代理配置(覆盖 Provider 级别代理)
proxy?: ProxyConfig | null
}
// Codex 上游元数据类型
export interface CodexUpstreamMetadata {
updated_at?: number // 更新时间Unix 时间戳)
plan_type?: string // 套餐类型
primary_used_percent?: number // 周限额窗口使用百分比
primary_reset_seconds?: number // 周限额重置剩余秒数
primary_reset_at?: number // 周限额重置时间Unix 时间戳)
primary_window_minutes?: number // 周限额窗口大小(分钟)
secondary_used_percent?: number // 5H限额窗口使用百分比
secondary_reset_seconds?: number // 5H限额重置剩余秒数
secondary_reset_at?: number // 5H限额重置时间Unix 时间戳)
secondary_window_minutes?: number // 5H限额窗口大小分钟
code_review_used_percent?: number // 代码审查限额使用百分比
code_review_reset_seconds?: number // 代码审查限额重置剩余秒数
code_review_reset_at?: number // 代码审查限额重置时间Unix 时间戳)
code_review_window_minutes?: number // 代码审查限额窗口大小(分钟)
has_credits?: boolean // 是否有积分
credits_balance?: number // 积分余额
}
export interface AntigravityModelQuota {
remaining_fraction: number // 剩余比例 (0.0-1.0)
used_percent: number // 已用百分比 (0.0-100.0)
reset_time?: string // RFC3339
}
export interface AntigravityUpstreamMetadata {
updated_at?: number // Unix 时间戳(秒)
quota_by_model?: Record<string, AntigravityModelQuota>
is_forbidden?: boolean // 账户是否被禁止访问
forbidden_reason?: string // 禁止访问原因
forbidden_at?: number // 禁止时间Unix 时间戳,秒)
}
// Kiro 上游配额信息
export interface KiroUpstreamMetadata {
subscription_title?: string // 订阅类型 (如 "KIRO PRO+")
current_usage?: number // 当前使用量
usage_limit?: number // 使用限额
remaining?: number // 剩余额度
usage_percentage?: number // 使用百分比 (0-100)
next_reset_at?: number // 下次重置时间Unix 时间戳,毫秒)
email?: string // 用户邮箱
updated_at?: number // Unix 时间戳(秒)
is_banned?: boolean // 账户是否被封禁
ban_reason?: string // 封禁原因
banned_at?: number // 封禁时间Unix 时间戳,秒)
}
export interface UpstreamMetadata {
codex?: CodexUpstreamMetadata
antigravity?: AntigravityUpstreamMetadata
kiro?: KiroUpstreamMetadata
}
// 按格式的健康度数据
export interface FormatHealthData {
health_score: number
error_rate: number
window_size: number
consecutive_failures: number
last_failure_at?: string | null
circuit_breaker: FormatCircuitBreakerData
}
// 按格式的熔断器数据
export interface FormatCircuitBreakerData {
open: boolean
open_at?: string | null
next_probe_at?: string | null
half_open_until?: string | null
half_open_successes: number
half_open_failures: number
}
export interface EndpointAPIKeyUpdate {
api_formats?: string[] // 支持的 API 格式列表
name?: string
api_key?: string // 仅在需要更新时提供
auth_type?: 'api_key' | 'vertex_ai' | 'oauth' // 认证类型
auth_config?: Record<string, any> // 认证配置Vertex AI Service Account JSON
rate_multipliers?: Record<string, number> | null // 按 API 格式的成本倍率
internal_priority?: number
global_priority_by_format?: Record<string, number> | null // 按 API 格式的全局优先级
rpm_limit?: number | null // RPM 速率限制 (1-10000)null 表示切换为自适应模式
allowed_models?: AllowedModels
capabilities?: Record<string, boolean> | null
cache_ttl_minutes?: number
max_probe_interval_minutes?: number
note?: string
is_active?: boolean
auto_fetch_models?: boolean // 是否启用自动获取模型
locked_models?: string[] // 被锁定的模型列表
// 模型过滤规则(仅当 auto_fetch_models=true 时生效)
model_include_patterns?: string[] // 模型包含规则(支持 * 和 ? 通配符)
model_exclude_patterns?: string[] // 模型排除规则(支持 * 和 ? 通配符)
// Key 级别代理配置(覆盖 Provider 级别代理null=清除
proxy?: ProxyConfig | null
}
export interface EndpointHealthDetail {
api_format: string
health_score: number
is_active: boolean
total_keys?: number
active_keys?: number
}
export interface EndpointHealthEvent {
timestamp: string
status: 'success' | 'failed' | 'skipped' | 'started'
status_code?: number | null
latency_ms?: number | null
error_type?: string | null
error_message?: string | null
}
export interface EndpointStatusMonitor {
api_format: string
total_attempts: number
success_count: number
failed_count: number
skipped_count: number
success_rate: number
provider_count: number
key_count: number
last_event_at?: string | null
events: EndpointHealthEvent[]
timeline?: string[]
time_range_start?: string | null
time_range_end?: string | null
}
export interface EndpointStatusMonitorResponse {
generated_at: string
formats: EndpointStatusMonitor[]
}
// 公开版事件(不含敏感信息如 provider_id, key_id
export interface PublicHealthEvent {
timestamp: string
status: string
status_code?: number | null
latency_ms?: number | null
error_type?: string | null
}
// 公开版端点状态监控类型(返回 events前端复用 EndpointHealthTimeline 组件)
export interface PublicEndpointStatusMonitor {
api_format: string
api_path: string // 本站入口路径
total_attempts: number
success_count: number
failed_count: number
skipped_count: number
success_rate: number
last_event_at?: string | null
events: PublicHealthEvent[]
timeline?: string[]
time_range_start?: string | null
time_range_end?: string | null
}
export interface PublicEndpointStatusMonitorResponse {
generated_at: string
formats: PublicEndpointStatusMonitor[]
}
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro'
export interface ProviderWithEndpointsSummary {
id: string
name: string
provider_type?: ProviderType
description?: string
website?: string
provider_priority: number
keep_priority_on_conversion: boolean // 格式转换时是否保持优先级
enable_format_conversion: boolean // 是否允许格式转换(提供商级别开关)
billing_type?: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'
monthly_quota_usd?: number
monthly_used_usd?: number
quota_reset_day?: number
quota_last_reset_at?: string // 当前周期开始时间
quota_expires_at?: string
// 请求配置(从 Endpoint 迁移)
max_retries?: number // 最大重试次数
proxy?: ProxyConfig | null // 代理配置
// 超时配置(秒),为空时使用全局配置
stream_first_byte_timeout?: number // 流式请求首字节超时
request_timeout?: number // 非流式请求整体超时
is_active: boolean
total_endpoints: number
active_endpoints: number
total_keys: number
active_keys: number
total_models: number
active_models: number
global_model_ids: string[]
avg_health_score: number
unhealthy_endpoints: number
api_formats: string[]
endpoint_health_details: EndpointHealthDetail[]
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
ops_architecture_id?: string // 扩展操作使用的架构 ID如 cubence, anyrouter
created_at: string
updated_at: string
}
export interface HealthStatus {
endpoint_id?: string
endpoint_health_score?: number
endpoint_consecutive_failures?: number
endpoint_last_failure_at?: string
endpoint_is_active?: boolean
key_id?: string
key_health_score?: number
key_consecutive_failures?: number
key_last_failure_at?: string
key_is_active?: boolean
key_statistics?: Record<string, any>
}
export interface HealthSummary {
endpoints: {
total: number
active: number
unhealthy: number
}
keys: {
total: number
active: number
unhealthy: number
}
}
export interface KeyRpmStatus {
key_id: string
current_rpm: number
rpm_limit?: number
}
export interface ProviderModelMapping {
name: string
priority: number // 优先级(数字越小优先级越高)
api_formats?: string[] // 作用域(适用的 API 格式),为空表示对所有格式生效
}
// 保留别名以保持向后兼容
export type ProviderModelAlias = ProviderModelMapping
export interface AdaptiveStatsResponse {
adaptive_mode: boolean
current_limit: number | null
learned_limit: number | null
concurrent_429_count: number
rpm_429_count: number
last_429_at: string | null
last_429_type: string | null
adjustment_count: number
recent_adjustments: Array<{
timestamp: string
old_limit: number
new_limit: number
reason: string
[key: string]: any
}>
}

View File

@@ -0,0 +1,95 @@
// ========== 路由预览相关类型 ==========
/**
* Key 路由信息
*/
export interface RoutingKeyInfo {
id: string
name: string
masked_key: string
internal_priority: number
global_priority_by_format?: Record<string, number> | null // 按 API 格式的全局优先级
rpm_limit?: number | null
is_adaptive: boolean
effective_rpm?: number | null
cache_ttl_minutes: number
health_score: number // 0-1 小数格式
is_active: boolean
api_formats: string[]
allowed_models?: string[] | null // 允许的模型列表null 表示不限制
circuit_breaker_open: boolean
circuit_breaker_formats: string[]
next_probe_at?: string | null // 下次探测时间ISO格式
}
/**
* Endpoint 路由信息
*/
export interface RoutingEndpointInfo {
id: string
api_format: string
base_url: string
custom_path?: string | null
is_active: boolean
keys: RoutingKeyInfo[]
total_keys: number
active_keys: number
}
/**
* 模型名称映射信息
*/
export interface RoutingModelMapping {
name: string
priority: number
api_formats?: string[] | null
}
/**
* Provider 路由信息
*/
export interface RoutingProviderInfo {
id: string
name: string
model_id: string
provider_priority: number
billing_type?: string | null
monthly_quota_usd?: number | null
monthly_used_usd?: number | null
is_active: boolean
provider_model_name: string
model_mappings: RoutingModelMapping[]
model_is_active: boolean
endpoints: RoutingEndpointInfo[]
total_endpoints: number
active_endpoints: number
}
/**
* 全局 Key 白名单项(用于前端实时匹配)
*/
export interface GlobalKeyWhitelistItem {
key_id: string
key_name: string
masked_key: string
provider_id: string
provider_name: string
allowed_models: string[]
}
/**
* 模型请求链路预览响应
*/
export interface ModelRoutingPreviewResponse {
global_model_id: string
global_model_name: string
display_name: string
is_active: boolean
global_model_mappings: string[] // GlobalModel 的模型映射规则(正则模式)
providers: RoutingProviderInfo[]
total_providers: number
active_providers: number
scheduling_mode: string
priority_mode: string
all_keys_whitelist: GlobalKeyWhitelistItem[]
}

View File

@@ -4,8 +4,6 @@ import { ArrowRight, Shuffle, FileCode, Globe, Shield, Check, Info, AlertTriangl
import { panelClasses } from './guide-config' import { panelClasses } from './guide-config'
import { useSiteInfo } from '@/composables/useSiteInfo' import { useSiteInfo } from '@/composables/useSiteInfo'
const { siteName } = useSiteInfo()
withDefaults( withDefaults(
defineProps<{ defineProps<{
baseUrl?: string baseUrl?: string
@@ -15,6 +13,8 @@ withDefaults(
} }
) )
const { siteName } = useSiteInfo()
// 格式转换示例 // 格式转换示例
const conversionExamples = [ const conversionExamples = [
{ {

View File

@@ -4,8 +4,6 @@ import { Search, ChevronDown, ExternalLink, HelpCircle } from 'lucide-vue-next'
import { faqItems, panelClasses } from './guide-config' import { faqItems, panelClasses } from './guide-config'
import { useSiteInfo } from '@/composables/useSiteInfo' import { useSiteInfo } from '@/composables/useSiteInfo'
const { siteName } = useSiteInfo()
withDefaults( withDefaults(
defineProps<{ defineProps<{
baseUrl?: string baseUrl?: string
@@ -15,6 +13,8 @@ withDefaults(
} }
) )
const { siteName } = useSiteInfo()
// 搜索关键词 // 搜索关键词
const searchQuery = ref('') const searchQuery = ref('')

View File

@@ -4,8 +4,6 @@ import { ArrowRight, Server, Layers, Key, Box, ChevronRight } from 'lucide-vue-n
import { coreConcepts, apiFormats, configSteps, panelClasses } from './guide-config' import { coreConcepts, apiFormats, configSteps, panelClasses } from './guide-config'
import { useSiteInfo } from '@/composables/useSiteInfo' import { useSiteInfo } from '@/composables/useSiteInfo'
const { siteName } = useSiteInfo()
withDefaults( withDefaults(
defineProps<{ defineProps<{
baseUrl?: string baseUrl?: string
@@ -15,6 +13,8 @@ withDefaults(
} }
) )
const { siteName } = useSiteInfo()
// 概念图标映射 // 概念图标映射
const conceptIcons = { const conceptIcons = {
blue: Server, blue: Server,

View File

@@ -4,8 +4,6 @@ import { ArrowRight, Server, Settings, Check, AlertTriangle, Info } from 'lucide
import { apiFormats, panelClasses } from './guide-config' import { apiFormats, panelClasses } from './guide-config'
import { useSiteInfo } from '@/composables/useSiteInfo' import { useSiteInfo } from '@/composables/useSiteInfo'
const { siteName } = useSiteInfo()
withDefaults( withDefaults(
defineProps<{ defineProps<{
baseUrl?: string baseUrl?: string
@@ -15,6 +13,8 @@ withDefaults(
} }
) )
const { siteName } = useSiteInfo()
// 端点配置字段说明 // 端点配置字段说明
const endpointFields = [ const endpointFields = [
{ name: '名称', description: '端点的显示名称,用于区分不同的端点', required: true }, { name: '名称', description: '端点的显示名称,用于区分不同的端点', required: true },

View File

@@ -4,8 +4,6 @@ import { ArrowRight, Users, Key, Shield, Check, Info, AlertTriangle, Clock } fro
import { panelClasses } from './guide-config' import { panelClasses } from './guide-config'
import { useSiteInfo } from '@/composables/useSiteInfo' import { useSiteInfo } from '@/composables/useSiteInfo'
const { siteName } = useSiteInfo()
withDefaults( withDefaults(
defineProps<{ defineProps<{
baseUrl?: string baseUrl?: string
@@ -15,6 +13,8 @@ withDefaults(
} }
) )
const { siteName } = useSiteInfo()
// API Key 字段说明 // API Key 字段说明
const keyFields = [ const keyFields = [
{ name: '名称', description: 'Key 的描述性名称,方便识别', required: true }, { name: '名称', description: 'Key 的描述性名称,方便识别', required: true },

View File

@@ -25,6 +25,7 @@ import asyncio
import json import json
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator, Awaitable, Callable from collections.abc import AsyncGenerator, Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any from typing import Any
import httpx import httpx
@@ -225,6 +226,22 @@ def _build_error_json_payload(
return _build_client_error_response_best_effort(message, client_format) return _build_client_error_response_best_effort(message, client_format)
@dataclass
class ProviderRequestResult:
"""_prepare_provider_request() 的返回结果,封装请求构建阶段的所有产出。"""
request_body: dict[str, Any]
url_model: str
mapped_model: str | None
envelope: Any # ProviderEnvelope | None
extra_headers: dict[str, str] = field(default_factory=dict)
upstream_is_stream: bool = True
needs_conversion: bool = False
provider_api_format: str = ""
client_api_format: str = ""
auth_info: Any = None
class ChatHandlerBase(BaseMessageHandler, ABC): class ChatHandlerBase(BaseMessageHandler, ABC):
""" """
Chat Handler 基类 Chat Handler 基类
@@ -755,62 +772,43 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
await self._record_stream_failure(ctx, e, original_headers, original_request_body) await self._record_stream_failure(ctx, e, original_headers, original_request_body)
raise raise
async def _execute_stream_request( async def _prepare_provider_request(
self, self,
ctx: StreamContext, *,
stream_processor: StreamProcessor, model: str,
provider: Provider, provider: Provider,
endpoint: ProviderEndpoint, endpoint: ProviderEndpoint,
key: ProviderAPIKey, key: ProviderAPIKey,
original_request_body: dict[str, Any], original_request_body: dict[str, Any],
original_headers: dict[str, str], client_api_format: str,
query_params: dict[str, str] | None = None, provider_api_format: str,
candidate: ProviderCandidate | None = None, candidate: ProviderCandidate | None,
is_disconnected: Callable[[], Awaitable[bool]] | None = None, client_is_stream: bool,
) -> AsyncGenerator[bytes]: ) -> ProviderRequestResult:
"""执行流式请求并返回流生成器""" """
# 重置上下文状态(重试时清除之前的数据) 构建 Provider 请求模型映射、格式转换、envelope 包装。
ctx.reset_for_retry()
# 更新 Provider 信息
ctx.update_provider_info(
provider_name=str(provider.name),
provider_id=str(provider.id),
endpoint_id=str(endpoint.id),
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 = (
ctx.api_format.value if hasattr(ctx.api_format, "value") else str(ctx.api_format)
)
provider_api_format = ctx.provider_api_format or _api_format_str
client_api_format = ctx.client_api_format or _api_format_str
流式和非流式请求共享此逻辑,唯一差异是 client_is_stream 参数。
"""
# 提前获取认证信息Vertex AI 格式判断需要使用 auth_config # 提前获取认证信息Vertex AI 格式判断需要使用 auth_config
auth_info = await get_provider_auth(endpoint, key) auth_info = await get_provider_auth(endpoint, key)
# 解析 Vertex AI 动态格式并计算 needs_conversion # 解析 Vertex AI 动态格式并计算 needs_conversion
provider_api_format, needs_conversion = _resolve_vertex_ai_format( provider_api_format, needs_conversion = _resolve_vertex_ai_format(
key, auth_info, ctx.model, provider_api_format, client_api_format, candidate key, auth_info, model, provider_api_format, client_api_format, candidate
) )
ctx.provider_api_format = provider_api_format
ctx.needs_conversion = needs_conversion
# 获取模型映射(优先使用映射匹配到的模型,其次是 Provider 级别的映射) # 获取模型映射(优先使用映射匹配到的模型,其次是 Provider 级别的映射)
mapped_model = candidate.mapping_matched_model if candidate else None mapped_model = candidate.mapping_matched_model if candidate else None
if not mapped_model: if not mapped_model:
mapped_model = await self._get_mapped_model( mapped_model = await self._get_mapped_model(
source_model=ctx.model, source_model=model,
provider_id=str(provider.id), provider_id=str(provider.id),
api_format=provider_api_format, api_format=provider_api_format,
) )
# 应用模型映射到请求体 # 应用模型映射到请求体
if mapped_model: if mapped_model:
ctx.mapped_model = mapped_model
request_body = self.apply_mapped_model(original_request_body, mapped_model) request_body = self.apply_mapped_model(original_request_body, mapped_model)
else: else:
request_body = dict(original_request_body) request_body = dict(original_request_body)
@@ -824,14 +822,14 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
same_format_variant = behavior.same_format_variant same_format_variant = behavior.same_format_variant
cross_format_variant = behavior.cross_format_variant cross_format_variant = behavior.cross_format_variant
# Upstream streaming policy (per-endpoint): may force upstream to sync/stream mode. # Upstream streaming policy (per-endpoint).
upstream_policy = get_upstream_stream_policy( upstream_policy = get_upstream_stream_policy(
endpoint, endpoint,
provider_type=provider_type, provider_type=provider_type,
endpoint_sig=str(provider_api_format), endpoint_sig=str(provider_api_format),
) )
upstream_is_stream = resolve_upstream_is_stream( upstream_is_stream = resolve_upstream_is_stream(
client_is_stream=True, client_is_stream=client_is_stream,
policy=upstream_policy, policy=upstream_policy,
) )
@@ -849,7 +847,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
request_body, request_body,
str(provider_api_format), str(provider_api_format),
mapped_model, mapped_model,
ctx.model, model,
) )
# 格式转换后,为需要 stream 字段的格式设置流式标志 # 格式转换后,为需要 stream 字段的格式设置流式标志
self._set_stream_after_conversion( self._set_stream_after_conversion(
@@ -886,13 +884,13 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
) )
# 获取 URL 模型名 # 获取 URL 模型名
url_model = self.get_model_for_url(request_body, mapped_model) or ctx.model url_model = self.get_model_for_url(request_body, mapped_model) or model
# Provider envelope: wrap request after auth is available and before RequestBuilder.build(). # Provider envelope: wrap request.
if envelope: if envelope:
request_body, url_model = envelope.wrap_request( request_body, url_model = envelope.wrap_request(
request_body, request_body,
model=url_model or ctx.model or "", model=url_model or model or "",
url_model=url_model, url_model=url_model,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None, decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
) )
@@ -902,6 +900,78 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
if envelope: if envelope:
extra_headers.update(envelope.extra_headers() or {}) extra_headers.update(envelope.extra_headers() or {})
return ProviderRequestResult(
request_body=request_body,
url_model=url_model,
mapped_model=mapped_model,
envelope=envelope,
extra_headers=extra_headers,
upstream_is_stream=upstream_is_stream,
needs_conversion=needs_conversion,
provider_api_format=provider_api_format,
client_api_format=client_api_format,
auth_info=auth_info,
)
async def _execute_stream_request(
self,
ctx: StreamContext,
stream_processor: StreamProcessor,
provider: Provider,
endpoint: ProviderEndpoint,
key: ProviderAPIKey,
original_request_body: dict[str, Any],
original_headers: dict[str, str],
query_params: dict[str, str] | None = None,
candidate: ProviderCandidate | None = None,
is_disconnected: Callable[[], Awaitable[bool]] | None = None,
) -> AsyncGenerator[bytes]:
"""执行流式请求并返回流生成器"""
# 重置上下文状态(重试时清除之前的数据)
ctx.reset_for_retry()
# 更新 Provider 信息
ctx.update_provider_info(
provider_name=str(provider.name),
provider_id=str(provider.id),
endpoint_id=str(endpoint.id),
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 = (
ctx.api_format.value if hasattr(ctx.api_format, "value") else str(ctx.api_format)
)
provider_api_format = ctx.provider_api_format or _api_format_str
client_api_format = ctx.client_api_format or _api_format_str
# 构建 Provider 请求模型映射、格式转换、envelope 包装)
prep = await self._prepare_provider_request(
model=ctx.model,
provider=provider,
endpoint=endpoint,
key=key,
original_request_body=original_request_body,
client_api_format=client_api_format,
provider_api_format=provider_api_format,
candidate=candidate,
client_is_stream=True,
)
provider_api_format = prep.provider_api_format
needs_conversion = prep.needs_conversion
ctx.provider_api_format = provider_api_format
ctx.needs_conversion = needs_conversion
mapped_model = prep.mapped_model
if mapped_model:
ctx.mapped_model = mapped_model
request_body = prep.request_body
url_model = prep.url_model
envelope = prep.envelope
upstream_is_stream = prep.upstream_is_stream
auth_info = prep.auth_info
# 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式) # 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式)
provider_payload, provider_headers = self._request_builder.build( provider_payload, provider_headers = self._request_builder.build(
request_body, request_body,
@@ -909,7 +979,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
endpoint, endpoint,
key, key,
is_stream=upstream_is_stream, is_stream=upstream_is_stream,
extra_headers=extra_headers if extra_headers else None, extra_headers=prep.extra_headers if prep.extra_headers else None,
pre_computed_auth=auth_info.as_tuple() if auth_info else None, pre_computed_auth=auth_info.as_tuple() if auth_info else None,
) )
if upstream_is_stream: if upstream_is_stream:
@@ -1070,6 +1140,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
) )
# Convert sync JSON -> InternalResponse, then InternalResponse -> client stream events. # Convert sync JSON -> InternalResponse, then InternalResponse -> client stream events.
registry = get_format_converter_registry()
src_norm = ( src_norm = (
registry.get_normalizer(str(provider_api_format)) if provider_api_format else None registry.get_normalizer(str(provider_api_format)) if provider_api_format else None
) )
@@ -1425,125 +1496,35 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
provider_name = str(provider.name) provider_name = str(provider.name)
provider_api_format = str(endpoint.api_format or api_format) provider_api_format = str(endpoint.api_format or api_format)
# 客户端格式(与流式处理保持一致的命名)
client_api_format = ( client_api_format = (
api_format.value if hasattr(api_format, "value") else str(api_format) api_format.value if hasattr(api_format, "value") else str(api_format)
) )
# 提前获取认证信息Vertex AI 格式判断需要使用 auth_config # 构建 Provider 请求模型映射、格式转换、envelope 包装
auth_info = await get_provider_auth(endpoint, key) prep = await self._prepare_provider_request(
model=model,
# 解析 Vertex AI 动态格式并计算 needs_conversion provider=provider,
provider_api_format, needs_conversion = _resolve_vertex_ai_format( endpoint=endpoint,
key, auth_info, model, provider_api_format, client_api_format, candidate key=key,
original_request_body=request_body_ref["body"],
client_api_format=client_api_format,
provider_api_format=provider_api_format,
candidate=candidate,
client_is_stream=False,
) )
provider_api_format = prep.provider_api_format
needs_conversion = prep.needs_conversion
provider_api_format_for_error = provider_api_format provider_api_format_for_error = provider_api_format
client_api_format_for_error = client_api_format client_api_format_for_error = client_api_format
needs_conversion_for_error = needs_conversion needs_conversion_for_error = needs_conversion
mapped_model = prep.mapped_model
# 获取模型映射(优先使用映射匹配到的模型,其次是 Provider 级别的映射)
mapped_model = candidate.mapping_matched_model if candidate else None
if not mapped_model:
mapped_model = await self._get_mapped_model(
source_model=model,
provider_id=str(provider.id),
api_format=provider_api_format,
)
# 应用模型映射
if mapped_model: if mapped_model:
mapped_model_result = mapped_model # 保存映射后的模型名,用于 Usage 记录 mapped_model_result = mapped_model
request_body = self.apply_mapped_model(request_body_ref["body"], mapped_model) request_body = prep.request_body
else: url_model = prep.url_model
request_body = dict(request_body_ref["body"]) envelope = prep.envelope
upstream_is_stream = prep.upstream_is_stream
provider_type = str(getattr(provider, "provider_type", "") or "").lower() auth_info = prep.auth_info
behavior = get_provider_behavior(
provider_type=provider_type,
endpoint_sig=provider_api_format,
)
envelope = behavior.envelope
same_format_variant = behavior.same_format_variant
cross_format_variant = behavior.cross_format_variant
# Upstream streaming policy (per-endpoint).
upstream_policy = get_upstream_stream_policy(
endpoint,
provider_type=provider_type,
endpoint_sig=str(provider_api_format),
)
upstream_is_stream = resolve_upstream_is_stream(
client_is_stream=False,
policy=upstream_policy,
)
# 跨格式:先做请求体转换(失败触发 failover
registry = get_format_converter_registry()
if needs_conversion:
request_body = registry.convert_request(
request_body,
client_api_format,
provider_api_format,
target_variant=cross_format_variant,
)
# 格式转换后,为需要 model 字段的格式设置模型名
self._set_model_after_conversion(
request_body,
provider_api_format,
mapped_model,
model,
)
# 格式转换后,为需要 stream 字段的格式设置流式标志
self._set_stream_after_conversion(
request_body,
client_api_format,
provider_api_format,
is_stream=upstream_is_stream,
)
else:
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
request_body = self.prepare_provider_request_body(request_body)
# 同格式时也需要应用 target_variant 转换(如 Codex
if same_format_variant:
request_body = registry.convert_request(
request_body,
provider_api_format,
provider_api_format,
target_variant=same_format_variant,
)
# 模型感知的请求后处理(如图像生成模型移除不兼容字段)
request_body = self.finalize_provider_request(
request_body,
mapped_model=mapped_model,
provider_api_format=str(provider_api_format) if provider_api_format else None,
)
# Force upstream stream/sync mode in request body (best-effort).
if provider_api_format:
enforce_stream_mode_for_upstream(
request_body,
provider_api_format=str(provider_api_format),
upstream_is_stream=upstream_is_stream,
)
# 获取 URL 模型名(兜底使用外层的 model确保 Gemini 等格式能正确构建 URL
url_model = self.get_model_for_url(request_body, mapped_model) or model
# Provider envelope: wrap request after auth is available and before RequestBuilder.build().
if envelope:
request_body, url_model = envelope.wrap_request(
request_body,
model=url_model or model or "",
url_model=url_model,
decrypted_auth_config=auth_info.decrypted_auth_config if auth_info else None,
)
# Provider envelope: extra upstream headers (e.g. dedicated User-Agent).
extra_headers: dict[str, str] = {}
if envelope:
extra_headers.update(envelope.extra_headers() or {})
# 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式) # 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式)
provider_payload, provider_hdrs = self._request_builder.build( provider_payload, provider_hdrs = self._request_builder.build(
@@ -1552,7 +1533,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
endpoint, endpoint,
key, key,
is_stream=upstream_is_stream, is_stream=upstream_is_stream,
extra_headers=extra_headers if extra_headers else None, extra_headers=prep.extra_headers if prep.extra_headers else None,
pre_computed_auth=auth_info.as_tuple() if auth_info else None, pre_computed_auth=auth_info.as_tuple() if auth_info else None,
) )
if upstream_is_stream: if upstream_is_stream:
@@ -1584,6 +1565,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
_effective_proxy = resolve_effective_proxy(provider.proxy, getattr(key, "proxy", None)) _effective_proxy = resolve_effective_proxy(provider.proxy, getattr(key, "proxy", None))
sync_proxy_info = resolve_proxy_info(_effective_proxy) sync_proxy_info = resolve_proxy_info(_effective_proxy)
_proxy_label = get_proxy_label(sync_proxy_info) _proxy_label = get_proxy_label(sync_proxy_info)
provider_type = str(getattr(provider, "provider_type", "") or "").lower()
logger.info( logger.info(
f" [{self.request_id}] 发送{'上游流式(聚合)' if upstream_is_stream else '非流式'}请求: " f" [{self.request_id}] 发送{'上游流式(聚合)' if upstream_is_stream else '非流式'}请求: "
@@ -1678,6 +1660,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
provider_parser=provider_parser, provider_parser=provider_parser,
) )
registry = get_format_converter_registry()
tgt_norm = ( tgt_norm = (
registry.get_normalizer(client_api_format) registry.get_normalizer(client_api_format)
if client_api_format if client_api_format

View File

@@ -6,7 +6,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import threading
import time import time
from collections import OrderedDict from collections import OrderedDict
from typing import Any from typing import Any
@@ -24,7 +23,7 @@ class MemoryCachePlugin(CachePlugin):
super().__init__(name, config) super().__init__(name, config)
self._cache: OrderedDict = OrderedDict() self._cache: OrderedDict = OrderedDict()
self._expiry: dict[str, float] = {} self._expiry: dict[str, float] = {}
self._lock = threading.RLock() self._lock = asyncio.Lock()
self._hits = 0 self._hits = 0
self._misses = 0 self._misses = 0
self._evictions = 0 self._evictions = 0
@@ -60,7 +59,7 @@ class MemoryCachePlugin(CachePlugin):
now = time.time() now = time.time()
expired_keys = [] expired_keys = []
with self._lock: async with self._lock:
for key, expiry in self._expiry.items(): for key, expiry in self._expiry.items():
if expiry < now: if expiry < now:
expired_keys.append(key) expired_keys.append(key)
@@ -81,7 +80,7 @@ class MemoryCachePlugin(CachePlugin):
async def get(self, key: str) -> Any | None: async def get(self, key: str) -> Any | None:
"""获取缓存值""" """获取缓存值"""
with self._lock: async with self._lock:
# 检查是否过期 # 检查是否过期
if key in self._expiry: if key in self._expiry:
if self._expiry[key] < time.time(): if self._expiry[key] < time.time():
@@ -103,7 +102,7 @@ class MemoryCachePlugin(CachePlugin):
async def set(self, key: str, value: Any, ttl: int | None = None) -> bool: async def set(self, key: str, value: Any, ttl: int | None = None) -> bool:
"""设置缓存值""" """设置缓存值"""
with self._lock: async with self._lock:
# 检查大小限制 # 检查大小限制
if key not in self._cache: if key not in self._cache:
self._check_size() self._check_size()
@@ -126,7 +125,7 @@ class MemoryCachePlugin(CachePlugin):
async def delete(self, key: str) -> bool: async def delete(self, key: str) -> bool:
"""删除缓存项""" """删除缓存项"""
with self._lock: async with self._lock:
if key in self._cache: if key in self._cache:
self._cache.pop(key) self._cache.pop(key)
self._expiry.pop(key, None) self._expiry.pop(key, None)
@@ -135,7 +134,7 @@ class MemoryCachePlugin(CachePlugin):
async def exists(self, key: str) -> bool: async def exists(self, key: str) -> bool:
"""检查缓存项是否存在""" """检查缓存项是否存在"""
with self._lock: async with self._lock:
# 检查是否过期 # 检查是否过期
if key in self._expiry: if key in self._expiry:
if self._expiry[key] < time.time(): if self._expiry[key] < time.time():
@@ -147,7 +146,7 @@ class MemoryCachePlugin(CachePlugin):
async def clear(self) -> bool: async def clear(self) -> bool:
"""清空所有缓存""" """清空所有缓存"""
with self._lock: async with self._lock:
self._cache.clear() self._cache.clear()
self._expiry.clear() self._expiry.clear()
return True return True

View File

@@ -17,6 +17,7 @@ from decimal import Decimal
from functools import lru_cache from functools import lru_cache
from typing import Any, Iterable, Literal from typing import Any, Iterable, Literal
from src.core.logger import logger
from src.services.billing.precision import DECIMAL_CONTEXT_PRECISION, to_decimal from src.services.billing.precision import DECIMAL_CONTEXT_PRECISION, to_decimal
@@ -378,6 +379,11 @@ class FormulaEngine:
if status == "missing_required": if status == "missing_required":
missing_required.append(var_name) missing_required.append(var_name)
continue continue
if status == "error":
# 求值异常但非 required使用 default 值继续
resolved[var_name] = value
progressed = True
continue
resolved[var_name] = value resolved[var_name] = value
progressed = True progressed = True
if not progressed: if not progressed:
@@ -387,6 +393,11 @@ class FormulaEngine:
for var_name, mapping in unresolved.items(): for var_name, mapping in unresolved.items():
required = bool(mapping.get("required", False)) required = bool(mapping.get("required", False))
default = mapping.get("default", 0) default = mapping.get("default", 0)
logger.warning(
"[FormulaEngine] computed 维度 '{}' 在迭代后仍未解析, required={}",
var_name,
required,
)
if required: if required:
missing_required.append(var_name) missing_required.append(var_name)
else: else:
@@ -505,9 +516,14 @@ class FormulaEngine:
except NameError: except NameError:
# dependency not ready yet # dependency not ready yet
return (None, "pending") if required else (default, "pending") return (None, "pending") if required else (default, "pending")
except Exception: except Exception as exc:
# treat as config error: fallback to default unless required logger.warning(
return (None, "missing_required") if required else (default, "ok") "[FormulaEngine] computed 维度 '{}' 求值异常: {}, expression={!r}",
var_name,
exc,
expr,
)
return (None, "missing_required") if required else (default, "error")
def _resolve_mapping( def _resolve_mapping(
self, self,
@@ -517,16 +533,41 @@ class FormulaEngine:
) -> tuple[Any, bool, dict[str, Any] | None]: ) -> tuple[Any, bool, dict[str, Any] | None]:
""" """
Returns: Returns:
(value, is_missing_required) (value, is_missing_required, tier_meta)
说明: 说明:
- is_missing_required 仅在 required=true 且缺失时为 True - is_missing_required 仅在 required=true 且缺失时为 True
- required=false 的缺失会使用 default 或 0 兜底,并返回 is_missing_required=False - required=false 的缺失会使用 default 或 0 兜底,并返回 is_missing_required=False
""" """
source = (mapping.get("source") or "constant").lower() source = (mapping.get("source") or "constant").lower()
if source == "constant":
return self._resolve_constant(mapping)
if source == "dimension":
return self._resolve_dimension(var_name, mapping, dims)
if source == "matrix":
return self._resolve_matrix(var_name, mapping, dims)
if source == "tiered":
return self._resolve_tiered(var_name, mapping, dims)
# 未知 source视为配置错误但不直接中断计费返回 default
return mapping.get("default", 0), False, None
@staticmethod
def _resolve_constant(
mapping: dict[str, Any],
) -> tuple[Any, bool, dict[str, Any] | None]:
"""constant 默认行为:由 variables 提供dimension_mappings 显式 constant 时仅做兜底"""
return mapping.get("default", 0), False, None
@staticmethod
def _resolve_dimension(
var_name: str,
mapping: dict[str, Any],
dims: dict[str, Any],
) -> tuple[Any, bool, dict[str, Any] | None]:
"""解析 dimension source从 dims 中取值并尝试转换为 Decimal"""
required = bool(mapping.get("required", False)) required = bool(mapping.get("required", False))
allow_zero = bool(mapping.get("allow_zero", False)) allow_zero = bool(mapping.get("allow_zero", False))
default = mapping.get("default", 0) default = mapping.get("default", 0)
def _missing() -> tuple[Any, bool]: def _missing() -> tuple[Any, bool]:
@@ -534,11 +575,6 @@ class FormulaEngine:
return None, True return None, True
return default, False return default, False
if source == "constant":
# constant 默认行为:由 variables 提供dimension_mappings 显式 constant 时仅做兜底
return default, False, None
if source == "dimension":
key = mapping.get("key") or var_name key = mapping.get("key") or var_name
raw = dims.get(key) raw = dims.get(key)
if raw is None: if raw is None:
@@ -548,7 +584,6 @@ class FormulaEngine:
if raw == "": if raw == "":
v, m = _missing() v, m = _missing()
return v, m, None return v, m, None
# 尝试将字符串解析为数字,否则按字符串返回(供上层自行决定)
try: try:
num = to_decimal(raw) num = to_decimal(raw)
if num == 0 and not allow_zero: if num == 0 and not allow_zero:
@@ -563,7 +598,6 @@ class FormulaEngine:
v, m = _missing() v, m = _missing()
return v, m, None return v, m, None
return num, False, None return num, False, None
# 其他类型:尽量转为 float否则视为缺失
try: try:
num = to_decimal(raw) num = to_decimal(raw)
if num == 0 and not allow_zero: if num == 0 and not allow_zero:
@@ -574,7 +608,21 @@ class FormulaEngine:
v, m = _missing() v, m = _missing()
return v, m, None return v, m, None
if source == "matrix": @staticmethod
def _resolve_matrix(
var_name: str,
mapping: dict[str, Any],
dims: dict[str, Any],
) -> tuple[Any, bool, dict[str, Any] | None]:
"""解析 matrix source从 map 中按 key 查找值"""
required = bool(mapping.get("required", False))
default = mapping.get("default", 0)
def _missing() -> tuple[Any, bool]:
if required:
return None, True
return default, False
key = mapping.get("key") or var_name key = mapping.get("key") or var_name
raw = dims.get(key) raw = dims.get(key)
if raw is None or raw == "": if raw is None or raw == "":
@@ -587,12 +635,26 @@ class FormulaEngine:
return to_decimal(matrix[raw_key]), False, None return to_decimal(matrix[raw_key]), False, None
except Exception: except Exception:
return matrix[raw_key], False, None return matrix[raw_key], False, None
# matrix 未命中:若 required=true 则仍视为缺失;否则使用 default
if required: if required:
return None, True, None return None, True, None
return default, False, None return default, False, None
if source == "tiered": def _resolve_tiered(
self,
var_name: str,
mapping: dict[str, Any],
dims: dict[str, Any],
) -> tuple[Any, bool, dict[str, Any] | None]:
"""解析 tiered source按阶梯匹配值"""
required = bool(mapping.get("required", False))
allow_zero = bool(mapping.get("allow_zero", False))
default = mapping.get("default", 0)
def _missing() -> tuple[Any, bool]:
if required:
return None, True
return default, False
tier_key = mapping.get("tier_key") tier_key = mapping.get("tier_key")
if not tier_key: if not tier_key:
v, m = _missing() v, m = _missing()
@@ -675,9 +737,6 @@ class FormulaEngine:
return value, False, {"tier_index": len(tiers) - 1, "tier_info": dict(last)} return value, False, {"tier_index": len(tiers) - 1, "tier_info": dict(last)}
return default, False, None return default, False, None
# 未知 source视为配置错误但不直接中断计费返回 default
return default, False, None
def _resolve_ttl_pricing( def _resolve_ttl_pricing(
self, self,
ttl_pricing: list[Any], ttl_pricing: list[Any],

View File

@@ -93,6 +93,10 @@ class CacheAffinityManager:
self._memory_lock: asyncio.Lock | None = None self._memory_lock: asyncio.Lock | None = None
# L1 缓存(即使使用 Redis 也启用,减少网络往返) # L1 缓存(即使使用 Redis 也启用,减少网络往返)
# 注意L1 是本地进程内缓存多实例部署时存在短暂不一致窗口TTL 秒级)。
# 当前 TTL 默认 3 秒,对于亲和性路由来说可接受:最坏情况是短暂路由到
# 旧 provider下次请求即可自动修正。如果需要严格一致性将 TTL 设为 0
# 以禁用 L1 缓存,或通过 CacheSyncService 接收 pub/sub 主动失效。
self._l1_cache_ttl = int(os.getenv("CACHE_AFFINITY_L1_TTL", str(CacheTTL.L1_LOCAL))) self._l1_cache_ttl = int(os.getenv("CACHE_AFFINITY_L1_TTL", str(CacheTTL.L1_LOCAL)))
self._l1_cache: dict[str, tuple[float, dict[str, Any]]] = {} self._l1_cache: dict[str, tuple[float, dict[str, Any]]] = {}
self._l1_lock = asyncio.Lock() self._l1_lock = asyncio.Lock()

View File

@@ -97,17 +97,16 @@ class LocalCache(BaseCacheBackend):
# 如果键已存在,更新访问顺序 # 如果键已存在,更新访问顺序
if key in self._cache: if key in self._cache:
self._cache.move_to_end(key) self._cache.move_to_end(key)
elif len(self._cache) >= self._max_size:
self._cache[key] = value # 插入新键前淘汰最旧项,确保容量不超过 max_size
self._expiry[key] = time.time() + ttl
# 检查容量限制,淘汰最旧项
if len(self._cache) > self._max_size:
oldest_key = next(iter(self._cache)) oldest_key = next(iter(self._cache))
del self._cache[oldest_key] del self._cache[oldest_key]
if oldest_key in self._expiry: if oldest_key in self._expiry:
del self._expiry[oldest_key] del self._expiry[oldest_key]
self._cache[key] = value
self._expiry[key] = time.time() + ttl
async def delete(self, key: str) -> None: async def delete(self, key: str) -> None:
"""删除缓存值(线程安全)""" """删除缓存值(线程安全)"""
async with self._lock: async with self._lock:
@@ -276,6 +275,7 @@ class RedisCache(BaseCacheBackend):
# 缓存后端工厂 # 缓存后端工厂
_cache_backends: dict[str, BaseCacheBackend] = {} _cache_backends: dict[str, BaseCacheBackend] = {}
_cache_backend_lock = asyncio.Lock()
async def get_cache_backend( async def get_cache_backend(
@@ -295,36 +295,44 @@ async def get_cache_backend(
""" """
cache_key = f"{name}:{backend_type}" cache_key = f"{name}:{backend_type}"
# 无锁快路径
if cache_key in _cache_backends: if cache_key in _cache_backends:
return _cache_backends[cache_key] return _cache_backends[cache_key]
# 根据类型创建缓存后端 async with _cache_backend_lock:
# Double-check: 锁内再检查一次,避免重复创建
if cache_key in _cache_backends:
return _cache_backends[cache_key]
backend = _create_cache_backend(name, backend_type, max_size, ttl)
_cache_backends[cache_key] = backend
return backend
def _create_cache_backend(
name: str, backend_type: str, max_size: int, ttl: int
) -> BaseCacheBackend:
"""根据类型创建缓存后端实例"""
if backend_type == "redis": if backend_type == "redis":
# 尝试使用 Redis
redis_client = get_redis_client_sync() redis_client = get_redis_client_sync()
if redis_client is None: if redis_client is None:
logger.warning(f"[CacheBackend] Redis 未初始化,{name} 降级为本地缓存") logger.warning(f"[CacheBackend] Redis 未初始化,{name} 降级为本地缓存")
backend = LocalCache(max_size=max_size, default_ttl=ttl) return LocalCache(max_size=max_size, default_ttl=ttl)
else: else:
backend = RedisCache(redis_client=redis_client, key_prefix=name, default_ttl=ttl)
logger.info(f"[CacheBackend] {name} 使用 Redis 缓存") logger.info(f"[CacheBackend] {name} 使用 Redis 缓存")
return RedisCache(redis_client=redis_client, key_prefix=name, default_ttl=ttl)
elif backend_type == "local": elif backend_type == "local":
# 强制使用本地缓存
backend = LocalCache(max_size=max_size, default_ttl=ttl)
logger.info(f"[CacheBackend] {name} 使用本地缓存") logger.info(f"[CacheBackend] {name} 使用本地缓存")
return LocalCache(max_size=max_size, default_ttl=ttl)
else: # auto else: # auto
# 自动选择:优先 Redis降级到 Local
redis_client = get_redis_client_sync() redis_client = get_redis_client_sync()
if redis_client is not None: if redis_client is not None:
backend = RedisCache(redis_client=redis_client, key_prefix=name, default_ttl=ttl)
logger.debug(f"[CacheBackend] {name} 自动选择 Redis 缓存") logger.debug(f"[CacheBackend] {name} 自动选择 Redis 缓存")
return RedisCache(redis_client=redis_client, key_prefix=name, default_ttl=ttl)
else: else:
backend = LocalCache(max_size=max_size, default_ttl=ttl)
logger.debug(f"[CacheBackend] {name} 自动选择本地缓存Redis 不可用)") logger.debug(f"[CacheBackend] {name} 自动选择本地缓存Redis 不可用)")
return LocalCache(max_size=max_size, default_ttl=ttl)
_cache_backends[cache_key] = backend
return backend

View File

@@ -110,21 +110,24 @@ class CacheSyncService:
logger.debug(f"[CacheSync] 注册处理器: {channel}") logger.debug(f"[CacheSync] 注册处理器: {channel}")
async def _listen(self) -> None: async def _listen(self) -> None:
"""监听 Redis pub/sub 消息""" """监听 Redis pub/sub 消息(含断线重连)"""
logger.info("[CacheSync] 开始监听缓存失效消息") logger.info("[CacheSync] 开始监听缓存失效消息")
consecutive_failures = 0
max_consecutive_failures = 10
reconnect_interval = 5.0
while self._running:
try: try:
async for message in self._pubsub.listen(): async for message in self._pubsub.listen():
consecutive_failures = 0 # 收到消息即重置
if message["type"] == "message": if message["type"] == "message":
channel = message["channel"] channel = message["channel"]
data = message["data"] data = message["data"]
# 解析消息
try: try:
payload = json.loads(data) payload = json.loads(data)
logger.debug(f"[CacheSync] 收到消息: {channel} -> {payload}") logger.debug(f"[CacheSync] 收到消息: {channel} -> {payload}")
# 调用注册的处理器
if channel in self._handlers: if channel in self._handlers:
handler = self._handlers[channel] handler = self._handlers[channel]
await handler(payload) await handler(payload)
@@ -136,8 +139,16 @@ class CacheSyncService:
logger.error(f"[CacheSync] 处理消息失败: {channel}, 错误: {e}") logger.error(f"[CacheSync] 处理消息失败: {channel}, 错误: {e}")
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info("[CacheSync] 监听任务已取消") logger.info("[CacheSync] 监听任务已取消")
return
except Exception as e: except Exception as e:
logger.error(f"[CacheSync] 监听失败: {e}") consecutive_failures += 1
logger.error(
f"[CacheSync] 监听失败 ({consecutive_failures}/{max_consecutive_failures}): {e}"
)
if consecutive_failures >= max_consecutive_failures:
logger.error("[CacheSync] 连续失败次数过多,停止重连")
return
await asyncio.sleep(reconnect_interval)
async def publish_global_model_changed(self, model_name: str) -> Any: async def publish_global_model_changed(self, model_name: str) -> Any:
"""发布 GlobalModel 变更通知""" """发布 GlobalModel 变更通知"""
@@ -154,13 +165,19 @@ class CacheSyncService:
await self._publish(self.CHANNEL_CLEAR_ALL, {}) await self._publish(self.CHANNEL_CLEAR_ALL, {})
async def _publish(self, channel: str, data: dict) -> None: async def _publish(self, channel: str, data: dict) -> None:
"""发布消息到 Redis 频道""" """发布消息到 Redis 频道(含简单重试)"""
try:
message = json.dumps(data) message = json.dumps(data)
last_error: Exception | None = None
for attempt in range(2):
try:
await self._redis.publish(channel, message) await self._redis.publish(channel, message)
logger.debug(f"[CacheSync] 发布消息: {channel} -> {data}") logger.debug(f"[CacheSync] 发布消息: {channel} -> {data}")
return
except Exception as e: except Exception as e:
logger.error(f"[CacheSync] 发布消息失败: {channel}, 错误: {e}") last_error = e
if attempt == 0:
await asyncio.sleep(0.5)
logger.error(f"[CacheSync] 发布消息失败(已重试): {channel}, 错误: {last_error}")
# 全局单例 # 全局单例

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import re import re
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, AsyncIterator from typing import Any, AsyncIterator
@@ -29,6 +30,16 @@ _SENSITIVE_PATTERN = re.compile(
) )
@dataclass
class AttemptErrorOutcome:
"""_handle_attempt_error 的返回结果"""
action: FailoverAction
last_status_code: int | None
max_retries: int
stop_result: ExecutionResult | None = None
class FailoverEngine: class FailoverEngine:
""" """
FailoverEngine executes candidate attempts under policies. FailoverEngine executes candidate attempts under policies.
@@ -186,23 +197,7 @@ class FailoverEngine:
record_id=record_id, record_id=record_id,
) )
# Mark success-like status self._record_attempt_success(record_id, attempt_result)
if record_id:
if attempt_result.kind == AttemptKind.STREAM:
# For streaming, mark "streaming" (final status is recorded elsewhere).
self._update_record(
record_id,
status="streaming",
status_code=attempt_result.http_status,
)
else:
self._update_record(
record_id,
status="success",
status_code=attempt_result.http_status,
finished_at=datetime.now(timezone.utc),
)
self.db.commit()
# PRE_EXPAND: mark unused slots after request ends (success) # PRE_EXPAND: mark unused slots after request ends (success)
if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map: if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map:
@@ -234,93 +229,32 @@ class FailoverEngine:
) )
except StreamProbeError as exc: except StreamProbeError as exc:
# Probe failed (before first chunk) => eligible for failover
last_status_code = exc.http_status last_status_code = exc.http_status
if record_id: self._record_attempt_failure(record_id, exc, exc.http_status)
self._update_record(
record_id,
status="failed",
status_code=exc.http_status,
error_type=type(exc).__name__,
error_message=self._sanitize(str(exc)),
finished_at=datetime.now(timezone.utc),
)
self.db.commit()
action = FailoverAction.CONTINUE action = FailoverAction.CONTINUE
except Exception as exc: except Exception as exc:
has_retry_left = retry_index + 1 < max_retries outcome = await self._handle_attempt_error(
exc,
# If caller provides an execution_error_handler, prefer it for RequestExecutor's ExecutionError.
handler_used = False
if execution_error_handler is not None:
try:
from src.services.request.executor import (
ExecutionError as _ExecutionError,
)
if isinstance(exc, _ExecutionError):
handler_used = True
action, new_max_retries = await execution_error_handler(
exec_err=exc,
candidate=candidate, candidate=candidate,
candidate_index=candidate_index, candidate_index=candidate_index,
retry_index=retry_index, retry_index=retry_index,
max_retries_for_candidate=max_retries, max_retries=max_retries,
record_id=record_id, record_id=record_id,
attempt_count=attempt_count, attempt_count=attempt_count,
max_attempts=max_attempts, max_attempts=max_attempts,
) execution_error_handler=execution_error_handler,
if new_max_retries is not None: retry_policy=retry_policy,
max_retries = max(max_retries, int(new_max_retries))
except Exception:
# Fall back to internal handler below.
handler_used = False
if not handler_used:
action = await self._handle_error(
exc,
candidate=candidate,
has_retry_left=has_retry_left,
)
last_status_code = int(getattr(exc, "status_code", 0) or 0) or int(
getattr(exc, "http_status", 0) or 0
)
if record_id:
self._update_record(
record_id,
status="failed",
status_code=last_status_code or None,
error_type=type(exc).__name__,
error_message=self._sanitize(str(exc)),
finished_at=datetime.now(timezone.utc),
)
self.db.commit()
if action == FailoverAction.STOP:
# PRE_EXPAND: STOP ends the request => mark remaining slots unused.
if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map:
self._mark_remaining_slots_unused(
candidate_record_map=candidate_record_map, candidate_record_map=candidate_record_map,
candidates=candidates, candidates=candidates,
success_candidate_idx=candidate_index,
success_retry_idx=retry_index,
retry_policy=retry_policy,
)
return ExecutionResult(
success=False,
error_type=type(exc).__name__,
error_message=self._sanitize(str(exc)),
last_status_code=last_status_code or None,
candidate_keys=self._get_candidate_keys(
request_id=request_id, request_id=request_id,
fallback=candidate_keys_fallback, candidate_keys_fallback=candidate_keys_fallback,
candidates=candidates,
),
attempt_count=attempt_count,
) )
action = outcome.action
last_status_code = outcome.last_status_code
max_retries = outcome.max_retries
if outcome.stop_result is not None:
return outcome.stop_result
# action switch: continue/ retry # action switch: continue/ retry
if action == FailoverAction.CONTINUE: if action == FailoverAction.CONTINUE:
@@ -357,6 +291,138 @@ class FailoverEngine:
attempt_count=attempt_count, attempt_count=attempt_count,
) )
def _record_attempt_success(self, record_id: str | None, attempt_result: AttemptResult) -> None:
"""Mark attempt record as success/streaming."""
if not record_id:
return
if attempt_result.kind == AttemptKind.STREAM:
self._update_record(
record_id,
status="streaming",
status_code=attempt_result.http_status,
)
else:
self._update_record(
record_id,
status="success",
status_code=attempt_result.http_status,
finished_at=datetime.now(timezone.utc),
)
self.db.commit()
def _record_attempt_failure(
self, record_id: str | None, exc: Exception, status_code: int | None = None
) -> None:
"""Mark attempt record as failed."""
if not record_id:
return
self._update_record(
record_id,
status="failed",
status_code=status_code,
error_type=type(exc).__name__,
error_message=self._sanitize(str(exc)),
finished_at=datetime.now(timezone.utc),
)
self.db.commit()
async def _handle_attempt_error(
self,
exc: Exception,
*,
candidate: ProviderCandidate,
candidate_index: int,
retry_index: int,
max_retries: int,
record_id: str | None,
attempt_count: int,
max_attempts: int | None,
execution_error_handler: Any,
retry_policy: RetryPolicy,
candidate_record_map: dict[tuple[int, int], str] | None,
candidates: list[ProviderCandidate],
request_id: str | None,
candidate_keys_fallback: list[CandidateKey],
) -> AttemptErrorOutcome:
"""
Handle attempt exception: delegate to external/internal handler, update records.
Returns:
AttemptErrorOutcome; stop_result is non-None only when action==STOP.
"""
has_retry_left = retry_index + 1 < max_retries
# If caller provides an execution_error_handler, prefer it for ExecutionError.
handler_used = False
action = FailoverAction.CONTINUE
if execution_error_handler is not None:
try:
from src.services.request.executor import ExecutionError as _ExecutionError
if isinstance(exc, _ExecutionError):
handler_used = True
action, new_max_retries = await execution_error_handler(
exec_err=exc,
candidate=candidate,
candidate_index=candidate_index,
retry_index=retry_index,
max_retries_for_candidate=max_retries,
record_id=record_id,
attempt_count=attempt_count,
max_attempts=max_attempts,
)
if new_max_retries is not None:
max_retries = max(max_retries, int(new_max_retries))
except Exception:
handler_used = False
last_status_code: int | None = None
if not handler_used:
action = await self._handle_error(
exc,
candidate=candidate,
has_retry_left=has_retry_left,
)
last_status_code = int(getattr(exc, "status_code", 0) or 0) or int(
getattr(exc, "http_status", 0) or 0
)
self._record_attempt_failure(record_id, exc, last_status_code or None)
if action == FailoverAction.STOP:
if retry_policy.mode == RetryMode.PRE_EXPAND and candidate_record_map:
self._mark_remaining_slots_unused(
candidate_record_map=candidate_record_map,
candidates=candidates,
success_candidate_idx=candidate_index,
success_retry_idx=retry_index,
retry_policy=retry_policy,
)
return AttemptErrorOutcome(
action=action,
last_status_code=last_status_code,
max_retries=max_retries,
stop_result=ExecutionResult(
success=False,
error_type=type(exc).__name__,
error_message=self._sanitize(str(exc)),
last_status_code=last_status_code or None,
candidate_keys=self._get_candidate_keys(
request_id=request_id,
fallback=candidate_keys_fallback,
candidates=candidates,
),
attempt_count=attempt_count,
),
)
return AttemptErrorOutcome(
action=action,
last_status_code=last_status_code,
max_retries=max_retries,
)
def _sanitize(self, message: str, max_length: int = 200) -> str: def _sanitize(self, message: str, max_length: int = 200) -> str:
if not message: if not message:
return "request_failed" return "request_failed"

View File

@@ -4,16 +4,19 @@ Orchestration 模块
提供请求编排相关的组件: 提供请求编排相关的组件:
- CandidateResolver: 候选解析器,负责获取和排序可用的 Provider 组合 - CandidateResolver: 候选解析器,负责获取和排序可用的 Provider 组合
- RequestDispatcher: 请求分发器,负责执行单个候选请求 - RequestDispatcher: 请求分发器,负责执行单个候选请求
- ErrorClassifier: 错误分类器,负责错误分类和处理策略 - ErrorClassifier: 错误分类器,负责错误分类(纯逻辑,无副作用)
- ErrorHandlerService: 错误处理服务,负责错误后的副作用(缓存失效、健康记录等)
""" """
from .candidate_resolver import CandidateResolver from .candidate_resolver import CandidateResolver
from .error_classifier import ErrorAction, ErrorClassifier from .error_classifier import ErrorAction, ErrorClassifier
from .error_handler import ErrorHandlerService
from .request_dispatcher import RequestDispatcher from .request_dispatcher import RequestDispatcher
__all__ = [ __all__ = [
"CandidateResolver", "CandidateResolver",
"RequestDispatcher", "RequestDispatcher",
"ErrorClassifier", "ErrorClassifier",
"ErrorHandlerService",
"ErrorAction", "ErrorAction",
] ]

View File

@@ -13,8 +13,6 @@ from typing import Any
import httpx import httpx
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.core.api_format.signature import make_signature_key
from src.core.crypto import CryptoService
from src.core.exceptions import ( from src.core.exceptions import (
ConcurrencyLimitError, ConcurrencyLimitError,
ProviderAuthException, ProviderAuthException,
@@ -28,10 +26,8 @@ from src.core.exceptions import (
from src.core.logger import logger from src.core.logger import logger
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
from src.services.cache.aware_scheduler import CacheAwareScheduler from src.services.cache.aware_scheduler import CacheAwareScheduler
from src.services.health.monitor import health_monitor from src.services.orchestration.error_handler import ErrorHandlerService
from src.services.provider.format import normalize_endpoint_signature
from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager
from src.services.rate_limit.detector import RateLimitType, detect_rate_limit_type
class ErrorAction(Enum): class ErrorAction(Enum):
@@ -117,37 +113,11 @@ class ErrorClassifier:
self.db = db self.db = db
self.adaptive_manager = adaptive_manager or get_adaptive_rpm_manager() self.adaptive_manager = adaptive_manager or get_adaptive_rpm_manager()
self.cache_scheduler = cache_scheduler self.cache_scheduler = cache_scheduler
self._error_handler = ErrorHandlerService(
def _extract_oauth_email(self, key: ProviderAPIKey | None) -> str | None: db=db,
if not key or str(getattr(key, "auth_type", "") or "").lower() != "oauth": adaptive_manager=self.adaptive_manager,
return None cache_scheduler=cache_scheduler,
encrypted_auth_config = getattr(key, "auth_config", None) )
if not encrypted_auth_config:
return None
try:
decrypted = CryptoService().decrypt(encrypted_auth_config, silent=True)
auth_config = json.loads(decrypted) if decrypted else {}
except Exception:
return None
email = auth_config.get("email")
if isinstance(email, str):
email = email.strip()
if email:
return email
return None
def _format_key_display(self, key: ProviderAPIKey | None) -> str:
if not key:
return "key=unknown"
key_id = str(getattr(key, "id", "") or "")[:8] or "unknown"
name = str(getattr(key, "name", "") or "").strip()
email = self._extract_oauth_email(key)
parts = [f"key={key_id}"]
if email:
parts.append(f"email={email}")
if name and name != email:
parts.append(f"name={name}")
return " ".join(parts)
# 表示客户端错误的 error type不区分大小写 # 表示客户端错误的 error type不区分大小写
# 这些 type 表明是请求本身的问题,不应重试 # 这些 type 表明是请求本身的问题,不应重试
@@ -376,38 +346,6 @@ class ErrorClassifier:
search_text = error_text.lower() search_text = error_text.lower()
return any(p.lower() in search_text for p in self.THINKING_ERROR_PATTERNS) return any(p.lower() in search_text for p in self.THINKING_ERROR_PATTERNS)
def _is_account_validation_required(self, error_text: str | None) -> bool:
"""
检测 403 错误是否为 Google 账号验证要求 (VALIDATION_REQUIRED)
Google 会在某些情况下要求账号所有者手动完成人机验证,
此时所有 API 请求都会返回 403 + VALIDATION_REQUIRED。
这是账号级别的永久性错误,重试无法修复,需要人工干预。
匹配条件(满足任一即可):
- error.details 中包含 reason=VALIDATION_REQUIRED
- error.status 为 PERMISSION_DENIED 且 message 包含 "verify your account"
- error.message 包含 "verify your account"
Args:
error_text: 错误响应文本
Returns:
是否为账号验证要求错误
"""
if not error_text:
return False
search_text = error_text.lower()
# 快速路径:关键词匹配
if "validation_required" in search_text:
return True
if "verify your account" in search_text and "permission_denied" in search_text:
return True
return False
def _extract_error_message(self, error_text: str | None) -> str | None: def _extract_error_message(self, error_text: str | None) -> str | None:
""" """
从错误响应中提取错误消息 从错误响应中提取错误消息
@@ -480,67 +418,15 @@ class ErrorClassifier:
exception: ProviderRateLimitException, exception: ProviderRateLimitException,
request_id: str | None = None, request_id: str | None = None,
) -> str: ) -> str:
""" """委托给 ErrorHandlerService"""
处理 429 速率限制错误的自适应调整 return await self._error_handler.handle_rate_limit(
Args:
key: API Key 对象
provider_name: 提供商名称
current_rpm: 当前分钟内的请求数
exception: 速率限制异常
request_id: 请求 ID用于日志
Returns:
限制类型: "concurrent""rpm""unknown"
"""
try:
# 提取响应头(如果有)
response_headers = {}
if hasattr(exception, "response_headers"):
response_headers = exception.response_headers or {}
# 检测速率限制类型
rate_limit_info = detect_rate_limit_type(
headers=response_headers,
provider_name=provider_name,
current_usage=current_rpm,
)
logger.info(
f" [{request_id}] 429错误分析: "
f"类型={rate_limit_info.limit_type}, "
f"retry_after={rate_limit_info.retry_after}s, "
f"当前RPM={current_rpm}"
)
# 调用自适应管理器处理
new_limit = self.adaptive_manager.handle_429_error(
db=self.db,
key=key, key=key,
rate_limit_info=rate_limit_info, provider_name=provider_name,
current_rpm=current_rpm, current_rpm=current_rpm,
exception=exception,
request_id=request_id,
) )
if rate_limit_info.limit_type == RateLimitType.CONCURRENT:
logger.warning(f" [{request_id}] 并发限制触发不调整RPM")
return "concurrent"
elif rate_limit_info.limit_type == RateLimitType.RPM:
if new_limit is not None:
logger.warning(
f" [{request_id}] 自适应调整: Key {key.id[:8]}... RPM限制 -> {new_limit}"
)
else:
logger.info(
f" [{request_id}] 学习中: Key {key.id[:8]}... 观察已记录,暂不设限"
)
return "rpm"
else:
return "unknown"
except Exception as e:
logger.exception(f" [{request_id}] 处理429错误时异常: {e}")
return "unknown"
def convert_http_error( def convert_http_error(
self, self,
error: httpx.HTTPStatusError, error: httpx.HTTPStatusError,
@@ -650,32 +536,10 @@ class ErrorClassifier:
attempt: int, attempt: int,
max_attempts: int, max_attempts: int,
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """处理 HTTP 错误,返回 extra_data分类 + 委托副作用给 ErrorHandlerService"""
处理 HTTP 错误,返回 extra_data
Args:
http_error: HTTP 状态错误
provider: Provider 对象
endpoint: Endpoint 对象
key: API Key 对象
affinity_key: 亲和性标识符(通常为 API Key ID
api_format: API 格式
global_model_id: GlobalModel ID规范化的模型标识
request_id: 请求 ID
captured_key_concurrent: 捕获的并发数
elapsed_ms: 耗时(毫秒)
attempt: 当前尝试次数
max_attempts: 最大尝试次数
Returns:
Dict[str, Any]: 额外数据,包含:
- error_response: 错误响应文本(如有)
- converted_error: 转换后的异常对象(用于判断是否应该重试)
"""
provider_name = str(provider.name) provider_name = str(provider.name)
# 尝试读取错误响应内容 # 尝试读取错误响应内容
# 优先使用 handler 附加的 upstream_response 属性(流式请求中 response.text 可能为空)
error_response_text = getattr(http_error, "upstream_response", None) error_response_text = getattr(http_error, "upstream_response", None)
if not error_response_text: if not error_response_text:
try: try:
@@ -689,110 +553,34 @@ class ErrorClassifier:
f"{http_error.response.status_code if http_error.response else 'unknown'}" f"{http_error.response.status_code if http_error.response else 'unknown'}"
) )
# 分类(纯逻辑)
converted_error = self.convert_http_error(http_error, provider_name, error_response_text) converted_error = self.convert_http_error(http_error, provider_name, error_response_text)
# 构建 extra_data包含转换后的异常
extra_data: dict[str, Any] = { extra_data: dict[str, Any] = {
"converted_error": converted_error, "converted_error": converted_error,
} }
if error_response_text: if error_response_text:
extra_data["error_response"] = error_response_text extra_data["error_response"] = error_response_text
# client_format用于缓存亲和性/缓存失效(用户视角)
client_format_str = normalize_endpoint_signature(api_format)
# provider_format用于健康度/熔断 bucketProvider 真实端点格式)
fam = str(getattr(endpoint, "api_family", "")).strip().lower()
kind = str(getattr(endpoint, "endpoint_kind", "")).strip().lower()
provider_format_str = make_signature_key(fam, kind) if fam and kind else client_format_str
# 处理客户端请求错误(不应重试,不失效缓存,不记录健康失败)
if isinstance(converted_error, UpstreamClientException): if isinstance(converted_error, UpstreamClientException):
logger.warning( logger.warning(
f" [{request_id}] 客户端请求错误,不进行重试: {converted_error.message}" f" [{request_id}] 客户端请求错误,不进行重试: {converted_error.message}"
) )
return extra_data return extra_data
# 处理认证错误 # 副作用(委托给 ErrorHandlerService
if isinstance(converted_error, ProviderAuthException): await self._error_handler.handle_http_error(
if endpoint and key and self.cache_scheduler is not None: http_error,
await self.cache_scheduler.invalidate_cache( converted_error,
affinity_key=affinity_key, error_response_text,
api_format=client_format_str, provider=provider,
global_model_id=global_model_id, endpoint=endpoint,
endpoint_id=str(endpoint.id),
key_id=str(key.id),
)
if key:
health_monitor.record_failure(
db=self.db,
key_id=str(key.id),
api_format=provider_format_str,
error_type="ProviderAuthException",
)
# 403 VALIDATION_REQUIRED → 标记 OAuth key 为账号级别封禁
# 这与 test-model 端点的行为对齐provider_query.py 第 669-690 行)
status_code = http_error.response.status_code if http_error.response else None
if (
status_code == 403
and key
and str(getattr(key, "auth_type", "") or "").lower() == "oauth"
and self._is_account_validation_required(error_response_text)
):
try:
from datetime import datetime, timezone
from src.services.provider.oauth_token import (
OAUTH_ACCOUNT_BLOCK_PREFIX,
)
key.oauth_invalid_at = datetime.now(timezone.utc)
key.oauth_invalid_reason = f"{OAUTH_ACCOUNT_BLOCK_PREFIX}Google 要求验证账号"
key.is_active = False
self.db.commit()
logger.warning(
" [{}] {} 因 403 VALIDATION_REQUIRED 已标记为账号异常并自动停用",
request_id,
self._format_key_display(key),
)
except Exception as mark_exc:
logger.debug(" [{}] 标记 oauth_invalid 失败: {}", request_id, mark_exc)
return extra_data
# 处理限流错误
if isinstance(converted_error, ProviderRateLimitException) and key:
await self.handle_rate_limit(
key=key, key=key,
provider_name=provider_name, affinity_key=affinity_key,
current_rpm=captured_key_concurrent, api_format=api_format,
exception=converted_error, global_model_id=global_model_id,
request_id=request_id, request_id=request_id,
) captured_key_concurrent=captured_key_concurrent,
if endpoint and self.cache_scheduler is not None:
await self.cache_scheduler.invalidate_cache(
affinity_key=affinity_key,
api_format=client_format_str,
global_model_id=global_model_id,
endpoint_id=str(endpoint.id),
key_id=str(key.id),
)
else:
# 其他错误也失效缓存
if endpoint and key and self.cache_scheduler is not None:
await self.cache_scheduler.invalidate_cache(
affinity_key=affinity_key,
api_format=client_format_str,
global_model_id=global_model_id,
endpoint_id=str(endpoint.id),
key_id=str(key.id),
)
# 记录健康失败
if key:
health_monitor.record_failure(
db=self.db,
key_id=str(key.id),
api_format=provider_format_str,
error_type=type(converted_error).__name__,
) )
return extra_data return extra_data
@@ -813,69 +601,20 @@ class ErrorClassifier:
attempt: int, attempt: int,
max_attempts: int, max_attempts: int,
) -> None: ) -> None:
""" """委托给 ErrorHandlerService"""
处理可重试错误
Args:
error: 异常对象
provider: Provider 对象
endpoint: Endpoint 对象
key: API Key 对象
affinity_key: 亲和性标识符(通常为 API Key ID
api_format: API 格式
global_model_id: GlobalModel ID规范化的模型标识用于缓存亲和性
captured_key_concurrent: 捕获的并发数
elapsed_ms: 耗时(毫秒)
request_id: 请求 ID
attempt: 当前尝试次数
max_attempts: 最大尝试次数
"""
provider_name = str(provider.name)
logger.warning( logger.warning(
f" [{request_id}] 请求失败 (attempt={attempt}/{max_attempts}): " f" [{request_id}] 请求失败 (attempt={attempt}/{max_attempts}): "
f"{type(error).__name__}: {str(error)}" f"{type(error).__name__}: {str(error)}"
) )
# client_format用于缓存亲和性/缓存失效(用户视角) await self._error_handler.handle_retriable_error(
client_format_str = normalize_endpoint_signature(api_format) error,
# provider_format用于健康度/熔断 bucketProvider 真实端点格式) provider=provider,
fam = str(getattr(endpoint, "api_family", "")).strip().lower() endpoint=endpoint,
kind = str(getattr(endpoint, "endpoint_kind", "")).strip().lower()
provider_format_str = make_signature_key(fam, kind) if fam and kind else client_format_str
# 处理限流错误
if isinstance(error, ProviderRateLimitException) and key:
await self.handle_rate_limit(
key=key, key=key,
provider_name=provider_name, affinity_key=affinity_key,
current_rpm=captured_key_concurrent, api_format=api_format,
exception=error, global_model_id=global_model_id,
captured_key_concurrent=captured_key_concurrent,
request_id=request_id, request_id=request_id,
) )
if endpoint and self.cache_scheduler is not None:
await self.cache_scheduler.invalidate_cache(
affinity_key=affinity_key,
api_format=client_format_str,
global_model_id=global_model_id,
endpoint_id=str(endpoint.id),
key_id=str(key.id),
)
elif endpoint and key and self.cache_scheduler is not None:
# 其他错误也失效缓存
await self.cache_scheduler.invalidate_cache(
affinity_key=affinity_key,
api_format=client_format_str,
global_model_id=global_model_id,
endpoint_id=str(endpoint.id),
key_id=str(key.id),
)
# 记录健康失败
if key:
health_monitor.record_failure(
db=self.db,
key_id=str(key.id),
api_format=provider_format_str,
error_type=type(error).__name__,
)

View File

@@ -0,0 +1,330 @@
"""
错误处理服务
负责错误发生后的副作用操作缓存失效、健康记录、RPM 调整、OAuth Key 标记等)。
与 ErrorClassifier纯分类无副作用分离遵循单一职责原则。
"""
from __future__ import annotations
import json
from typing import Any
import httpx
from sqlalchemy.orm import Session
from src.core.api_format.signature import make_signature_key
from src.core.crypto import CryptoService
from src.core.exceptions import (
ProviderAuthException,
ProviderRateLimitException,
UpstreamClientException,
)
from src.core.logger import logger
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint
from src.services.cache.aware_scheduler import CacheAwareScheduler
from src.services.health.monitor import health_monitor
from src.services.provider.format import normalize_endpoint_signature
from src.services.rate_limit.adaptive_rpm import get_adaptive_rpm_manager
from src.services.rate_limit.detector import RateLimitType, detect_rate_limit_type
class ErrorHandlerService:
"""
错误处理服务 - 负责错误发生后的副作用操作
职责:
1. 缓存亲和性失效
2. 健康监控记录
3. 429 自适应 RPM 调整
4. OAuth Key 状态标记
"""
def __init__(
self,
db: Session,
adaptive_manager: Any | None = None,
cache_scheduler: CacheAwareScheduler | None = None,
) -> None:
self.db = db
self.adaptive_manager = adaptive_manager or get_adaptive_rpm_manager()
self.cache_scheduler = cache_scheduler
async def handle_rate_limit(
self,
key: ProviderAPIKey,
provider_name: str,
current_rpm: int | None,
exception: ProviderRateLimitException,
request_id: str | None = None,
) -> str:
"""
处理 429 速率限制错误的自适应调整
Returns:
限制类型: "concurrent""rpm""unknown"
"""
try:
response_headers = {}
if hasattr(exception, "response_headers"):
response_headers = exception.response_headers or {}
rate_limit_info = detect_rate_limit_type(
headers=response_headers,
provider_name=provider_name,
current_usage=current_rpm,
)
logger.info(
" [{}] 429错误分析: 类型={}, retry_after={}s, 当前RPM={}",
request_id,
rate_limit_info.limit_type,
rate_limit_info.retry_after,
current_rpm,
)
new_limit = self.adaptive_manager.handle_429_error(
db=self.db,
key=key,
rate_limit_info=rate_limit_info,
current_rpm=current_rpm,
)
if rate_limit_info.limit_type == RateLimitType.CONCURRENT:
logger.warning(" [{}] 并发限制触发不调整RPM", request_id)
return "concurrent"
elif rate_limit_info.limit_type == RateLimitType.RPM:
if new_limit is not None:
logger.warning(
" [{}] 自适应调整: Key {}... RPM限制 -> {}",
request_id,
str(key.id)[:8],
new_limit,
)
else:
logger.info(
" [{}] 学习中: Key {}... 观察已记录,暂不设限",
request_id,
str(key.id)[:8],
)
return "rpm"
else:
return "unknown"
except Exception as e:
logger.exception(" [{}] 处理429错误时异常: {}", request_id, e)
return "unknown"
async def handle_http_error(
self,
http_error: httpx.HTTPStatusError,
converted_error: Exception,
error_response_text: str | None,
*,
provider: Provider,
endpoint: ProviderEndpoint,
key: ProviderAPIKey,
affinity_key: str,
api_format: str,
global_model_id: str,
request_id: str | None,
captured_key_concurrent: int | None,
) -> None:
"""
处理 HTTP 错误的副作用缓存失效、健康记录、OAuth 标记)。
纯副作用方法:不返回分类结果,不做错误转换。
"""
client_format_str = normalize_endpoint_signature(api_format)
fam = str(getattr(endpoint, "api_family", "")).strip().lower()
kind = str(getattr(endpoint, "endpoint_kind", "")).strip().lower()
provider_format_str = make_signature_key(fam, kind) if fam and kind else client_format_str
# 客户端请求错误:不失效缓存,不记录健康失败
if isinstance(converted_error, UpstreamClientException):
return
can_invalidate = bool(endpoint and key and self.cache_scheduler is not None)
# 认证错误
if isinstance(converted_error, ProviderAuthException):
if can_invalidate:
await self._invalidate_cache(
affinity_key, client_format_str, global_model_id, endpoint, key
)
if key:
health_monitor.record_failure(
db=self.db,
key_id=str(key.id),
api_format=provider_format_str,
error_type="ProviderAuthException",
)
# 403 VALIDATION_REQUIRED -> 标记 OAuth key 为账号级别封禁
status_code = http_error.response.status_code if http_error.response else None
if (
status_code == 403
and key
and str(getattr(key, "auth_type", "") or "").lower() == "oauth"
and self._is_account_validation_required(error_response_text)
):
self._mark_oauth_key_blocked(key, request_id)
return
# 限流错误
if isinstance(converted_error, ProviderRateLimitException) and key:
await self.handle_rate_limit(
key=key,
provider_name=str(provider.name),
current_rpm=captured_key_concurrent,
exception=converted_error,
request_id=request_id,
)
# 所有非客户端错误均失效缓存
if can_invalidate:
await self._invalidate_cache(
affinity_key, client_format_str, global_model_id, endpoint, key
)
# 记录健康失败
if key:
health_monitor.record_failure(
db=self.db,
key_id=str(key.id),
api_format=provider_format_str,
error_type=type(converted_error).__name__,
)
async def handle_retriable_error(
self,
error: Exception,
*,
provider: Provider,
endpoint: ProviderEndpoint,
key: ProviderAPIKey,
affinity_key: str,
api_format: str,
global_model_id: str,
captured_key_concurrent: int | None,
request_id: str | None,
) -> None:
"""处理可重试错误的副作用(缓存失效、健康记录)"""
client_format_str = normalize_endpoint_signature(api_format)
fam = str(getattr(endpoint, "api_family", "")).strip().lower()
kind = str(getattr(endpoint, "endpoint_kind", "")).strip().lower()
provider_format_str = make_signature_key(fam, kind) if fam and kind else client_format_str
# 限流错误
if isinstance(error, ProviderRateLimitException) and key:
await self.handle_rate_limit(
key=key,
provider_name=str(provider.name),
current_rpm=captured_key_concurrent,
exception=error,
request_id=request_id,
)
# 失效缓存
if endpoint and key and self.cache_scheduler is not None:
await self._invalidate_cache(
affinity_key, client_format_str, global_model_id, endpoint, key
)
# 记录健康失败
if key:
health_monitor.record_failure(
db=self.db,
key_id=str(key.id),
api_format=provider_format_str,
error_type=type(error).__name__,
)
async def _invalidate_cache(
self,
affinity_key: str,
api_format: str,
global_model_id: str,
endpoint: ProviderEndpoint,
key: ProviderAPIKey,
) -> None:
"""失效缓存亲和性(调用方需确保 cache_scheduler 可用)"""
assert self.cache_scheduler is not None # noqa: S101
await self.cache_scheduler.invalidate_cache(
affinity_key=affinity_key,
api_format=api_format,
global_model_id=global_model_id,
endpoint_id=str(endpoint.id),
key_id=str(key.id),
)
@staticmethod
def _extract_oauth_email(key: ProviderAPIKey | None) -> str | None:
"""从 OAuth Key 的加密 auth_config 中提取邮箱"""
if not key or str(getattr(key, "auth_type", "") or "").lower() != "oauth":
return None
encrypted_auth_config = getattr(key, "auth_config", None)
if not encrypted_auth_config:
return None
try:
decrypted = CryptoService().decrypt(encrypted_auth_config, silent=True)
auth_config = json.loads(decrypted) if decrypted else {}
except Exception:
return None
email = auth_config.get("email")
if isinstance(email, str):
email = email.strip()
if email:
return email
return None
@classmethod
def _format_key_display(cls, key: ProviderAPIKey | None) -> str:
"""格式化 Key 显示信息(用于日志)"""
if not key:
return "key=unknown"
key_id = str(getattr(key, "id", "") or "")[:8] or "unknown"
name = str(getattr(key, "name", "") or "").strip()
email = cls._extract_oauth_email(key)
parts = [f"key={key_id}"]
if email:
parts.append(f"email={email}")
if name and name != email:
parts.append(f"name={name}")
return " ".join(parts)
@staticmethod
def _is_account_validation_required(error_text: str | None) -> bool:
"""
检测 403 错误是否为 Google 账号验证要求 (VALIDATION_REQUIRED)
匹配条件(满足任一即可):
- error.details 中包含 reason=VALIDATION_REQUIRED
- error.status 为 PERMISSION_DENIED 且 message 包含 "verify your account"
"""
if not error_text:
return False
search_text = error_text.lower()
if "validation_required" in search_text:
return True
if "verify your account" in search_text and "permission_denied" in search_text:
return True
return False
def _mark_oauth_key_blocked(self, key: ProviderAPIKey, request_id: str | None) -> None:
"""标记 OAuth key 为账号级别封禁"""
try:
from datetime import datetime, timezone
from src.services.provider.oauth_token import OAUTH_ACCOUNT_BLOCK_PREFIX
key.oauth_invalid_at = datetime.now(timezone.utc)
key.oauth_invalid_reason = f"{OAUTH_ACCOUNT_BLOCK_PREFIX}Google 要求验证账号"
key.is_active = False
self.db.commit()
logger.warning(
" [{}] {} 因 403 VALIDATION_REQUIRED 已标记为账号异常并自动停用",
request_id,
self._format_key_display(key),
)
except Exception as mark_exc:
logger.debug(" [{}] 标记 oauth_invalid 失败: {}", request_id, mark_exc)

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Any from typing import Any
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -79,3 +79,26 @@ class UsageRecordParams:
valid_statuses = {"pending", "streaming", "completed", "failed", "cancelled"} valid_statuses = {"pending", "streaming", "completed", "failed", "cancelled"}
if self.status not in valid_statuses: if self.status not in valid_statuses:
raise ValueError(f"无效的状态值: {self.status},有效值: {valid_statuses}") raise ValueError(f"无效的状态值: {self.status},有效值: {valid_statuses}")
@dataclass
class UsageCostInfo:
"""成本与价格信息,用于 _build_usage_params 参数封装"""
# 成本计算结果
input_cost: float = 0.0
output_cost: float = 0.0
cache_creation_cost: float = 0.0
cache_read_cost: float = 0.0
cache_cost: float = 0.0
request_cost: float = 0.0
total_cost: float = 0.0
# 价格信息
input_price: float | None = None
output_price: float | None = None
cache_creation_price: float | None = None
cache_read_price: float | None = None
request_price: float | None = None
# 倍率
actual_rate_multiplier: float = 1.0
is_free_tier: bool = False

View File

@@ -12,7 +12,7 @@ from src.core.logger import logger
from src.models.database import ApiKey, Provider, Usage, User from src.models.database import ApiKey, Provider, Usage, User
from src.services.billing.token_normalization import normalize_input_tokens_for_billing from src.services.billing.token_normalization import normalize_input_tokens_for_billing
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
from src.services.usage._types import UsageRecordParams from src.services.usage._types import UsageCostInfo, UsageRecordParams
from src.services.usage.error_classifier import classify_error from src.services.usage.error_classifier import classify_error
@@ -74,26 +74,26 @@ class UsageRecordingMixin:
provider_api_key_id: str | None, provider_api_key_id: str | None,
status: str, status: str,
target_model: str | None, target_model: str | None,
# 成本计算结果 cost: UsageCostInfo,
input_cost: float,
output_cost: float,
cache_creation_cost: float,
cache_read_cost: float,
cache_cost: float,
request_cost: float,
total_cost: float,
# 价格信息
input_price: float | None,
output_price: float | None,
cache_creation_price: float | None,
cache_read_price: float | None,
request_price: float | None,
# 倍率
actual_rate_multiplier: float,
is_free_tier: bool,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""构建 Usage 记录的参数字典(内部方法,避免代码重复)""" """构建 Usage 记录的参数字典(内部方法,避免代码重复)"""
# 展开成本信息
input_cost = cost.input_cost
output_cost = cost.output_cost
cache_creation_cost = cost.cache_creation_cost
cache_read_cost = cost.cache_read_cost
cache_cost = cost.cache_cost
request_cost = cost.request_cost
total_cost = cost.total_cost
input_price = cost.input_price
output_price = cost.output_price
cache_creation_price = cost.cache_creation_price
cache_read_price = cost.cache_read_price
request_price = cost.request_price
actual_rate_multiplier = cost.actual_rate_multiplier
is_free_tier = cost.is_free_tier
# 根据配置决定是否记录请求详情 # 根据配置决定是否记录请求详情
should_log_headers = SystemConfigService.should_log_headers(db) should_log_headers = SystemConfigService.should_log_headers(db)
should_log_body = SystemConfigService.should_log_body(db) should_log_body = SystemConfigService.should_log_body(db)
@@ -463,6 +463,7 @@ class UsageRecordingMixin:
provider_api_key_id=params.provider_api_key_id, provider_api_key_id=params.provider_api_key_id,
status=params.status, status=params.status,
target_model=params.target_model, target_model=params.target_model,
cost=UsageCostInfo(
input_cost=input_cost, input_cost=input_cost,
output_cost=output_cost, output_cost=output_cost,
cache_creation_cost=cache_creation_cost, cache_creation_cost=cache_creation_cost,
@@ -477,6 +478,7 @@ class UsageRecordingMixin:
request_price=request_price, request_price=request_price,
actual_rate_multiplier=actual_rate_multiplier, actual_rate_multiplier=actual_rate_multiplier,
is_free_tier=is_free_tier, is_free_tier=is_free_tier,
),
) )
return usage_params, total_cost return usage_params, total_cost
@@ -921,6 +923,7 @@ class UsageRecordingMixin:
provider_api_key_id=provider_api_key_id, provider_api_key_id=provider_api_key_id,
status=status, status=status,
target_model=target_model, target_model=target_model,
cost=UsageCostInfo(
input_cost=input_cost, input_cost=input_cost,
output_cost=output_cost, output_cost=output_cost,
cache_creation_cost=cache_creation_cost, cache_creation_cost=cache_creation_cost,
@@ -928,14 +931,9 @@ class UsageRecordingMixin:
cache_cost=cache_cost, cache_cost=cache_cost,
request_cost=request_cost, request_cost=request_cost,
total_cost=total_cost, total_cost=total_cost,
# token 价格对异步任务不适用,保持 None
input_price=None,
output_price=None,
cache_creation_price=None,
cache_read_price=None,
request_price=None,
actual_rate_multiplier=actual_rate_multiplier, actual_rate_multiplier=actual_rate_multiplier,
is_free_tier=is_free_tier, is_free_tier=is_free_tier,
),
) )
# Upsert并发幂等优先用 billing_status 作为结算闸门) # Upsert并发幂等优先用 billing_status 作为结算闸门)

View File

@@ -1,4 +1,6 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -8,7 +10,7 @@ from src.services.request.executor import RequestExecutor
@asynccontextmanager @asynccontextmanager
async def _noop_async_cm(): async def _noop_async_cm() -> AsyncGenerator[None]:
yield yield
@@ -46,7 +48,9 @@ async def test_executor_records_health_by_provider_format() -> None:
candidate.key = key candidate.key = key
candidate.is_cached = False candidate.is_cached = False
async def request_func(_provider, _endpoint, _key, _candidate): # noqa: ANN001 async def request_func(
_provider: Any, _endpoint: Any, _key: Any, _candidate: Any
) -> dict[str, bool]:
return {"ok": True} return {"ok": True}
with ( with (
@@ -96,7 +100,7 @@ async def test_error_classifier_records_failure_by_provider_format() -> None:
key.id = "k1" key.id = "k1"
with patch( with patch(
"src.services.orchestration.error_classifier.health_monitor.record_failure" "src.services.orchestration.error_handler.health_monitor.record_failure"
) as record_failure: ) as record_failure:
await classifier.handle_retriable_error( await classifier.handle_retriable_error(
error=RuntimeError("boom"), error=RuntimeError("boom"),