mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: 新增 Codex 上游兼容整流器
- 强制 store=false - 确保 instructions 字段存在(缺失/None -> "") - 强制 parallel_tool_calls=true,并确保 include 包含 reasoning.encrypted_content - 删除 Codex 会拒绝的一些字段:max_output_tokens/max_completion_tokens/max_tokens/temperature/top_p/service_tier - 将 input[] 里 role=system 的 message 改成 developer - 在发送上游请求前注入整流(覆盖“同格式透传”和“跨格式转换”两条链路)
This commit is contained in:
@@ -68,6 +68,7 @@ from src.models.database import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from src.services.cache.aware_scheduler import ProviderCandidate
|
from src.services.cache.aware_scheduler import ProviderCandidate
|
||||||
|
from src.services.provider.codex import maybe_patch_request_for_codex
|
||||||
from src.services.provider.transport import (
|
from src.services.provider.transport import (
|
||||||
build_provider_url,
|
build_provider_url,
|
||||||
get_vertex_ai_effective_format,
|
get_vertex_ai_effective_format,
|
||||||
@@ -779,6 +780,13 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
|
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
|
||||||
request_body = self.prepare_provider_request_body(request_body)
|
request_body = self.prepare_provider_request_body(request_body)
|
||||||
|
|
||||||
|
# Provider-specific compatibility patches (e.g. Codex requires store=false and instructions).
|
||||||
|
request_body = maybe_patch_request_for_codex(
|
||||||
|
provider_type=str(getattr(provider, "provider_type", "") or ""),
|
||||||
|
provider_api_format=str(provider_api_format),
|
||||||
|
request_body=request_body,
|
||||||
|
)
|
||||||
|
|
||||||
# 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式)
|
# 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式)
|
||||||
provider_payload, provider_headers = self._request_builder.build(
|
provider_payload, provider_headers = self._request_builder.build(
|
||||||
request_body,
|
request_body,
|
||||||
@@ -1105,6 +1113,13 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
|
|||||||
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
|
# 同格式:按原逻辑做轻量清理(子类可覆盖以移除不需要的字段)
|
||||||
request_body = self.prepare_provider_request_body(request_body)
|
request_body = self.prepare_provider_request_body(request_body)
|
||||||
|
|
||||||
|
# Provider-specific compatibility patches (e.g. Codex requires store=false and instructions).
|
||||||
|
request_body = maybe_patch_request_for_codex(
|
||||||
|
provider_type=str(getattr(provider, "provider_type", "") or ""),
|
||||||
|
provider_api_format=str(provider_api_format),
|
||||||
|
request_body=request_body,
|
||||||
|
)
|
||||||
|
|
||||||
# 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式)
|
# 构建请求(上游始终使用 header 认证,不跟随客户端的 query 方式)
|
||||||
provider_payload, provider_hdrs = self._request_builder.build(
|
provider_payload, provider_hdrs = self._request_builder.build(
|
||||||
request_body,
|
request_body,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ from src.models.database import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from src.services.cache.aware_scheduler import ProviderCandidate
|
from src.services.cache.aware_scheduler import ProviderCandidate
|
||||||
|
from src.services.provider.codex import maybe_patch_request_for_codex
|
||||||
from src.services.provider.transport import build_provider_url
|
from src.services.provider.transport import build_provider_url
|
||||||
from src.services.system.config import SystemConfigService
|
from src.services.system.config import SystemConfigService
|
||||||
from src.utils.sse_parser import SSEEventParser
|
from src.utils.sse_parser import SSEEventParser
|
||||||
@@ -746,6 +747,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
self.get_model_for_url(request_body, mapped_model) or mapped_model or ctx.model
|
self.get_model_for_url(request_body, mapped_model) or mapped_model or ctx.model
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Provider-specific compatibility patches (e.g. Codex requires store=false and instructions).
|
||||||
|
request_body = maybe_patch_request_for_codex(
|
||||||
|
provider_type=str(getattr(provider, "provider_type", "") or ""),
|
||||||
|
provider_api_format=str(provider_api_format),
|
||||||
|
request_body=request_body,
|
||||||
|
)
|
||||||
|
|
||||||
# 获取认证信息(处理 Service Account 等异步认证场景)
|
# 获取认证信息(处理 Service Account 等异步认证场景)
|
||||||
auth_info = await get_provider_auth(endpoint, key)
|
auth_info = await get_provider_auth(endpoint, key)
|
||||||
|
|
||||||
@@ -2294,6 +2302,13 @@ class CliMessageHandlerBase(BaseMessageHandler):
|
|||||||
self.get_model_for_url(request_body, mapped_model) or mapped_model or model
|
self.get_model_for_url(request_body, mapped_model) or mapped_model or model
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Provider-specific compatibility patches (e.g. Codex requires store=false and instructions).
|
||||||
|
request_body = maybe_patch_request_for_codex(
|
||||||
|
provider_type=str(getattr(provider, "provider_type", "") or ""),
|
||||||
|
provider_api_format=str(provider_api_format),
|
||||||
|
request_body=request_body,
|
||||||
|
)
|
||||||
|
|
||||||
# 获取认证信息(处理 Service Account 等异步认证场景)
|
# 获取认证信息(处理 Service Account 等异步认证场景)
|
||||||
auth_info = await get_provider_auth(endpoint, key)
|
auth_info = await get_provider_auth(endpoint, key)
|
||||||
|
|
||||||
|
|||||||
106
src/services/provider/codex.py
Normal file
106
src/services/provider/codex.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""
|
||||||
|
Codex upstream request compatibility helpers.
|
||||||
|
|
||||||
|
The Codex upstream (https://chatgpt.com/backend-api/codex) is largely compatible with the
|
||||||
|
OpenAI Responses (/responses, aka "openai:cli") schema, but enforces some extra constraints.
|
||||||
|
|
||||||
|
CLIProxyAPI's reference implementation applies a small set of mutations before forwarding.
|
||||||
|
We replicate the same mutations here to keep Aether's routing compatible when
|
||||||
|
Provider.provider_type == "codex".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_CODEX_REQUIRED_INCLUDE_ITEM = "reasoning.encrypted_content"
|
||||||
|
|
||||||
|
|
||||||
|
def patch_openai_cli_request_for_codex(request_body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Mutate an OpenAI Responses (openai:cli) request into a Codex-compatible payload.
|
||||||
|
|
||||||
|
Notes (based on CLIProxyAPI translators):
|
||||||
|
- `store` must be explicitly set to false.
|
||||||
|
- `instructions` field must exist (Codex rejects missing instructions).
|
||||||
|
- Codex rejects several generation params, so strip them.
|
||||||
|
- Codex does not accept `system` role inside the `input` array.
|
||||||
|
- Enable `parallel_tool_calls` and request encrypted reasoning content.
|
||||||
|
"""
|
||||||
|
if not isinstance(request_body, dict):
|
||||||
|
return request_body
|
||||||
|
|
||||||
|
result: dict[str, Any] = dict(request_body)
|
||||||
|
|
||||||
|
# Required by Codex: explicitly disable storing.
|
||||||
|
result["store"] = False
|
||||||
|
|
||||||
|
# Required by Codex: ensure instructions exists (can be empty).
|
||||||
|
instructions = result.get("instructions")
|
||||||
|
if instructions is None:
|
||||||
|
result["instructions"] = ""
|
||||||
|
elif not isinstance(instructions, str):
|
||||||
|
result["instructions"] = str(instructions)
|
||||||
|
|
||||||
|
# Codex defaults/tooling expectations
|
||||||
|
result["parallel_tool_calls"] = True
|
||||||
|
|
||||||
|
include_value = result.get("include")
|
||||||
|
include: list[str] = []
|
||||||
|
if isinstance(include_value, list):
|
||||||
|
include = [v for v in include_value if isinstance(v, str) and v]
|
||||||
|
if _CODEX_REQUIRED_INCLUDE_ITEM not in include:
|
||||||
|
include.append(_CODEX_REQUIRED_INCLUDE_ITEM)
|
||||||
|
result["include"] = include
|
||||||
|
|
||||||
|
# Codex Responses rejects token limit fields and some sampling params.
|
||||||
|
for key in (
|
||||||
|
"max_output_tokens",
|
||||||
|
"max_completion_tokens",
|
||||||
|
"max_tokens",
|
||||||
|
"temperature",
|
||||||
|
"top_p",
|
||||||
|
"service_tier",
|
||||||
|
):
|
||||||
|
result.pop(key, None)
|
||||||
|
|
||||||
|
# Convert role "system" to "developer" in input array to comply with Codex API requirements.
|
||||||
|
input_value = result.get("input")
|
||||||
|
if isinstance(input_value, list):
|
||||||
|
patched_input: list[Any] = []
|
||||||
|
for item in input_value:
|
||||||
|
if (
|
||||||
|
isinstance(item, dict)
|
||||||
|
and item.get("type") == "message"
|
||||||
|
and item.get("role") == "system"
|
||||||
|
):
|
||||||
|
item = dict(item)
|
||||||
|
item["role"] = "developer"
|
||||||
|
patched_input.append(item)
|
||||||
|
result["input"] = patched_input
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_patch_request_for_codex(
|
||||||
|
*,
|
||||||
|
provider_type: str | None,
|
||||||
|
provider_api_format: str | None,
|
||||||
|
request_body: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Apply Codex compatibility patches only when the selected upstream is Codex and the
|
||||||
|
endpoint uses the OpenAI Responses schema ("openai:cli").
|
||||||
|
"""
|
||||||
|
if str(provider_type or "").strip().lower() != "codex":
|
||||||
|
return request_body
|
||||||
|
if str(provider_api_format or "").strip().lower() != "openai:cli":
|
||||||
|
return request_body
|
||||||
|
return patch_openai_cli_request_for_codex(request_body)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"maybe_patch_request_for_codex",
|
||||||
|
"patch_openai_cli_request_for_codex",
|
||||||
|
]
|
||||||
|
|
||||||
103
tests/services/test_codex_request.py
Normal file
103
tests/services/test_codex_request.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.services.provider.codex import maybe_patch_request_for_codex, patch_openai_cli_request_for_codex
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_openai_cli_request_for_codex_sets_store_and_instructions() -> None:
|
||||||
|
req = {"model": "gpt-test", "input": []}
|
||||||
|
out = patch_openai_cli_request_for_codex(req)
|
||||||
|
|
||||||
|
assert out is not req
|
||||||
|
assert out["store"] is False
|
||||||
|
assert "instructions" in out
|
||||||
|
assert isinstance(out["instructions"], str)
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_openai_cli_request_for_codex_strips_rejected_params() -> None:
|
||||||
|
req = {
|
||||||
|
"model": "gpt-test",
|
||||||
|
"input": [],
|
||||||
|
"max_output_tokens": 123,
|
||||||
|
"max_completion_tokens": 456,
|
||||||
|
"max_tokens": 789,
|
||||||
|
"temperature": 0.5,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"service_tier": "default",
|
||||||
|
}
|
||||||
|
out = patch_openai_cli_request_for_codex(req)
|
||||||
|
|
||||||
|
for key in (
|
||||||
|
"max_output_tokens",
|
||||||
|
"max_completion_tokens",
|
||||||
|
"max_tokens",
|
||||||
|
"temperature",
|
||||||
|
"top_p",
|
||||||
|
"service_tier",
|
||||||
|
):
|
||||||
|
assert key not in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_openai_cli_request_for_codex_converts_system_role_to_developer() -> None:
|
||||||
|
req = {
|
||||||
|
"model": "gpt-test",
|
||||||
|
"instructions": "ignored",
|
||||||
|
"input": [
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"role": "system",
|
||||||
|
"content": [{"type": "input_text", "text": "You are a pirate."}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "input_text", "text": "Hello"}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
out = patch_openai_cli_request_for_codex(req)
|
||||||
|
|
||||||
|
assert isinstance(out.get("input"), list)
|
||||||
|
assert out["input"][0]["role"] == "developer"
|
||||||
|
assert out["input"][1]["role"] == "user"
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_openai_cli_request_for_codex_adds_required_include_item() -> None:
|
||||||
|
req = {"model": "gpt-test", "input": []}
|
||||||
|
out = patch_openai_cli_request_for_codex(req)
|
||||||
|
|
||||||
|
assert "include" in out
|
||||||
|
assert "reasoning.encrypted_content" in out["include"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_maybe_patch_request_for_codex_is_noop_for_non_codex() -> None:
|
||||||
|
req = {"model": "gpt-test", "input": []}
|
||||||
|
out = maybe_patch_request_for_codex(
|
||||||
|
provider_type="custom",
|
||||||
|
provider_api_format="openai:cli",
|
||||||
|
request_body=req,
|
||||||
|
)
|
||||||
|
assert out is req
|
||||||
|
|
||||||
|
|
||||||
|
def test_maybe_patch_request_for_codex_is_noop_for_non_openai_cli() -> None:
|
||||||
|
req = {"model": "gpt-test", "input": []}
|
||||||
|
out = maybe_patch_request_for_codex(
|
||||||
|
provider_type="codex",
|
||||||
|
provider_api_format="openai:chat",
|
||||||
|
request_body=req,
|
||||||
|
)
|
||||||
|
assert out is req
|
||||||
|
|
||||||
|
|
||||||
|
def test_maybe_patch_request_for_codex_patches_for_codex_openai_cli() -> None:
|
||||||
|
req = {"model": "gpt-test", "input": []}
|
||||||
|
out = maybe_patch_request_for_codex(
|
||||||
|
provider_type="codex",
|
||||||
|
provider_api_format="openai:cli",
|
||||||
|
request_body=req,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert out is not req
|
||||||
|
assert out["store"] is False
|
||||||
|
assert "instructions" in out
|
||||||
|
|
||||||
Reference in New Issue
Block a user