refactor: 将格式转换全局开关迁移到环境变量配置

- 新增 FORMAT_CONVERSION_ENABLED 环境变量(默认开启)
- 移除数据库中的 format_conversion_enabled 系统配置
- 移除前端系统设置页面的格式转换开关 UI
- 统一同族格式转换(OPENAI/OPENAI_CLI)逻辑,也需检查全局开关和端点配置
- 流生成器使用 ctx.needs_conversion 替代 _needs_format_conversion 方法调用
- 更新测试用例适配新逻辑
- 清理 .env.example 中过时的超时和连接池配置注释
This commit is contained in:
fawney19
2026-01-28 01:22:39 +08:00
parent 1e0255d0ed
commit af0daa91ad
9 changed files with 112 additions and 135 deletions

View File

@@ -43,51 +43,3 @@ ADMIN_PASSWORD=admin123456
# 默认: * (允许所有源)
# CORS_ORIGINS=*
# ==================== 超时配置 ====================
# 以下配置控制各种超时行为,影响故障转移和请求处理
# --- HTTP 连接层超时httpx 底层) ---
# 这些是网络层的超时,控制 TCP 连接和数据传输
# TCP 连接建立超时(默认 10 秒)
# 无法在此时间内建立 TCP 连接则触发故障转移
# HTTP_CONNECT_TIMEOUT=10.0
# 读取数据超时(默认 60 秒)
# 两次数据包之间的最大等待时间,超时触发故障转移
# 注意:如果上游持续发送数据(哪怕很慢),此计时器会不断重置
# HTTP_READ_TIMEOUT=60.0
# 流式响应首字节超时(默认 30 秒,范围 10-120 秒)
# 从发起请求到收到第一个字节的最大等待时间
# 仅对流式请求生效,超时触发故障转移
# STREAM_FIRST_BYTE_TIMEOUT=30.0
# ==================== 数据库连接池配置 ====================
# 连接池大小直接影响并发能力,特别是流式请求场景
# 每个流式请求会占用一个连接直到响应完成
# PostgreSQL 最大连接数(默认 100需与 postgresql.conf 中 max_connections 匹配)
# PG_MAX_CONNECTIONS=100
# 预留给管理工具的连接数(默认 10
# PG_RESERVED_CONNECTIONS=10
# 连接池大小(默认自动计算:(PG_MAX - RESERVED) / WORKERS / 2
# 高并发场景建议手动设置较大值
# DB_POOL_SIZE=20
# 最大溢出连接数(默认等于 DB_POOL_SIZE
# 高峰期可临时创建的额外连接
# DB_MAX_OVERFLOW=20
# 连接获取超时(默认 60 秒)
# 超时后抛出 TimeoutError
# DB_POOL_TIMEOUT=60
# 连接池使用率警告阈值(默认 70%
# DB_POOL_WARN_THRESHOLD=70
# 批量余额查询并发限制(默认自动计算:连接池的 40%
# 控制 Provider 余额查询的并发数,避免耗尽连接池
# BATCH_BALANCE_CONCURRENCY=8

View File

@@ -188,25 +188,6 @@
</div>
</div>
<div class="flex items-center h-full">
<div class="flex items-center space-x-2">
<Checkbox
id="format-conversion-enabled"
v-model:checked="systemConfig.format_conversion_enabled"
/>
<div>
<Label
for="format-conversion-enabled"
class="cursor-pointer"
>
启用格式自动转换
</Label>
<p class="text-xs text-muted-foreground">
允许网关在 OpenAI/Claude/Gemini 格式间自动转换
</p>
</div>
</div>
</div>
</div>
</CardSection>
@@ -854,8 +835,6 @@ interface SystemConfig {
enable_registration: boolean
// 独立余额 Key 过期管理
auto_delete_expired_keys: boolean
// 格式转换
format_conversion_enabled: boolean
// 日志记录
request_log_level: string
max_request_body_size: number
@@ -908,8 +887,6 @@ const systemConfig = ref<SystemConfig>({
enable_registration: false,
// 独立余额 Key 过期管理
auto_delete_expired_keys: false,
// 格式转换
format_conversion_enabled: false,
// 日志记录
request_log_level: 'basic',
max_request_body_size: 1048576,
@@ -935,8 +912,7 @@ const hasBasicConfigChanges = computed(() => {
systemConfig.value.default_user_quota_usd !== originalConfig.value.default_user_quota_usd ||
systemConfig.value.rate_limit_per_minute !== originalConfig.value.rate_limit_per_minute ||
systemConfig.value.enable_registration !== originalConfig.value.enable_registration ||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
systemConfig.value.format_conversion_enabled !== originalConfig.value.format_conversion_enabled
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys
)
})
@@ -1013,8 +989,6 @@ async function loadSystemConfig() {
'enable_registration',
// 独立余额 Key 过期管理
'auto_delete_expired_keys',
// 格式转换
'format_conversion_enabled',
// 日志记录
'request_log_level',
'max_request_body_size',
@@ -1072,11 +1046,6 @@ async function saveBasicConfig() {
value: systemConfig.value.auto_delete_expired_keys,
description: '是否自动删除过期的API Key'
},
{
key: 'format_conversion_enabled',
value: systemConfig.value.format_conversion_enabled,
description: '是否启用全局格式自动转换'
},
]
await Promise.all(
@@ -1090,7 +1059,6 @@ async function saveBasicConfig() {
originalConfig.value.rate_limit_per_minute = systemConfig.value.rate_limit_per_minute
originalConfig.value.enable_registration = systemConfig.value.enable_registration
originalConfig.value.auto_delete_expired_keys = systemConfig.value.auto_delete_expired_keys
originalConfig.value.format_conversion_enabled = systemConfig.value.format_conversion_enabled
}
success('基础配置已保存')
} catch (err) {

View File

@@ -862,8 +862,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
# 检查是否需要格式转换
needs_conversion = self._needs_format_conversion(ctx)
# 使用已设置的 ctx.needs_conversion由候选筛选阶段根据端点配置判断
# 不再调用 _needs_format_conversion,它只检查格式差异,不检查端点配置
needs_conversion = ctx.needs_conversion
async for chunk in stream_response.aiter_bytes():
buffer += chunk
@@ -1224,8 +1225,9 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 使用增量解码器处理跨 chunk 的 UTF-8 字符
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
# 检查是否需要格式转换
needs_conversion = self._needs_format_conversion(ctx)
# 使用已设置的 ctx.needs_conversion由候选筛选阶段根据端点配置判断
# 不再调用 _needs_format_conversion,它只检查格式差异,不检查端点配置
needs_conversion = ctx.needs_conversion
# 先处理预读的字节块
for chunk in prefetched_chunks:
@@ -2394,7 +2396,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
def _needs_format_conversion(self, ctx: StreamContext) -> bool:
"""
检查是否需要进行格式转换
[已废弃] 仅根据格式差异判断是否需要转换
警告:此方法只检查格式是否不同,不检查端点的 format_acceptance_config 配置!
正确的判断应使用候选筛选阶段的结果ctx.needs_conversion该结果由
is_format_compatible() 函数根据全局开关和端点配置计算得出。
此方法保留仅供调试和日志输出使用,流生成器中不应调用此方法。
当 Provider 的 API 格式与客户端请求的 API 格式不同时,需要转换响应。
例如:客户端请求 Claude 格式,但 Provider 返回 OpenAI 格式。

View File

@@ -99,9 +99,10 @@ def _get_formats_for_api(api_format: str) -> list[str]:
return _OPENAI_FORMATS
def _is_format_conversion_enabled(db: Session) -> bool:
"""检查全局格式转换开关"""
return bool(SystemConfigService.get_config(db, "format_conversion_enabled", False))
def _is_format_conversion_enabled() -> bool:
"""检查全局格式转换开关(从环境变量读取,默认开启)"""
from src.config.settings import config
return config.format_conversion_enabled
def _get_convertible_formats(client_format: str, global_conversion_enabled: bool) -> list[str]:
@@ -511,7 +512,7 @@ async def list_models(
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
# 获取可用格式(包括可转换的格式)
global_conversion_enabled = _is_format_conversion_enabled(db)
global_conversion_enabled = _is_format_conversion_enabled()
candidate_formats = _get_convertible_formats(api_format, global_conversion_enabled)
candidate_formats, empty_response = _filter_formats_by_restrictions(
candidate_formats, restrictions, api_format
@@ -615,7 +616,7 @@ async def retrieve_model(
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
# 获取可用格式(包括可转换的格式)
global_conversion_enabled = _is_format_conversion_enabled(db)
global_conversion_enabled = _is_format_conversion_enabled()
candidate_formats = _get_convertible_formats(api_format, global_conversion_enabled)
candidate_formats, _ = _filter_formats_by_restrictions(
candidate_formats, restrictions, api_format
@@ -700,7 +701,7 @@ async def list_models_gemini(
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
# 获取可用格式(包括可转换的格式)
global_conversion_enabled = _is_format_conversion_enabled(db)
global_conversion_enabled = _is_format_conversion_enabled()
candidate_formats = _get_convertible_formats("gemini", global_conversion_enabled)
candidate_formats, empty_response = _filter_formats_by_restrictions(
candidate_formats, restrictions, "gemini"
@@ -781,7 +782,7 @@ async def get_model_gemini(
restrictions = AccessRestrictions.from_api_key_and_user(key_record, user)
# 获取可用格式(包括可转换的格式)
global_conversion_enabled = _is_format_conversion_enabled(db)
global_conversion_enabled = _is_format_conversion_enabled()
candidate_formats = _get_convertible_formats("gemini", global_conversion_enabled)
candidate_formats, _ = _filter_formats_by_restrictions(
candidate_formats, restrictions, "gemini"

View File

@@ -147,6 +147,13 @@ class Config:
# HTTP_REQUEST_TIMEOUT: 非流式请求整体超时(秒),默认 300 秒
self.http_request_timeout = float(os.getenv("HTTP_REQUEST_TIMEOUT", "300.0"))
# 格式转换配置
# FORMAT_CONVERSION_ENABLED: 全局格式转换总开关,默认开启
# 注意:即使开启,也需要端点配置 format_acceptance_config.enabled=true 才能生效
self.format_conversion_enabled = os.getenv(
"FORMAT_CONVERSION_ENABLED", "true"
).lower() == "true"
# HTTP 连接池配置
# HTTP_MAX_CONNECTIONS: 最大连接数,影响并发能力
# - 每个连接占用一个 socket过多会耗尽系统资源

View File

@@ -1,7 +1,12 @@
"""
格式兼容性检查
用于候选筛选时判断端点是否可以处理客户端请式。
用于候选筛选时判断端点是否可以处理客户端请求格式。
转换逻辑:
1. 格式完全匹配 -> 透传(无需转换)
2. 同族格式透传CLAUDE/CLAUDE_CLI、GEMINI/GEMINI_CLI 格式相同,只是认证不同)
3. 需要转换的情况 -> 检查全局开关 + 端点配置 + 转换器能力
"""
from __future__ import annotations
@@ -33,7 +38,7 @@ def is_format_compatible(
endpoint_api_format: 端点的 API 格式
endpoint_format_acceptance_config: 端点的格式接受配置
is_stream: 是否是流式请求
global_conversion_enabled: 全局格式转换开关
global_conversion_enabled: 全局格式转换开关(来自环境变量 FORMAT_CONVERSION_ENABLED默认 True
registry: 转换器注册表(可选,默认使用全局单例)
Returns:
@@ -55,7 +60,7 @@ def is_format_compatible(
provider_format = endpoint_api_format.upper()
client_format_upper = client_format.upper()
# 1. 格式完全匹配 -> 兼容,无需转换
# 1. 格式完全匹配 -> 透传(无需转换
if provider_format == client_format_upper:
return True, False, None
@@ -63,32 +68,18 @@ def is_format_compatible(
provider_base = get_base_format(provider_format)
client_base = get_base_format(client_format_upper)
if provider_base == client_base:
# 同族格式:检查是否需要转换
# - OPENAI 和 OPENAI_CLI 的请求/响应格式不同Chat Completions vs Responses API需要转换
# - CLAUDE 和 CLAUDE_CLI、GEMINI 和 GEMINI_CLI 格式相同,只是认证不同,可透传
if provider_base == "OPENAI":
# OPENAI/OPENAI_CLI 同族转换:兼容但需要转换
# 检查转换器能力(同族转换不需要全局开关和端点配置)
if registry.can_convert_full(
client_format_upper, provider_format, require_stream=is_stream
):
return True, True, None # 兼容,需要转换
else:
return (
False,
False,
f"不存在 {client_format} <-> {provider_format} 的完整转换器",
)
else:
# CLAUDE/CLAUDE_CLI、GEMINI/GEMINI_CLI 等:格式相同,可透传
is_same_family = provider_base == client_base
if is_same_family and provider_base != "OPENAI":
# CLAUDE/CLAUDE_CLI、GEMINI/GEMINI_CLI 等:格式相同,只是认证不同,可透传
# 注意:这些格式之间没有定义转换器,因为数据格式完全相同
return True, False, None
# 3. 检查全局开关
# 3. 需要转换的情况OPENAI/OPENAI_CLI 同族转换 或 跨格式转换)
# 检查全局开关(来自环境变量,默认开启)
if not global_conversion_enabled:
return False, False, "全局格式转换未启用"
return False, False, "全局格式转换未启用(环境变量 FORMAT_CONVERSION_ENABLED=false"
# 4. 检查端点配置
# 4. 检查端点配置(核心控制)
if endpoint_format_acceptance_config is None:
return False, False, "端点未配置格式转换"

View File

@@ -659,9 +659,8 @@ class CacheAwareScheduler:
return [], global_model_id
# 2. 构建候选列表(传入 is_stream 和 capability_requirements 用于过滤)
global_conversion_enabled = bool(
SystemConfigService.get_config(db, "format_conversion_enabled", False)
)
from src.config.settings import config
global_conversion_enabled = config.format_conversion_enabled
candidates = await self._build_candidates(
db=db,
providers=providers,

View File

@@ -118,10 +118,6 @@ class SystemConfigService:
"value": "cache_affinity",
"description": "调度模式fixed_order(固定顺序模式,严格按优先级顺序) 或 cache_affinity(缓存亲和模式优先使用已缓存的Provider)",
},
"format_conversion_enabled": {
"value": False,
"description": "是否启用全局格式自动转换(需要端点配置 format_acceptance_config 才能生效)",
},
"auto_delete_expired_keys": {
"value": False,
"description": "是否自动删除过期的API KeyTrue=物理删除False=仅禁用),仅管理员可配置",

View File

@@ -46,6 +46,7 @@ def test_cli_format_convertible_when_converter_supports_full() -> None:
def test_global_switch_disabled_blocks_conversion() -> None:
"""全局开关关闭时(环境变量 FORMAT_CONVERSION_ENABLED=false阻止转换"""
ok, needs_conv, reason = is_format_compatible(
"CLAUDE",
"OPENAI",
@@ -56,7 +57,7 @@ def test_global_switch_disabled_blocks_conversion() -> None:
)
assert ok is False
assert needs_conv is False
assert reason and "全局" in reason
assert reason and ("全局" in reason or "FORMAT_CONVERSION_ENABLED" in reason)
def test_endpoint_config_none_blocks_conversion() -> None:
@@ -219,9 +220,9 @@ def test_openai_cli_to_openai_needs_conversion() -> None:
ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI",
"OPENAI",
endpoint_format_acceptance_config=None, # 同族转换需要端点配置
endpoint_format_acceptance_config={"enabled": True}, # 同族转换需要端点配置
is_stream=False,
global_conversion_enabled=False, # 同族转换需要全局开关
global_conversion_enabled=True, # 同族转换需要全局开关
registry=registry,
)
assert ok is True
@@ -237,9 +238,9 @@ def test_openai_to_openai_cli_needs_conversion() -> None:
ok, needs_conv, reason = is_format_compatible(
"OPENAI",
"OPENAI_CLI",
endpoint_format_acceptance_config=None, # 同族转换需要端点配置
endpoint_format_acceptance_config={"enabled": True}, # 同族转换需要端点配置
is_stream=False,
global_conversion_enabled=False, # 同族转换需要全局开关
global_conversion_enabled=True, # 同族转换需要全局开关
registry=registry,
)
assert ok is True
@@ -255,9 +256,9 @@ def test_openai_cli_to_openai_stream_needs_conversion() -> None:
ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI",
"OPENAI",
endpoint_format_acceptance_config=None,
endpoint_format_acceptance_config={"enabled": True}, # 同族转换也需要端点配置
is_stream=True,
global_conversion_enabled=False,
global_conversion_enabled=True, # 同族转换也需要全局开关
registry=registry,
)
assert ok is True
@@ -273,11 +274,65 @@ def test_openai_cli_to_openai_fails_without_converter() -> None:
ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI",
"OPENAI",
endpoint_format_acceptance_config=None,
endpoint_format_acceptance_config={"enabled": True},
is_stream=False,
global_conversion_enabled=False,
global_conversion_enabled=True,
registry=registry,
)
assert ok is False
assert needs_conv is False
assert reason and "转换器" in reason
def test_openai_cli_to_openai_blocked_when_global_switch_disabled() -> None:
"""同族转换OPENAI/OPENAI_CLI也受全局开关限制环境变量 FORMAT_CONVERSION_ENABLED=false"""
registry = MagicMock()
registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI",
"OPENAI",
endpoint_format_acceptance_config={"enabled": True},
is_stream=False,
global_conversion_enabled=False, # 全局开关关闭
registry=registry,
)
assert ok is False
assert needs_conv is False
assert reason and ("全局" in reason or "FORMAT_CONVERSION_ENABLED" in reason)
def test_openai_cli_to_openai_blocked_when_endpoint_disabled() -> None:
"""同族转换OPENAI/OPENAI_CLI也受端点开关限制"""
registry = MagicMock()
registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI",
"OPENAI",
endpoint_format_acceptance_config={"enabled": False}, # 端点开关关闭
is_stream=False,
global_conversion_enabled=True,
registry=registry,
)
assert ok is False
assert needs_conv is False
assert reason and "未启用" in reason
def test_openai_cli_to_openai_blocked_when_endpoint_not_configured() -> None:
"""同族转换OPENAI/OPENAI_CLI也需要端点配置"""
registry = MagicMock()
registry.can_convert_full.return_value = True
ok, needs_conv, reason = is_format_compatible(
"OPENAI_CLI",
"OPENAI",
endpoint_format_acceptance_config=None, # 无端点配置
is_stream=False,
global_conversion_enabled=True,
registry=registry,
)
assert ok is False
assert needs_conv is False
assert reason and "未配置" in reason