feat(codex): 拆分openai:compact为独立端点,简化Codex请求为透传模式

- 新增openai:compact端点类型(EndpointKind.COMPACT),独立于openai:cli
- OpenAICompactAdapter继承OpenAICliAdapter,自动标记compact模式
- Codex请求补丁改为纯透传:仅清理内部标记,不再修改客户端payload
- stream_policy支持openai:compact独立策略,compact端点移除stream字段
- candidate_builder支持compact回退到cli端点
- auth_type: vertex_ai重命名为service_account,保持向后兼容
- Vertex Provider新增api_formats与auth_type组合校验
- KeyAllowedModels对话框改为从Provider获取模型,展示provider_model_name
- Dialog内Select组件自动禁用Portal,修复层级遮挡问题
- 新增Codex compact端点回填迁移脚本
This commit is contained in:
fawney19
2026-03-01 23:55:26 +08:00
parent 4bf3a453e7
commit 97d42703da
37 changed files with 650 additions and 286 deletions

View File

@@ -481,6 +481,7 @@ class EndpointHealthService:
kind_label = {
"chat": "Chat",
"cli": "CLI",
"compact": "Compact",
"video": "Video",
"image": "Image",
}.get(kind, kind)

View File

@@ -21,7 +21,7 @@ MAX_CONCURRENT_REQUESTS = 5
# 模型获取格式优先级:同族内优先使用 chat 端点,若无则回退到 cli 端点
MODEL_FETCH_FORMAT_PRIORITY: list[tuple[str, ...]] = [
("openai:chat", "openai:cli"),
("openai:chat", "openai:cli", "openai:compact"),
("claude:chat", "claude:cli"),
("gemini:chat", "gemini:cli"),
]

View File

@@ -1,21 +1,8 @@
"""
Codex provider request patching helpers (passthrough path).
"""Codex provider request patching helpers (passthrough path).
This is the **primary** Codex request transformation used by the normalizer's
``patch_for_variant("codex")`` fast path. It applies minimal, non-destructive
patches directly on the original request dict -- no internal representation
round-trip, so every field the client sent is preserved as-is unless explicitly
modified here.
Transformations applied:
- Force ``store=false``.
- Force ``stream=true`` (except compact requests).
- Force ``parallel_tool_calls=true``.
- Ensure ``instructions`` exists (empty string when absent).
- Convert ``role=system`` messages to ``role=developer``.
- Drop request parameters known to be rejected by Codex gateways.
- Force ``include`` to ``["reasoning.encrypted_content"]``.
- Drop compatibility-problematic fields (``context_management`` / ``user``).
Codex requests are now treated as passthrough:
- Do not mutate client payload fields.
- Only strip internal sentinel fields that must never reach upstream.
"""
from __future__ import annotations
@@ -24,20 +11,6 @@ from typing import Any
from src.core.provider_types import ProviderType
_REJECTED_PARAMS: frozenset[str] = frozenset(
{
"max_output_tokens",
"max_completion_tokens",
"temperature",
"top_p",
"service_tier",
"previous_response_id",
"truncation",
}
)
_REQUIRED_INCLUDE_ITEM = "reasoning.encrypted_content"
def patch_openai_cli_request_for_codex(request_body: dict[str, Any]) -> dict[str, Any]:
"""
@@ -46,49 +19,8 @@ def patch_openai_cli_request_for_codex(request_body: dict[str, Any]) -> dict[str
This function never mutates the input object.
"""
out: dict[str, Any] = dict(request_body)
for k in _REJECTED_PARAMS:
out.pop(k, None)
# Codex gateways often reject/ignore persistence; be explicit.
out["store"] = False
# Codex compact endpoint is non-streaming; normal responses requires stream=true.
is_compact = bool(out.pop("_aether_compact", False))
if is_compact:
out.pop("stream", None)
else:
out["stream"] = True
# Codex expects parallel tool calls enabled.
out["parallel_tool_calls"] = True
# Ensure instructions exists (some gateways require it even if empty).
instructions = out.get("instructions")
if not isinstance(instructions, str):
out["instructions"] = ""
# Convert "system" role to "developer" (Codex behavior).
input_items = out.get("input")
if isinstance(input_items, list):
patched_items: list[Any] = []
for item in input_items:
if isinstance(item, dict):
patched = dict(item)
if patched.get("role") == "system":
patched["role"] = "developer"
patched_items.append(patched)
else:
patched_items.append(item)
out["input"] = patched_items
# Keep codex behavior deterministic: force the exact include list.
out["include"] = [_REQUIRED_INCLUDE_ITEM]
# Codex upstream currently rejects these fields.
out.pop("context_management", None)
out.pop("user", None)
# Internal routing marker; never send upstream.
out.pop("_aether_compact", None)
return out
@@ -108,7 +40,7 @@ def maybe_patch_request_for_codex(
"""
if (provider_type or "").lower() != ProviderType.CODEX:
return request_body
if (provider_api_format or "").lower() != "openai:cli":
if (provider_api_format or "").lower() not in {"openai:cli", "openai:compact"}:
return request_body
if not isinstance(request_body, dict):
return request_body

View File

@@ -55,13 +55,15 @@ def get_upstream_stream_policy(
Defaults:
- Codex + openai:cli: FORCE_STREAM (Codex upstream requires stream=true).
- Codex + openai:compact: follow endpoint/client policy (no hard force).
"""
provider_obj = getattr(endpoint, "provider", None)
pt = str(provider_type or getattr(provider_obj, "provider_type", "") or "").strip().lower()
sig = str(endpoint_sig or getattr(endpoint, "api_format", "") or "").strip().lower()
is_codex_compact = False
if pt == ProviderType.CODEX and sig == "openai:cli":
is_codex_cli = pt == ProviderType.CODEX and sig == "openai:cli"
is_codex_compact = pt == ProviderType.CODEX and sig == "openai:compact"
if is_codex_cli:
try:
from src.services.provider.adapters.codex.context import get_codex_request_context
@@ -82,8 +84,7 @@ def get_upstream_stream_policy(
if parsed != UpstreamStreamPolicy.AUTO:
# Codex upstream requires streaming; do not allow forcing non-stream.
if (
pt == ProviderType.CODEX
and sig == "openai:cli"
is_codex_cli
and parsed == UpstreamStreamPolicy.FORCE_NON_STREAM
and not is_codex_compact
):
@@ -93,7 +94,7 @@ def get_upstream_stream_policy(
return parsed
# Safe-by-default: Codex Responses OAuth behaves like SSE-only.
if pt == ProviderType.CODEX and sig == "openai:cli":
if is_codex_cli:
return (
UpstreamStreamPolicy.FORCE_NON_STREAM
if is_codex_compact
@@ -134,13 +135,30 @@ def enforce_stream_mode_for_upstream(
meta = resolve_endpoint_definition(provider_api_format)
provider_uses_stream = meta.stream_in_body if meta is not None else True
provider_fmt = str(provider_api_format or "").strip().lower()
# OpenAI compact endpoint: keep request body stream field absent.
if provider_fmt == "openai:compact":
request_body.pop("stream", None)
return request_body
# Backward compatibility: Codex compact routed through openai:cli + context marker.
if provider_fmt == "openai:cli":
try:
from src.services.provider.adapters.codex.context import get_codex_request_context
ctx = get_codex_request_context()
if ctx and ctx.is_compact:
request_body.pop("stream", None)
return request_body
except Exception:
pass
if provider_uses_stream:
request_body["stream"] = bool(upstream_is_stream)
else:
request_body.pop("stream", None)
# OpenAI Chat Completions: request usage in streaming mode.
provider_fmt = str(provider_api_format or "").strip().lower()
if upstream_is_stream and provider_fmt == "openai:chat":
stream_options = request_body.get("stream_options")
if not isinstance(stream_options, dict):

View File

@@ -6,7 +6,12 @@ Provider Key 认证类型相关规则。
def normalize_auth_type(raw: str) -> str:
"""将数据库中的 auth_type 归一化为逻辑类型。
Kiro 在数据库中存储为 ``"kiro"`` ``"oauth"``,统一映射为 ``"oauth"``。
- ``"kiro"`` -> ``"oauth"`` (Kiro 使用 OAuth 流程)
- ``"vertex_ai"`` -> ``"service_account"`` (旧的 Vertex AI auth_type 已重命名)
"""
t = str(raw or "api_key").strip() or "api_key"
return "oauth" if t == "kiro" else t
if t == "kiro": # TODO: 迁移稳定后清理,同步清理各处 in ("...", "kiro") 兼容检查
return "oauth"
if t == "vertex_ai": # TODO: 迁移稳定后清理,同步清理各处 in ("...", "vertex_ai") 兼容检查
return "service_account"
return t

View File

@@ -26,14 +26,14 @@ def check_duplicate_key(
对于不同的认证类型,使用不同的比较方式:
- api_key: 比较 API Key 的哈希值
- vertex_ai: 比较 Service Account 的 client_email
- service_account: 比较 Service Account 的 client_email
Args:
db: 数据库会话
provider_id: Provider ID
auth_type: 认证类型 (api_key, vertex_ai, oauth)
auth_type: 认证类型 (api_key, service_account, oauth)
new_api_key: 新的 API Key用于 api_key 类型)
new_auth_config: 新的认证配置(用于 vertex_ai 类型)
new_auth_config: 新的认证配置(用于 service_account 类型)
exclude_key_id: 要排除的 Key ID用于更新场景
"""
if auth_type == "api_key" and new_api_key:
@@ -66,7 +66,7 @@ def check_duplicate_key(
# 解密失败时跳过该 Key
continue
elif auth_type == "vertex_ai" and new_auth_config:
elif auth_type in ("service_account", "vertex_ai") and new_auth_config:
new_client_email = (
new_auth_config.get("client_email") if isinstance(new_auth_config, dict) else None
)
@@ -76,7 +76,7 @@ def check_duplicate_key(
# 仅查询同 auth_type 且有 auth_config 的 Keys
query = db.query(ProviderAPIKey).filter(
ProviderAPIKey.provider_id == provider_id,
ProviderAPIKey.auth_type == "vertex_ai",
ProviderAPIKey.auth_type.in_(["service_account", "vertex_ai"]),
ProviderAPIKey.auth_config.isnot(None),
)
if exclude_key_id:

View File

@@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
from src.core.crypto import crypto_service
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger
from src.core.provider_types import ProviderType
from src.models.database import Provider, ProviderAPIKey
from src.models.endpoint_models import (
EndpointAPIKeyCreate,
@@ -32,6 +33,37 @@ from src.services.provider_keys.key_side_effects import (
from src.services.provider_keys.response_builder import build_key_response
def _validate_vertex_api_formats(
provider_type: str | None,
auth_type: str,
api_formats: list[str] | None,
) -> None:
"""校验 Vertex Provider 的 key.api_formats 与 auth_type 是否匹配。"""
if str(provider_type or "").strip().lower() != ProviderType.VERTEX_AI.value:
return
formats = [
str(fmt or "").strip().lower() for fmt in (api_formats or []) if str(fmt or "").strip()
]
if not formats:
return
if auth_type == "api_key":
allowed = {"gemini:chat"}
elif auth_type in {"service_account", "vertex_ai"}:
allowed = {"gemini:chat", "claude:chat"}
else:
return
invalid = sorted({fmt for fmt in formats if fmt not in allowed})
if invalid:
allowed_text = ", ".join(sorted(allowed))
invalid_text = ", ".join(invalid)
raise InvalidRequestException(
f"Vertex {auth_type} 不支持以下 API 格式: {invalid_text};允许: {allowed_text}"
)
@dataclass
class _UpdateKeyPreparation:
"""更新 Key 前置准备结果。"""
@@ -154,14 +186,14 @@ def _prepare_update_key_payload(
raise InvalidRequestException("API Key 认证模式下 api_key 不能为空")
# 切换回 API Key清理非本模式配置
update_data["auth_config"] = None
elif target_auth_type == "vertex_ai":
elif target_auth_type == "service_account":
if is_auth_type_switch and not update_data.get("auth_config"):
raise InvalidRequestException(
"从 API Key 切换到 Vertex AI 认证模式时,必须提供 Service Account JSON"
"切换到 Service Account 认证模式时,必须提供 Service Account JSON"
)
# Vertex AI 不允许手工写入 api_key仅保留占位符
# Service Account 不允许手工写入 api_key仅保留占位符
if api_key_in_payload and api_key_value not in {None, "__placeholder__"}:
raise InvalidRequestException("Vertex AI 认证模式下不允许直接填写 api_key")
raise InvalidRequestException("Service Account 认证模式下不允许直接填写 api_key")
if is_auth_type_switch or api_key_in_payload:
update_data["api_key"] = "__placeholder__"
elif target_auth_type == "oauth":
@@ -186,6 +218,15 @@ def _prepare_update_key_payload(
exclude_key_id=key_id,
)
# Vertex Provider: auth_type 与 api_formats 的组合必须合法
provider = getattr(key, "provider", None)
effective_api_formats = update_data.get("api_formats", key.api_formats)
_validate_vertex_api_formats(
getattr(provider, "provider_type", None),
target_auth_type,
effective_api_formats,
)
if "api_key" in update_data:
api_key_raw = update_data["api_key"]
if api_key_raw is None:
@@ -261,7 +302,7 @@ def _prepare_create_key_payload(
if auth_type == "api_key":
if not key_data.api_key:
raise InvalidRequestException("API Key 认证模式下 api_key 为必填字段")
elif auth_type == "vertex_ai":
elif auth_type == "service_account":
if not key_data.auth_config:
raise InvalidRequestException("Service Account 认证模式下 auth_config 为必填字段")
elif auth_type == "oauth":
@@ -384,12 +425,19 @@ async def create_provider_key_response(
if not key_data.api_formats:
raise InvalidRequestException("api_formats 为必填字段")
_, new_key = _prepare_create_key_payload(
auth_type, new_key = _prepare_create_key_payload(
db=db,
provider_id=provider_id,
key_data=key_data,
)
# Vertex Provider: auth_type 与 api_formats 的组合必须合法
_validate_vertex_api_formats(
getattr(provider, "provider_type", None),
auth_type,
key_data.api_formats,
)
db.add(new_key)
db.commit()
db.refresh(new_key)

View File

@@ -59,7 +59,7 @@ def get_keys_grouped_by_format(db: Session) -> dict:
continue # 跳过没有 API 格式的 Key
auth_type = normalize_auth_type(getattr(key, "auth_type", "api_key"))
if auth_type == "vertex_ai":
if auth_type in ("service_account", "vertex_ai"):
masked_key = "[Service Account]"
elif auth_type == "oauth":
masked_key = "[OAuth Token]"
@@ -166,15 +166,15 @@ def reveal_endpoint_key_payload(
auth_type = normalize_auth_type(getattr(key, "auth_type", "api_key"))
# Vertex AI 类型返回 auth_config需要解密
if auth_type == "vertex_ai":
# Service Account 类型返回 auth_config需要解密
if auth_type in ("service_account", "vertex_ai"):
encrypted_auth_config = getattr(key, "auth_config", None)
if encrypted_auth_config:
try:
decrypted_config = crypto_service.decrypt(encrypted_auth_config)
auth_config = json.loads(decrypted_config)
logger.info(f"[REVEAL] 查看 Auth Config: ID={key_id}, Name={key.name}")
return {"auth_type": "vertex_ai", "auth_config": auth_config}
return {"auth_type": auth_type, "auth_config": auth_config}
except Exception as e:
logger.error(f"解密 Auth Config 失败: ID={key_id}, Error={e}")
raise InvalidRequestException(
@@ -184,12 +184,11 @@ def reveal_endpoint_key_payload(
# 兼容auth_config 为空时尝试从 api_key 解密(仅对迁移前的旧数据有效)
try:
decrypted_key = crypto_service.decrypt(key.api_key)
# 检查是否是新格式的占位符(表示 auth_config 丢失)
if decrypted_key == "__placeholder__":
logger.error(f"Vertex AI Key 缺少 auth_config: ID={key_id}")
logger.error(f"Service Account Key 缺少 auth_config: ID={key_id}")
raise InvalidRequestException("认证配置丢失,请重新添加该密钥。")
logger.info(f"[REVEAL] 查看完整 Key (legacy vertex_ai): ID={key_id}, Name={key.name}")
return {"auth_type": "vertex_ai", "auth_config": decrypted_key}
logger.info(f"[REVEAL] 查看完整 Key (legacy SA): ID={key_id}, Name={key.name}")
return {"auth_type": auth_type, "auth_config": decrypted_key}
except InvalidRequestException:
raise
except Exception as e:

View File

@@ -19,8 +19,8 @@ def build_key_response(
"""构建 Key 响应对象。"""
auth_type = normalize_auth_type(getattr(key, "auth_type", "api_key"))
if auth_type == "vertex_ai":
# Vertex AI 使用 Service Account不显示占位符
if auth_type in ("service_account", "vertex_ai"):
# Service Account 不显示占位符
masked_key = "[Service Account]"
elif auth_type == "oauth":
masked_key = "[OAuth Token]"

View File

@@ -415,9 +415,12 @@ class CandidateBuilder:
if isinstance(raw, int) and raw > 0:
output_limit = raw
# chat/cli 互相可回退(用于同协议族下的端点变体),video/image 等不跨类回退
# chat/cli 互相可回退(用于同协议族下的端点变体),compact 可回退到 cli。
# video/image 等不跨类回退。
if client_kind in {EndpointKind.CHAT, EndpointKind.CLI}:
allowed_kinds = {EndpointKind.CHAT, EndpointKind.CLI}
elif client_kind == EndpointKind.COMPACT:
allowed_kinds = {EndpointKind.COMPACT, EndpointKind.CLI}
else:
allowed_kinds = {client_kind}