mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(endpoint): 端点默认 body_rules 机制与 Codex 规则回填
- EndpointDefinition 新增 default_body_rules 字段,openai:cli/compact 配置 Codex 默认规则
- 创建端点时若未指定 body_rules 则自动填充对应格式的默认值
- 新增 GET /defaults/{api_format}/body-rules 接口查询默认规则
- 前端 EndpointFormDialog 增加"重置请求体"按钮,支持一键恢复默认
- Alembic 迁移回填已有 Codex 端点的默认 body_rules
- 新增 metadata 和 endpoint 创建默认值的单元测试
This commit is contained in:
@@ -0,0 +1,69 @@
|
|||||||
|
"""backfill_codex_default_body_rules
|
||||||
|
|
||||||
|
Backfill default body_rules for openai:cli and openai:compact endpoints
|
||||||
|
that currently have body_rules IS NULL.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- drop max_output_tokens
|
||||||
|
- drop temperature
|
||||||
|
- drop top_p
|
||||||
|
- set store = false
|
||||||
|
- set instructions = "You are GPT-5." (when instructions not exists)
|
||||||
|
|
||||||
|
Revision ID: dd0278c0a28c
|
||||||
|
Revises: 1d2e3f4a5b6c
|
||||||
|
Create Date: 2026-03-02 15:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "dd0278c0a28c"
|
||||||
|
down_revision = "1d2e3f4a5b6c"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_TARGET_FORMATS = ("openai:cli", "openai:compact")
|
||||||
|
|
||||||
|
_DEFAULT_BODY_RULES = [
|
||||||
|
{"action": "drop", "path": "max_output_tokens"},
|
||||||
|
{"action": "drop", "path": "temperature"},
|
||||||
|
{"action": "drop", "path": "top_p"},
|
||||||
|
{"action": "set", "path": "store", "value": False},
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"path": "instructions",
|
||||||
|
"value": "You are GPT-5.",
|
||||||
|
"condition": {"path": "instructions", "op": "not_exists"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# 幂等性: 仅回填 body_rules 为空(SQL NULL 或 JSON null)的记录
|
||||||
|
rules_json = json.dumps(_DEFAULT_BODY_RULES, ensure_ascii=False)
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE provider_endpoints
|
||||||
|
SET body_rules = CAST(:rules AS json),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE api_format IN (:fmt1, :fmt2)
|
||||||
|
AND (body_rules IS NULL OR body_rules::text = 'null')
|
||||||
|
"""),
|
||||||
|
{"rules": rules_json, "fmt1": _TARGET_FORMATS[0], "fmt2": _TARGET_FORMATS[1]},
|
||||||
|
)
|
||||||
|
if result.rowcount:
|
||||||
|
print(f" backfilled body_rules for {result.rowcount} endpoint(s)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Data backfill: no-op to avoid removing user-customized rules.
|
||||||
|
return
|
||||||
@@ -68,3 +68,11 @@ export async function deleteEndpoint(endpointId: string): Promise<{ message: str
|
|||||||
const response = await client.delete(`/api/admin/endpoints/${endpointId}`)
|
const response = await client.delete(`/api/admin/endpoints/${endpointId}`)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定 API 格式的默认请求体规则
|
||||||
|
*/
|
||||||
|
export async function getDefaultBodyRules(apiFormat: string): Promise<{ api_format: string; body_rules: BodyRule[] }> {
|
||||||
|
const response = await client.get(`/api/admin/endpoints/defaults/${encodeURIComponent(apiFormat)}/body-rules`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -204,6 +204,18 @@
|
|||||||
<Plus class="w-3 h-3 mr-1" />
|
<Plus class="w-3 h-3 mr-1" />
|
||||||
请求体
|
请求体
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="isFixedProvider"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 text-xs px-2"
|
||||||
|
title="重置请求体"
|
||||||
|
:disabled="resettingDefaultRulesEndpointId === endpoint.id"
|
||||||
|
@click="handleResetBodyRulesToDefault(endpoint)"
|
||||||
|
>
|
||||||
|
<RotateCcw class="w-3 h-3 mr-1" />
|
||||||
|
重置请求体
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CollapsibleContent class="pt-3">
|
<CollapsibleContent class="pt-3">
|
||||||
@@ -829,6 +841,7 @@ import { log } from '@/utils/logger'
|
|||||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||||
import {
|
import {
|
||||||
createEndpoint,
|
createEndpoint,
|
||||||
|
getDefaultBodyRules,
|
||||||
updateEndpoint,
|
updateEndpoint,
|
||||||
deleteEndpoint,
|
deleteEndpoint,
|
||||||
type ProviderEndpoint,
|
type ProviderEndpoint,
|
||||||
@@ -1084,6 +1097,7 @@ function handleBodyRuleDragEnd(endpointId: string) {
|
|||||||
// 状态
|
// 状态
|
||||||
const addingEndpoint = ref(false)
|
const addingEndpoint = ref(false)
|
||||||
const savingEndpointId = ref<string | null>(null)
|
const savingEndpointId = ref<string | null>(null)
|
||||||
|
const resettingDefaultRulesEndpointId = ref<string | null>(null)
|
||||||
const deletingEndpointId = ref<string | null>(null)
|
const deletingEndpointId = ref<string | null>(null)
|
||||||
const togglingEndpointId = ref<string | null>(null)
|
const togglingEndpointId = ref<string | null>(null)
|
||||||
const togglingFormatEndpointId = ref<string | null>(null)
|
const togglingFormatEndpointId = ref<string | null>(null)
|
||||||
@@ -1114,6 +1128,9 @@ function setBodyRuleHelpOpen(endpointId: string, open: boolean) {
|
|||||||
|
|
||||||
// 每个端点的编辑状态(内联编辑)
|
// 每个端点的编辑状态(内联编辑)
|
||||||
const endpointEditStates = ref<Record<string, EndpointEditState>>({})
|
const endpointEditStates = ref<Record<string, EndpointEditState>>({})
|
||||||
|
const defaultBodyRulesByFormat = ref<Record<string, BodyRule[]>>({})
|
||||||
|
const defaultBodyRulesLoaded = ref<Record<string, boolean>>({})
|
||||||
|
const loadingDefaultBodyRulesByFormat = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
// 系统保留的 header 名称(不允许用户设置)
|
// 系统保留的 header 名称(不允许用户设置)
|
||||||
const RESERVED_HEADERS = new Set([
|
const RESERVED_HEADERS = new Set([
|
||||||
@@ -1261,6 +1278,40 @@ const deleteConfirmDescription = computed(() => {
|
|||||||
return `确定要删除 ${formatLabel} 端点吗?关联密钥将移除对该 API 格式的支持。`
|
return `确定要删除 ${formatLabel} 端点吗?关联密钥将移除对该 API 格式的支持。`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function loadDefaultBodyRulesForFormat(apiFormat: string, force = false): Promise<BodyRule[]> {
|
||||||
|
if (!apiFormat) return []
|
||||||
|
if (!force && defaultBodyRulesLoaded.value[apiFormat]) {
|
||||||
|
return defaultBodyRulesByFormat.value[apiFormat] || []
|
||||||
|
}
|
||||||
|
if (loadingDefaultBodyRulesByFormat.value[apiFormat]) {
|
||||||
|
return defaultBodyRulesByFormat.value[apiFormat] || []
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingDefaultBodyRulesByFormat.value[apiFormat] = true
|
||||||
|
try {
|
||||||
|
const response = await getDefaultBodyRules(apiFormat)
|
||||||
|
const normalized = response.api_format || apiFormat
|
||||||
|
const rules = response.body_rules || []
|
||||||
|
defaultBodyRulesByFormat.value[normalized] = rules
|
||||||
|
defaultBodyRulesByFormat.value[apiFormat] = rules
|
||||||
|
defaultBodyRulesLoaded.value[normalized] = true
|
||||||
|
defaultBodyRulesLoaded.value[apiFormat] = true
|
||||||
|
return rules
|
||||||
|
} catch (error: unknown) {
|
||||||
|
defaultBodyRulesByFormat.value[apiFormat] = []
|
||||||
|
defaultBodyRulesLoaded.value[apiFormat] = true
|
||||||
|
log.warn('加载默认请求体规则失败', apiFormat, error)
|
||||||
|
return []
|
||||||
|
} finally {
|
||||||
|
loadingDefaultBodyRulesByFormat.value[apiFormat] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preloadDefaultBodyRules(endpoints: ProviderEndpoint[]): Promise<void> {
|
||||||
|
const formats = Array.from(new Set(endpoints.map(e => e.api_format).filter(Boolean)))
|
||||||
|
await Promise.all(formats.map(fmt => loadDefaultBodyRulesForFormat(fmt)))
|
||||||
|
}
|
||||||
|
|
||||||
// 获取指定 API 格式的默认路径
|
// 获取指定 API 格式的默认路径
|
||||||
function getDefaultPath(apiFormat: string, baseUrl?: string): string {
|
function getDefaultPath(apiFormat: string, baseUrl?: string): string {
|
||||||
const format = apiFormats.value.find(f => f.value === apiFormat)
|
const format = apiFormats.value.find(f => f.value === apiFormat)
|
||||||
@@ -2052,6 +2103,37 @@ function resetEndpointChanges(endpoint: ProviderEndpoint) {
|
|||||||
endpointEditStates.value[endpoint.id] = initEndpointEditState(endpoint)
|
endpointEditStates.value[endpoint.id] = initEndpointEditState(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleResetBodyRulesToDefault(endpoint: ProviderEndpoint) {
|
||||||
|
resettingDefaultRulesEndpointId.value = endpoint.id
|
||||||
|
try {
|
||||||
|
const defaultRules = await loadDefaultBodyRulesForFormat(endpoint.api_format, true)
|
||||||
|
if (!defaultRules.length) {
|
||||||
|
showError('该端点没有默认请求体规则')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!endpointEditStates.value[endpoint.id]) {
|
||||||
|
endpointEditStates.value[endpoint.id] = initEndpointEditState(endpoint)
|
||||||
|
}
|
||||||
|
const state = endpointEditStates.value[endpoint.id]
|
||||||
|
if (!state) return
|
||||||
|
|
||||||
|
const resetState = initEndpointEditState({
|
||||||
|
...endpoint,
|
||||||
|
body_rules: defaultRules,
|
||||||
|
})
|
||||||
|
state.bodyRules = resetState.bodyRules
|
||||||
|
endpointRulesExpanded.value[endpoint.id] = (state.rules.length + state.bodyRules.length) > 0
|
||||||
|
clearBodyRuleDragState(endpoint.id)
|
||||||
|
clearBodyRuleSelectOpen(endpoint.id)
|
||||||
|
success('已重置请求体为默认规则,请点击保存生效')
|
||||||
|
} catch (error: unknown) {
|
||||||
|
showError(parseApiError(error, '重置失败'), '错误')
|
||||||
|
} finally {
|
||||||
|
resettingDefaultRulesEndpointId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 将可编辑规则数组转换为 API 需要的 HeaderRule[]
|
// 将可编辑规则数组转换为 API 需要的 HeaderRule[]
|
||||||
function rulesToHeaderRules(rules: EditableRule[]): HeaderRule[] | null {
|
function rulesToHeaderRules(rules: EditableRule[]): HeaderRule[] | null {
|
||||||
const result: HeaderRule[] = []
|
const result: HeaderRule[] = []
|
||||||
@@ -2126,6 +2208,7 @@ watch(() => props.modelValue, (open) => {
|
|||||||
const hasRules = (endpoint.header_rules?.length || 0) + (endpoint.body_rules?.length || 0) > 0
|
const hasRules = (endpoint.header_rules?.length || 0) + (endpoint.body_rules?.length || 0) > 0
|
||||||
endpointRulesExpanded.value[endpoint.id] = hasRules
|
endpointRulesExpanded.value[endpoint.id] = hasRules
|
||||||
}
|
}
|
||||||
|
void preloadDefaultBodyRules(localEndpoints.value)
|
||||||
} else {
|
} else {
|
||||||
// 关闭对话框时完全清空新端点表单
|
// 关闭对话框时完全清空新端点表单
|
||||||
newEndpoint.value = { api_format: '', base_url: '', custom_path: '' }
|
newEndpoint.value = { api_format: '', base_url: '', custom_path: '' }
|
||||||
@@ -2141,6 +2224,12 @@ watch(() => props.endpoints, (endpoints) => {
|
|||||||
endpointEditStates.value[endpoint.id] = initEndpointEditState(endpoint)
|
endpointEditStates.value[endpoint.id] = initEndpointEditState(endpoint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const newFormats = localEndpoints.value
|
||||||
|
.filter(e => e.api_format && !defaultBodyRulesLoaded.value[e.api_format])
|
||||||
|
.map(e => ({ api_format: e.api_format }) as ProviderEndpoint)
|
||||||
|
if (newFormats.length) {
|
||||||
|
void preloadDefaultBodyRules(newFormats)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from src.api.base.admin_adapter import AdminApiAdapter
|
|||||||
from src.api.base.context import ApiRequestContext
|
from src.api.base.context import ApiRequestContext
|
||||||
from src.api.base.models_service import invalidate_models_list_cache
|
from src.api.base.models_service import invalidate_models_list_cache
|
||||||
from src.api.base.pipeline import ApiRequestPipeline
|
from src.api.base.pipeline import ApiRequestPipeline
|
||||||
|
from src.core.api_format.metadata import get_default_body_rules_for_endpoint
|
||||||
from src.core.api_format.signature import parse_signature_key
|
from src.core.api_format.signature import parse_signature_key
|
||||||
from src.core.exceptions import InvalidRequestException, NotFoundException
|
from src.core.exceptions import InvalidRequestException, NotFoundException
|
||||||
from src.core.logger import logger
|
from src.core.logger import logger
|
||||||
@@ -136,6 +137,17 @@ async def create_provider_endpoint(
|
|||||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/defaults/{api_format}/body-rules")
|
||||||
|
async def get_default_endpoint_body_rules(
|
||||||
|
api_format: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""获取指定 endpoint signature 的默认 body_rules。"""
|
||||||
|
adapter = AdminGetDefaultBodyRulesAdapter(api_format=api_format)
|
||||||
|
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{endpoint_id}", response_model=ProviderEndpointResponse)
|
@router.get("/{endpoint_id}", response_model=ProviderEndpointResponse)
|
||||||
async def get_endpoint(
|
async def get_endpoint(
|
||||||
endpoint_id: str,
|
endpoint_id: str,
|
||||||
@@ -324,6 +336,9 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
|
|||||||
endpoint_kind = sig.endpoint_kind.value
|
endpoint_kind = sig.endpoint_kind.value
|
||||||
# 使用归一化后的 signature key,确保格式一致性
|
# 使用归一化后的 signature key,确保格式一致性
|
||||||
normalized_api_format = sig.key
|
normalized_api_format = sig.key
|
||||||
|
body_rules = self.endpoint_data.body_rules
|
||||||
|
if body_rules is None:
|
||||||
|
body_rules = get_default_body_rules_for_endpoint(normalized_api_format) or None
|
||||||
|
|
||||||
new_endpoint = ProviderEndpoint(
|
new_endpoint = ProviderEndpoint(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
@@ -334,7 +349,7 @@ class AdminCreateProviderEndpointAdapter(AdminApiAdapter):
|
|||||||
base_url=self.endpoint_data.base_url,
|
base_url=self.endpoint_data.base_url,
|
||||||
custom_path=self.endpoint_data.custom_path,
|
custom_path=self.endpoint_data.custom_path,
|
||||||
header_rules=self.endpoint_data.header_rules,
|
header_rules=self.endpoint_data.header_rules,
|
||||||
body_rules=self.endpoint_data.body_rules,
|
body_rules=body_rules,
|
||||||
max_retries=self.endpoint_data.max_retries,
|
max_retries=self.endpoint_data.max_retries,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
config=self.endpoint_data.config,
|
config=self.endpoint_data.config,
|
||||||
@@ -593,3 +608,19 @@ class AdminDeleteProviderEndpointAdapter(AdminApiAdapter):
|
|||||||
"message": f"Endpoint {self.endpoint_id} 已删除",
|
"message": f"Endpoint {self.endpoint_id} 已删除",
|
||||||
"affected_keys_count": affected_keys_count,
|
"affected_keys_count": affected_keys_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AdminGetDefaultBodyRulesAdapter(AdminApiAdapter):
|
||||||
|
api_format: str
|
||||||
|
|
||||||
|
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||||
|
try:
|
||||||
|
normalized_api_format = parse_signature_key(self.api_format).key
|
||||||
|
except Exception as exc:
|
||||||
|
raise InvalidRequestException(f"无效的 api_format: {self.api_format}") from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"api_format": normalized_api_format,
|
||||||
|
"body_rules": get_default_body_rules_for_endpoint(normalized_api_format),
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ from src.core.api_format.metadata import (
|
|||||||
can_passthrough_endpoint,
|
can_passthrough_endpoint,
|
||||||
get_auth_config_for_endpoint,
|
get_auth_config_for_endpoint,
|
||||||
get_data_format_id_for_endpoint,
|
get_data_format_id_for_endpoint,
|
||||||
|
get_default_body_rules_for_endpoint,
|
||||||
get_default_path_for_endpoint,
|
get_default_path_for_endpoint,
|
||||||
get_endpoint_definition,
|
get_endpoint_definition,
|
||||||
get_extra_headers_for_endpoint,
|
get_extra_headers_for_endpoint,
|
||||||
@@ -98,6 +99,7 @@ __all__ = [
|
|||||||
"get_extra_headers_for_endpoint",
|
"get_extra_headers_for_endpoint",
|
||||||
"get_protected_keys_for_endpoint",
|
"get_protected_keys_for_endpoint",
|
||||||
"get_data_format_id_for_endpoint",
|
"get_data_format_id_for_endpoint",
|
||||||
|
"get_default_body_rules_for_endpoint",
|
||||||
"can_passthrough_endpoint",
|
"can_passthrough_endpoint",
|
||||||
# Utils
|
# Utils
|
||||||
"is_cli_format",
|
"is_cli_format",
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ API endpoint metadata (new mode).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterable, Mapping, Sequence
|
from collections.abc import Iterable, Mapping, Sequence
|
||||||
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from src.core.api_format.enums import ApiFamily, AuthMethod, EndpointKind
|
from src.core.api_format.enums import ApiFamily, AuthMethod, EndpointKind
|
||||||
from src.core.api_format.signature import EndpointSignature, make_signature_key, parse_signature_key
|
from src.core.api_format.signature import EndpointSignature, make_signature_key, parse_signature_key
|
||||||
@@ -47,6 +49,7 @@ class EndpointDefinition:
|
|||||||
stream_in_body: bool = True
|
stream_in_body: bool = True
|
||||||
|
|
||||||
data_format_id: str = ""
|
data_format_id: str = ""
|
||||||
|
default_body_rules: Sequence[dict[str, Any]] = field(default_factory=tuple)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def signature(self) -> EndpointSignature:
|
def signature(self) -> EndpointSignature:
|
||||||
@@ -65,6 +68,19 @@ class EndpointDefinition:
|
|||||||
yield value
|
yield value
|
||||||
|
|
||||||
|
|
||||||
|
_CODEX_DEFAULT_BODY_RULES: tuple[dict[str, Any], ...] = (
|
||||||
|
{"action": "drop", "path": "max_output_tokens"},
|
||||||
|
{"action": "drop", "path": "temperature"},
|
||||||
|
{"action": "drop", "path": "top_p"},
|
||||||
|
{"action": "set", "path": "store", "value": False},
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"path": "instructions",
|
||||||
|
"value": "You are GPT-5.",
|
||||||
|
"condition": {"path": "instructions", "op": "not_exists"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
_ENDPOINT_DEFINITIONS: dict[tuple[ApiFamily, EndpointKind], EndpointDefinition] = {
|
_ENDPOINT_DEFINITIONS: dict[tuple[ApiFamily, EndpointKind], EndpointDefinition] = {
|
||||||
# Claude
|
# Claude
|
||||||
(ApiFamily.CLAUDE, EndpointKind.CHAT): EndpointDefinition(
|
(ApiFamily.CLAUDE, EndpointKind.CHAT): EndpointDefinition(
|
||||||
@@ -122,6 +138,7 @@ _ENDPOINT_DEFINITIONS: dict[tuple[ApiFamily, EndpointKind], EndpointDefinition]
|
|||||||
auth_type="bearer",
|
auth_type="bearer",
|
||||||
protected_keys=frozenset({"authorization", "content-type"}),
|
protected_keys=frozenset({"authorization", "content-type"}),
|
||||||
data_format_id="openai_responses",
|
data_format_id="openai_responses",
|
||||||
|
default_body_rules=_CODEX_DEFAULT_BODY_RULES,
|
||||||
),
|
),
|
||||||
(ApiFamily.OPENAI, EndpointKind.COMPACT): EndpointDefinition(
|
(ApiFamily.OPENAI, EndpointKind.COMPACT): EndpointDefinition(
|
||||||
api_family=ApiFamily.OPENAI,
|
api_family=ApiFamily.OPENAI,
|
||||||
@@ -135,6 +152,7 @@ _ENDPOINT_DEFINITIONS: dict[tuple[ApiFamily, EndpointKind], EndpointDefinition]
|
|||||||
# compact endpoint is non-streaming by design.
|
# compact endpoint is non-streaming by design.
|
||||||
stream_in_body=False,
|
stream_in_body=False,
|
||||||
data_format_id="openai_responses",
|
data_format_id="openai_responses",
|
||||||
|
default_body_rules=_CODEX_DEFAULT_BODY_RULES,
|
||||||
),
|
),
|
||||||
(ApiFamily.OPENAI, EndpointKind.VIDEO): EndpointDefinition(
|
(ApiFamily.OPENAI, EndpointKind.VIDEO): EndpointDefinition(
|
||||||
api_family=ApiFamily.OPENAI,
|
api_family=ApiFamily.OPENAI,
|
||||||
@@ -292,6 +310,15 @@ def get_data_format_id_for_endpoint(
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_body_rules_for_endpoint(
|
||||||
|
value: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
definition = resolve_endpoint_definition(value)
|
||||||
|
if not definition or not definition.default_body_rules:
|
||||||
|
return []
|
||||||
|
return deepcopy(list(definition.default_body_rules))
|
||||||
|
|
||||||
|
|
||||||
def can_passthrough_endpoint(
|
def can_passthrough_endpoint(
|
||||||
client: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
|
client: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
|
||||||
provider: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
|
provider: str | EndpointSignature | tuple[ApiFamily, EndpointKind],
|
||||||
@@ -336,6 +363,7 @@ __all__ = [
|
|||||||
"get_extra_headers_for_endpoint",
|
"get_extra_headers_for_endpoint",
|
||||||
"get_protected_keys_for_endpoint",
|
"get_protected_keys_for_endpoint",
|
||||||
"get_data_format_id_for_endpoint",
|
"get_data_format_id_for_endpoint",
|
||||||
|
"get_default_body_rules_for_endpoint",
|
||||||
"can_passthrough_endpoint",
|
"can_passthrough_endpoint",
|
||||||
"make_endpoint_signature",
|
"make_endpoint_signature",
|
||||||
]
|
]
|
||||||
|
|||||||
103
tests/unit/test_admin_endpoint_create_defaults.py
Normal file
103
tests/unit/test_admin_endpoint_create_defaults.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.api.admin.endpoints import routes
|
||||||
|
from src.api.admin.endpoints.routes import AdminCreateProviderEndpointAdapter
|
||||||
|
from src.models.database import Provider, ProviderEndpoint
|
||||||
|
from src.models.endpoint_models import ProviderEndpointCreate
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeQuery:
|
||||||
|
def __init__(self, result: object | None) -> None:
|
||||||
|
self._result = result
|
||||||
|
|
||||||
|
def filter(self, *_args: object, **_kwargs: object) -> "_FakeQuery":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def first(self) -> object | None:
|
||||||
|
return self._result
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
def __init__(self, provider: object) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.added: ProviderEndpoint | None = None
|
||||||
|
|
||||||
|
def query(self, model: object) -> _FakeQuery:
|
||||||
|
if model is Provider:
|
||||||
|
return _FakeQuery(self.provider)
|
||||||
|
if model is ProviderEndpoint:
|
||||||
|
return _FakeQuery(None)
|
||||||
|
raise AssertionError(f"unexpected model: {model}")
|
||||||
|
|
||||||
|
def add(self, obj: ProviderEndpoint) -> None:
|
||||||
|
self.added = obj
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def refresh(self, _obj: ProviderEndpoint) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _noop_invalidate_cache() -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_endpoint_injects_default_body_rules_when_missing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(routes, "invalidate_models_list_cache", _noop_invalidate_cache)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
routes,
|
||||||
|
"get_default_body_rules_for_endpoint",
|
||||||
|
lambda _fmt: [{"action": "drop", "path": "max_output_tokens"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
db = _FakeDB(
|
||||||
|
provider=SimpleNamespace(id="p1", name="P1", provider_type="custom"),
|
||||||
|
)
|
||||||
|
adapter = AdminCreateProviderEndpointAdapter(
|
||||||
|
provider_id="p1",
|
||||||
|
endpoint_data=ProviderEndpointCreate(
|
||||||
|
provider_id="p1",
|
||||||
|
api_format="openai:cli",
|
||||||
|
base_url="https://api.example.com",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.handle(SimpleNamespace(db=db)) # type: ignore[arg-type]
|
||||||
|
assert db.added is not None
|
||||||
|
assert db.added.body_rules == [{"action": "drop", "path": "max_output_tokens"}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_endpoint_keeps_user_body_rules_when_provided(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(routes, "invalidate_models_list_cache", _noop_invalidate_cache)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
routes,
|
||||||
|
"get_default_body_rules_for_endpoint",
|
||||||
|
lambda _fmt: [{"action": "drop", "path": "max_output_tokens"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
user_rules = [{"action": "set", "path": "metadata.source", "value": "user"}]
|
||||||
|
db = _FakeDB(
|
||||||
|
provider=SimpleNamespace(id="p1", name="P1", provider_type="custom"),
|
||||||
|
)
|
||||||
|
adapter = AdminCreateProviderEndpointAdapter(
|
||||||
|
provider_id="p1",
|
||||||
|
endpoint_data=ProviderEndpointCreate(
|
||||||
|
provider_id="p1",
|
||||||
|
api_format="openai:cli",
|
||||||
|
base_url="https://api.example.com",
|
||||||
|
body_rules=user_rules,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
await adapter.handle(SimpleNamespace(db=db)) # type: ignore[arg-type]
|
||||||
|
assert db.added is not None
|
||||||
|
assert db.added.body_rules == user_rules
|
||||||
61
tests/unit/test_api_format_metadata.py
Normal file
61
tests/unit/test_api_format_metadata.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
import src.core.api_format.metadata as metadata
|
||||||
|
from src.core.api_format.enums import ApiFamily, EndpointKind
|
||||||
|
from src.core.api_format.metadata import EndpointDefinition, get_default_body_rules_for_endpoint
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_default_body_rules_for_endpoint_returns_empty_for_invalid() -> None:
|
||||||
|
assert get_default_body_rules_for_endpoint("not-a-valid-signature") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_default_body_rules_for_endpoint_returns_empty_for_no_rules() -> None:
|
||||||
|
"""claude:chat 没有配置 default_body_rules,应返回空列表。"""
|
||||||
|
assert get_default_body_rules_for_endpoint("claude:chat") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_default_body_rules_for_endpoint_returns_codex_rules() -> None:
|
||||||
|
"""openai:cli 和 openai:compact 应返回 Codex 默认规则。"""
|
||||||
|
cli_rules = get_default_body_rules_for_endpoint("openai:cli")
|
||||||
|
assert len(cli_rules) == 5
|
||||||
|
actions = [r["action"] for r in cli_rules]
|
||||||
|
assert actions == ["drop", "drop", "drop", "set", "set"]
|
||||||
|
assert cli_rules[0]["path"] == "max_output_tokens"
|
||||||
|
assert cli_rules[1]["path"] == "temperature"
|
||||||
|
assert cli_rules[2]["path"] == "top_p"
|
||||||
|
assert cli_rules[3] == {"action": "set", "path": "store", "value": False}
|
||||||
|
assert cli_rules[4]["path"] == "instructions"
|
||||||
|
assert cli_rules[4]["condition"]["op"] == "not_exists"
|
||||||
|
|
||||||
|
compact_rules = get_default_body_rules_for_endpoint("openai:compact")
|
||||||
|
assert compact_rules == cli_rules
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_default_body_rules_for_endpoint_returns_deep_copy(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
definition = EndpointDefinition(
|
||||||
|
api_family=ApiFamily.OPENAI,
|
||||||
|
endpoint_kind=EndpointKind.CLI,
|
||||||
|
default_body_rules=(
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"path": "metadata",
|
||||||
|
"value": {"source": "default"},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(metadata, "resolve_endpoint_definition", lambda _value: definition)
|
||||||
|
|
||||||
|
rules = get_default_body_rules_for_endpoint("openai:cli")
|
||||||
|
assert rules == [
|
||||||
|
{
|
||||||
|
"action": "set",
|
||||||
|
"path": "metadata",
|
||||||
|
"value": {"source": "default"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
rules[0]["value"]["source"] = "changed"
|
||||||
|
assert definition.default_body_rules[0]["value"]["source"] == "default"
|
||||||
Reference in New Issue
Block a user