feat: 添加提供商格式转换优先级保持配置

- 新增 Provider.keep_priority_on_conversion 字段,控制格式转换时是否保持优先级
- 新增全局配置 KEEP_PRIORITY_ON_CONVERSION,可全局启用优先级保持
- 调度器根据配置决定是否降级需要格式转换的候选
- 前端 Provider 表单添加"保持优先级"开关
- 调整 HTTP 读写超时默认值为 3600 秒
This commit is contained in:
fawney19
2026-01-28 15:53:33 +08:00
parent 5e81a0b581
commit bae278a0c1
8 changed files with 212 additions and 27 deletions

View File

@@ -0,0 +1,58 @@
"""add_keep_priority_on_conversion_to_providers
Revision ID: 364680d1bc99
Revises: f7c8d9e0a1b2
Create Date: 2026-01-28 12:00:00+00:00
Changes:
1. providers 表: 添加 keep_priority_on_conversion 字段
- 格式转换时是否保持提供商原优先级
- 默认 False需要格式转换时候选会被降级到不需要转换的候选之后
- 设为 True即使需要格式转换也保持原优先级排名
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy import inspect
# revision identifiers, used by Alembic.
revision = "364680d1bc99"
down_revision = "f7c8d9e0a1b2"
branch_labels = None
depends_on = None
def table_exists(table_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
return table_name in inspector.get_table_names()
def column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns(table_name)]
return column_name in columns
def upgrade() -> None:
# === providers 表: 添加格式转换优先级保持配置 ===
if table_exists("providers"):
if not column_exists("providers", "keep_priority_on_conversion"):
op.add_column(
"providers",
sa.Column(
"keep_priority_on_conversion",
sa.Boolean(),
nullable=False,
server_default="false",
),
)
def downgrade() -> None:
# === providers 表: 移除格式转换优先级保持配置 ===
if table_exists("providers"):
if column_exists("providers", "keep_priority_on_conversion"):
op.drop_column("providers", "keep_priority_on_conversion")

View File

@@ -309,6 +309,7 @@ export interface ProviderWithEndpointsSummary {
description?: string
website?: string
provider_priority: number
keep_priority_on_conversion: boolean // 格式转换时是否保持优先级
billing_type?: 'monthly_quota' | 'pay_as_you_go' | 'free_tier'
monthly_quota_usd?: number
monthly_used_usd?: number

View File

@@ -171,6 +171,25 @@
</div>
</div>
<!-- 格式转换配置 -->
<div class="space-y-3">
<h3 class="text-sm font-medium border-b pb-2">
格式转换
</h3>
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
<div class="space-y-0.5">
<span class="text-sm font-medium">保持优先级</span>
<p class="text-xs text-muted-foreground">
跨格式请求时保持原优先级排名,不降级到格式匹配的提供商之后
</p>
</div>
<Switch
:model-value="form.keep_priority_on_conversion"
@update:model-value="(v: boolean) => form.keep_priority_on_conversion = v"
/>
</div>
</div>
<!-- 代理配置 -->
<div class="space-y-3">
<div class="flex items-center justify-between">
@@ -295,6 +314,7 @@ const form = ref({
quota_last_reset_at: '', // 周期开始时间
quota_expires_at: '',
provider_priority: 999,
keep_priority_on_conversion: false, // 格式转换时是否保持优先级
// 状态配置
is_active: true,
rate_limit: undefined as number | undefined,
@@ -323,6 +343,7 @@ function resetForm() {
quota_last_reset_at: '',
quota_expires_at: '',
provider_priority: 999,
keep_priority_on_conversion: false,
is_active: true,
rate_limit: undefined,
concurrent_limit: undefined,
@@ -356,6 +377,7 @@ function loadProviderData() {
quota_expires_at: props.provider.quota_expires_at ?
new Date(props.provider.quota_expires_at).toISOString().slice(0, 16) : '',
provider_priority: props.provider.provider_priority || 999,
keep_priority_on_conversion: props.provider.keep_priority_on_conversion ?? false,
is_active: props.provider.is_active,
rate_limit: undefined,
concurrent_limit: undefined,
@@ -416,6 +438,7 @@ const handleSubmit = async () => {
quota_last_reset_at: form.value.quota_last_reset_at || undefined,
quota_expires_at: form.value.quota_expires_at || undefined,
provider_priority: form.value.provider_priority,
keep_priority_on_conversion: form.value.keep_priority_on_conversion,
is_active: form.value.is_active,
// 请求配置
max_retries: form.value.max_retries ?? undefined,

View File

@@ -301,6 +301,7 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
description=provider.description,
website=provider.website,
provider_priority=provider.provider_priority,
keep_priority_on_conversion=provider.keep_priority_on_conversion,
is_active=provider.is_active,
billing_type=provider.billing_type.value if provider.billing_type else None,
monthly_quota_usd=provider.monthly_quota_usd,

View File

@@ -141,8 +141,8 @@ class Config:
# HTTP 请求超时配置(秒)
self.http_connect_timeout = float(os.getenv("HTTP_CONNECT_TIMEOUT", "10.0"))
self.http_read_timeout = float(os.getenv("HTTP_READ_TIMEOUT", "60.0"))
self.http_write_timeout = float(os.getenv("HTTP_WRITE_TIMEOUT", "60.0"))
self.http_read_timeout = float(os.getenv("HTTP_READ_TIMEOUT", "3600.0"))
self.http_write_timeout = float(os.getenv("HTTP_WRITE_TIMEOUT", "3600.0"))
self.http_pool_timeout = float(os.getenv("HTTP_POOL_TIMEOUT", "10.0"))
# HTTP_REQUEST_TIMEOUT: 非流式请求整体超时(秒),默认 300 秒
self.http_request_timeout = float(os.getenv("HTTP_REQUEST_TIMEOUT", "300.0"))
@@ -154,6 +154,14 @@ class Config:
"FORMAT_CONVERSION_ENABLED", "true"
).lower() == "true"
# KEEP_PRIORITY_ON_CONVERSION: 格式转换时是否保持提供商原优先级,默认关闭
# - false默认: 需要格式转换的候选整体降级到不需要转换的候选之后
# - true: 所有提供商保持原优先级,不因格式转换降级
# 注意:即使全局关闭,单个提供商也可以通过 keep_priority_on_conversion 字段保持自己的优先级
self.keep_priority_on_conversion = os.getenv(
"KEEP_PRIORITY_ON_CONVERSION", "false"
).lower() == "true"
# HTTP 连接池配置
# HTTP_MAX_CONNECTIONS: 最大连接数,影响并发能力
# - 每个连接占用一个 socket过多会耗尽系统资源

View File

@@ -645,6 +645,12 @@ class Provider(Base):
# 101+: 备用(高成本或限制严格的)
provider_priority = Column(Integer, default=100)
# 格式转换时是否保持优先级(默认 False
# - False: 需要格式转换时,该提供商的候选会被降级到不需要转换的候选之后
# - True: 即使需要格式转换,也保持原优先级排名
# 注意:如果全局配置 KEEP_PRIORITY_ON_CONVERSION=true此字段被忽略所有提供商都保持优先级
keep_priority_on_conversion = Column(Boolean, default=False, nullable=False)
# 状态
is_active = Column(Boolean, default=True, nullable=False)

View File

@@ -613,6 +613,10 @@ class ProviderUpdateRequest(BaseModel):
description: Optional[str] = None
website: Optional[str] = Field(None, max_length=500, description="主站网站")
provider_priority: Optional[int] = Field(None, description="提供商优先级(数字越小越优先)")
keep_priority_on_conversion: Optional[bool] = Field(
None,
description="格式转换时是否保持优先级True=保持原优先级False=需要转换时降级)",
)
is_active: Optional[bool] = None
billing_type: Optional[str] = Field(
None, description="计费类型monthly_quota/pay_as_you_go/free_tier"
@@ -637,6 +641,10 @@ class ProviderWithEndpointsSummary(BaseModel):
description: Optional[str] = None
website: Optional[str] = None
provider_priority: int = Field(default=100, description="提供商优先级(数字越小越优先)")
keep_priority_on_conversion: bool = Field(
default=False,
description="格式转换时是否保持优先级True=保持原优先级False=需要转换时降级)",
)
is_active: bool
# 计费相关字段

View File

@@ -1162,9 +1162,22 @@ class CacheAwareScheduler:
candidate.is_cached = False
return candidates
# 按是否匹配缓存亲和性分类候选
cached_candidates: List[ProviderCandidate] = []
other_candidates: List[ProviderCandidate] = []
# 判断候选是否应该被降级(用于分组)
from src.config.settings import config
global_keep_priority = config.keep_priority_on_conversion
def should_demote(c: ProviderCandidate) -> bool:
"""判断候选是否应该被降级"""
if global_keep_priority:
return False # 全局开启时,所有候选都不降级
if not c.needs_conversion:
return False # exact 候选不降级
if getattr(c.provider, "keep_priority_on_conversion", False):
return False # 提供商配置了保持优先级
return True # 需要降级
# 按是否匹配缓存亲和性分类候选,同时记录是否降级
matched_candidate: Optional[ProviderCandidate] = None
matched = False
for candidate in candidates:
@@ -1178,7 +1191,7 @@ class CacheAwareScheduler:
and key.id == affinity.key_id
):
candidate.is_cached = True
cached_candidates.append(candidate)
matched_candidate = candidate
matched = True
logger.debug(
f"检测到缓存亲和性: affinity_key={affinity_key[:8]}..., "
@@ -1189,18 +1202,57 @@ class CacheAwareScheduler:
)
else:
candidate.is_cached = False
other_candidates.append(candidate)
if not matched:
logger.debug(f"API格式 {api_format_str} 的缓存亲和性存在但组合不可用")
return candidates
# 重新排序:缓存候选优先
if cached_candidates:
result = cached_candidates + other_candidates
logger.debug(f"{len(cached_candidates)} 个缓存组合已提升至优先级")
# 缓存亲和性命中且该候选可用(未被跳过)时,无条件优先使用
# 理由1) 它之前成功过2) 它有 prompt cache 优势
# 只有当缓存亲和性的候选被跳过(健康度太低/熔断)时,才按 exact 优先排序
assert matched_candidate is not None # guaranteed by matched=True
if not matched_candidate.is_skipped:
# 缓存命中且健康,无条件提升到最前面
other_candidates = [c for c in candidates if c is not matched_candidate]
result = [matched_candidate] + other_candidates
logger.debug(
f"缓存亲和性命中且健康,无条件优先使用 "
f"(needs_conversion={matched_candidate.needs_conversion})"
)
return result
return candidates
# 缓存命中但被跳过(不健康),按 exact 优先排序
# 缓存候选在其所属类别内提升到最前面
logger.debug(
f"缓存亲和性命中但不健康 (skip_reason={matched_candidate.skip_reason})"
f"按 exact 优先排序"
)
matched_should_demote = should_demote(matched_candidate)
# 分组:非降级类 和 降级类
keep_priority_candidates: List[ProviderCandidate] = []
demote_candidates: List[ProviderCandidate] = []
for c in candidates:
if c is matched_candidate:
continue # 先跳过缓存命中的候选
if should_demote(c):
demote_candidates.append(c)
else:
keep_priority_candidates.append(c)
# 将缓存命中的候选插入到其所属类别的最前面
if matched_should_demote:
# 缓存命中的是降级类,插入到降级类最前面
demote_candidates.insert(0, matched_candidate)
else:
# 缓存命中的是非降级类,插入到非降级类最前面
keep_priority_candidates.insert(0, matched_candidate)
result = keep_priority_candidates + demote_candidates
logger.debug(f"缓存组合已提升至其类别内优先级 (demote={matched_should_demote})")
return result
except Exception as e:
logger.warning(f"检查缓存亲和性失败: {e},继续使用默认排序")
@@ -1249,31 +1301,59 @@ class CacheAwareScheduler:
"""
根据优先级模式对候选列表排序(数字越小越优先)
排序规则:
1. exact 候选needs_conversion=False优先于 convertible 候选
2. 在同一类型内,按优先级模式排序
- provider: 提供商优先模式,按 Provider.provider_priority -> Key.internal_priority 排序
- global_key: 全局 Key 优先模式,按 Key.global_priority_by_format 排序
排序规则(受 KEEP_PRIORITY_ON_CONVERSION 配置影响)
1. 如果全局配置 keep_priority_on_conversion=True所有候选保持原优先级
2. 否则,按 needs_conversion 和 provider.keep_priority_on_conversion 分组
- 保持优先级的候选exact 或 provider.keep_priority_on_conversion=True按原优先级排序
- 需要降级的候选convertible 且 provider.keep_priority_on_conversion=False整体排在后面
3. 在同一组内,按优先级模式排序:
- provider: 按 Provider.provider_priority -> Key.internal_priority 排序
- global_key: 按 Key.global_priority_by_format 排序
"""
if not candidates:
return candidates
# 按 needs_conversion 分组exact 优先
exact_candidates = [c for c in candidates if not c.needs_conversion]
convertible_candidates = [c for c in candidates if c.needs_conversion]
from src.config.settings import config
# 全局配置:如果开启,所有候选保持原优先级
global_keep_priority = config.keep_priority_on_conversion
if global_keep_priority:
# 全局开启:不分组,直接按优先级模式排序
if self.priority_mode == self.PRIORITY_MODE_GLOBAL_KEY:
return self._sort_by_global_priority_with_hash(candidates, affinity_key, api_format)
# 提供商优先模式:保持构建时的顺序(已按 provider_priority 排序)
return candidates
# 全局未开启:按是否需要降级分组
# - 不需要降级exact 候选 或 provider.keep_priority_on_conversion=True 的 convertible 候选
# - 需要降级convertible 且 provider.keep_priority_on_conversion=False
keep_priority_candidates: List[ProviderCandidate] = []
demote_candidates: List[ProviderCandidate] = []
for c in candidates:
if not c.needs_conversion:
# exact 候选:不需要降级
keep_priority_candidates.append(c)
elif getattr(c.provider, "keep_priority_on_conversion", False):
# convertible 但提供商配置了保持优先级
keep_priority_candidates.append(c)
else:
# convertible 且未配置保持优先级:降级
demote_candidates.append(c)
if self.priority_mode == self.PRIORITY_MODE_GLOBAL_KEY:
# 全局 Key 优先模式:分别对两组排序后合并
sorted_exact = self._sort_by_global_priority_with_hash(
exact_candidates, affinity_key, api_format
sorted_keep = self._sort_by_global_priority_with_hash(
keep_priority_candidates, affinity_key, api_format
)
sorted_convertible = self._sort_by_global_priority_with_hash(
convertible_candidates, affinity_key, api_format
sorted_demote = self._sort_by_global_priority_with_hash(
demote_candidates, affinity_key, api_format
)
return sorted_exact + sorted_convertible
return sorted_keep + sorted_demote
# 提供商优先模式:exact 在前convertible 在后(各组内部顺序已由构建时保证)
return exact_candidates + convertible_candidates
# 提供商优先模式:保持优先级的在前,降级的在后(各组内部顺序已由构建时保证)
return keep_priority_candidates + demote_candidates
def _sort_by_global_priority_with_hash(
self,