mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor(conversion): OpenAI Chat/Responses API 跨格式字段双向转换统一化
- 将工具/tool_choice/web_search/custom tool 的双向转换函数提取到 constants.py,
openai.py 和 openai_cli.py 共享,消除两端逻辑不一致
- 新增 Chat <-> Responses 的 passthrough 字段白名单,支持 metadata/user/
service_tier/prompt_cache_key 等字段跨格式透传
- 支持 text config (response_format + verbosity) 在 Chat/Responses 间互转
- 修复 Gemini json_schema 解包:OpenAI 的 {name, schema, strict} 包装层
不再被整体传入 Gemini responseSchema
- 修复 reasoning_effort 优先级:显式 effort 优先于 budget_tokens 反推,
避免 Claude output_config.effort 被覆盖
- Gemini 格式输出增加 web_search_options -> googleSearch 工具映射
- Claude schema validator 增加 web_search 类型工具的宽松校验
- 新增覆盖测试:custom tool/tool_choice、allowed_tools、web_search 双向转换、
text config 映射、passthrough 字段保留、跨格式 schema 校验
This commit is contained in:
@@ -1023,6 +1023,16 @@ def _validate_claude_tool_def(tool: Any, index: int) -> list[str]:
|
||||
|
||||
if "name" not in tool:
|
||||
errors.append(f"{prefix}: tool missing 'name'")
|
||||
tool_type = tool.get("type")
|
||||
if isinstance(tool_type, str) and tool_type.startswith("web_search_"):
|
||||
max_uses = tool.get("max_uses")
|
||||
if max_uses is not None and not isinstance(max_uses, int):
|
||||
errors.append(f"{prefix}: web_search 'max_uses' must be int")
|
||||
user_location = tool.get("user_location")
|
||||
if user_location is not None and not isinstance(user_location, dict):
|
||||
errors.append(f"{prefix}: web_search 'user_location' must be dict")
|
||||
return errors
|
||||
|
||||
if "input_schema" not in tool:
|
||||
errors.append(f"{prefix}: tool missing 'input_schema'")
|
||||
|
||||
|
||||
@@ -261,6 +261,253 @@ def test_openai_chat_empty_tool_call_id_repaired_when_convert_to_openai_cli() ->
|
||||
assert function_call_output.get("call_id") == generated_id
|
||||
|
||||
|
||||
def test_openai_chat_prompt_cache_key_preserved_when_convert_to_openai_cli() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
openai_chat_req = {
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"prompt_cache_key": "cache-key-123",
|
||||
}
|
||||
|
||||
out = reg.convert_request(openai_chat_req, "openai:chat", "openai:cli")
|
||||
|
||||
assert out["prompt_cache_key"] == "cache-key-123"
|
||||
|
||||
|
||||
def test_openai_chat_text_config_maps_to_openai_cli_text_block() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
openai_chat_req = {
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "answer", "schema": {"type": "object"}},
|
||||
},
|
||||
"verbosity": "low",
|
||||
"logit_bias": {"42": 3},
|
||||
}
|
||||
|
||||
out = reg.convert_request(openai_chat_req, "openai:chat", "openai:cli")
|
||||
|
||||
assert out["text"] == {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "answer", "schema": {"type": "object"}},
|
||||
},
|
||||
"verbosity": "low",
|
||||
}
|
||||
assert "response_format" not in out
|
||||
assert "verbosity" not in out
|
||||
assert "logit_bias" not in out
|
||||
|
||||
|
||||
def test_openai_cli_text_config_maps_to_openai_chat_fields() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
openai_cli_req = {
|
||||
"model": "gpt-5",
|
||||
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}],
|
||||
"text": {
|
||||
"format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "answer", "schema": {"type": "object"}},
|
||||
},
|
||||
"verbosity": "high",
|
||||
},
|
||||
"prompt_cache_key": "cache-key-456",
|
||||
"service_tier": "flex",
|
||||
"top_logprobs": 4,
|
||||
}
|
||||
|
||||
out = reg.convert_request(openai_cli_req, "openai:cli", "openai:chat")
|
||||
|
||||
assert out["response_format"] == {
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "answer", "schema": {"type": "object"}},
|
||||
}
|
||||
assert out["verbosity"] == "high"
|
||||
assert out["prompt_cache_key"] == "cache-key-456"
|
||||
assert out["service_tier"] == "flex"
|
||||
assert out["top_logprobs"] == 4
|
||||
assert "text" not in out
|
||||
|
||||
|
||||
def test_openai_chat_custom_tool_and_choice_convert_to_openai_cli() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
openai_chat_req = {
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"tools": [
|
||||
{
|
||||
"type": "custom",
|
||||
"custom": {
|
||||
"name": "grep_repo",
|
||||
"description": "Search repository text",
|
||||
"format": {"type": "text"},
|
||||
},
|
||||
}
|
||||
],
|
||||
"tool_choice": {"type": "custom", "custom": {"name": "grep_repo"}},
|
||||
}
|
||||
|
||||
out = reg.convert_request(openai_chat_req, "openai:chat", "openai:cli")
|
||||
|
||||
assert out["tools"] == [
|
||||
{
|
||||
"type": "custom",
|
||||
"name": "grep_repo",
|
||||
"description": "Search repository text",
|
||||
"format": {"type": "text"},
|
||||
}
|
||||
]
|
||||
assert out["tool_choice"] == {"type": "custom", "name": "grep_repo"}
|
||||
|
||||
|
||||
def test_openai_cli_custom_tool_and_choice_convert_to_openai_chat() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
openai_cli_req = {
|
||||
"model": "gpt-5",
|
||||
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}],
|
||||
"tools": [
|
||||
{
|
||||
"type": "custom",
|
||||
"name": "grep_repo",
|
||||
"description": "Search repository text",
|
||||
"format": {"type": "text"},
|
||||
}
|
||||
],
|
||||
"tool_choice": {"type": "custom", "name": "grep_repo"},
|
||||
}
|
||||
|
||||
out = reg.convert_request(openai_cli_req, "openai:cli", "openai:chat")
|
||||
|
||||
assert out["tools"] == [
|
||||
{
|
||||
"type": "custom",
|
||||
"custom": {
|
||||
"name": "grep_repo",
|
||||
"description": "Search repository text",
|
||||
"format": {"type": "text"},
|
||||
},
|
||||
}
|
||||
]
|
||||
assert out["tool_choice"] == {"type": "custom", "custom": {"name": "grep_repo"}}
|
||||
|
||||
|
||||
def test_openai_chat_allowed_tools_choice_convert_to_openai_cli() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
openai_chat_req = {
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"tool_choice": {
|
||||
"type": "allowed_tools",
|
||||
"allowed_tools": {
|
||||
"mode": "required",
|
||||
"tools": [{"type": "function", "function": {"name": "grep_repo"}}],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out = reg.convert_request(openai_chat_req, "openai:chat", "openai:cli")
|
||||
|
||||
assert out["tool_choice"] == {
|
||||
"type": "allowed_tools",
|
||||
"mode": "required",
|
||||
"tools": [{"type": "function", "function": {"name": "grep_repo"}}],
|
||||
}
|
||||
|
||||
|
||||
def test_openai_cli_allowed_tools_choice_convert_to_openai_chat() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
openai_cli_req = {
|
||||
"model": "gpt-5",
|
||||
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}],
|
||||
"tool_choice": {
|
||||
"type": "allowed_tools",
|
||||
"mode": "required",
|
||||
"tools": [{"type": "function", "name": "grep_repo"}],
|
||||
},
|
||||
}
|
||||
|
||||
out = reg.convert_request(openai_cli_req, "openai:cli", "openai:chat")
|
||||
|
||||
assert out["tool_choice"] == {
|
||||
"type": "allowed_tools",
|
||||
"allowed_tools": {
|
||||
"mode": "required",
|
||||
"tools": [{"type": "function", "name": "grep_repo"}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_openai_chat_web_search_options_convert_to_openai_cli_tools() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
openai_chat_req = {
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"web_search_options": {
|
||||
"user_location": {
|
||||
"type": "approximate",
|
||||
"approximate": {"country": "US", "city": "San Francisco"},
|
||||
},
|
||||
"search_context_size": "high",
|
||||
},
|
||||
}
|
||||
|
||||
out = reg.convert_request(openai_chat_req, "openai:chat", "openai:cli")
|
||||
|
||||
assert out["tools"] == [
|
||||
{
|
||||
"type": "web_search",
|
||||
"user_location": {
|
||||
"type": "approximate",
|
||||
"country": "US",
|
||||
"city": "San Francisco",
|
||||
},
|
||||
"search_context_size": "high",
|
||||
}
|
||||
]
|
||||
assert "web_search_options" not in out
|
||||
|
||||
|
||||
def test_openai_cli_web_search_tool_convert_to_openai_chat_options() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
openai_cli_req = {
|
||||
"model": "gpt-5",
|
||||
"input": [{"role": "user", "content": [{"type": "input_text", "text": "hi"}]}],
|
||||
"tools": [
|
||||
{
|
||||
"type": "web_search",
|
||||
"user_location": {
|
||||
"type": "approximate",
|
||||
"country": "US",
|
||||
"city": "San Francisco",
|
||||
},
|
||||
"search_context_size": "high",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
out = reg.convert_request(openai_cli_req, "openai:cli", "openai:chat")
|
||||
|
||||
assert out["web_search_options"] == {
|
||||
"user_location": {
|
||||
"type": "approximate",
|
||||
"approximate": {"country": "US", "city": "San Francisco"},
|
||||
},
|
||||
"search_context_size": "high",
|
||||
}
|
||||
assert "tools" not in out
|
||||
|
||||
|
||||
def test_openai_cli_reasoning_preserved_in_roundtrip() -> None:
|
||||
"""测试 OpenAI CLI 的 reasoning block 在 roundtrip 中被保留"""
|
||||
reg = _make_registry_with_cli()
|
||||
@@ -354,6 +601,36 @@ def test_claude_tool_use_to_openai_cli() -> None:
|
||||
assert fco_items[0]["output"] == "Hello World"
|
||||
|
||||
|
||||
def test_claude_explicit_effort_preserved_in_openai_cli() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
claude_req = {
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"thinking": {"type": "enabled", "budget_tokens": 31999},
|
||||
"output_config": {"effort": "medium"},
|
||||
}
|
||||
|
||||
out = reg.convert_request(claude_req, "claude:chat", "openai:cli")
|
||||
|
||||
assert out["reasoning"] == {"effort": "medium"}
|
||||
|
||||
|
||||
def test_claude_explicit_effort_preserved_in_openai_chat() -> None:
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
claude_req = {
|
||||
"model": "gpt-5.4",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"thinking": {"type": "enabled", "budget_tokens": 31999},
|
||||
"output_config": {"effort": "medium"},
|
||||
}
|
||||
|
||||
out = reg.convert_request(claude_req, "claude:chat", "openai:chat")
|
||||
|
||||
assert out["reasoning_effort"] == "medium"
|
||||
|
||||
|
||||
def test_stream_openai_cli_in_progress_event() -> None:
|
||||
"""测试 OpenAI CLI 流式 response.in_progress 事件"""
|
||||
reg = _make_registry_with_cli()
|
||||
|
||||
@@ -16,6 +16,8 @@ from src.core.api_format.conversion.normalizers.openai import OpenAINormalizer
|
||||
from src.core.api_format.conversion.registry import FormatConversionRegistry
|
||||
from src.core.api_format.conversion.stream_state import StreamState
|
||||
|
||||
from .fixtures.schema_validators import get_request_validator
|
||||
|
||||
|
||||
def _make_registry() -> FormatConversionRegistry:
|
||||
reg = FormatConversionRegistry()
|
||||
@@ -105,3 +107,114 @@ def test_registry_canonical_stream_openai_to_claude() -> None:
|
||||
|
||||
types = [cast(dict[str, Any], e).get("type") for e in cast(list[dict[str, Any]], out_events)]
|
||||
assert types[:3] == ["message_start", "content_block_start", "content_block_delta"]
|
||||
|
||||
|
||||
def test_registry_canonical_request_openai_to_claude_preserves_supported_fields() -> None:
|
||||
reg = _make_registry()
|
||||
|
||||
openai_req = {
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"reasoning_effort": "xhigh",
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "answer_schema",
|
||||
"schema": {"type": "object", "properties": {"answer": {"type": "string"}}},
|
||||
"strict": True,
|
||||
},
|
||||
},
|
||||
"verbosity": "high",
|
||||
"web_search_options": {
|
||||
"search_context_size": "high",
|
||||
"user_location": {"type": "approximate", "city": "Shanghai"},
|
||||
},
|
||||
"prompt_cache_key": "cache-key-123",
|
||||
"service_tier": "priority",
|
||||
"safety_identifier": "user-123",
|
||||
}
|
||||
|
||||
out = reg.convert_request(openai_req, "openai:chat", "claude:chat")
|
||||
|
||||
assert out["output_config"] == {"effort": "max"}
|
||||
assert "tools" in out
|
||||
assert out["tools"][-1] == {
|
||||
"type": "web_search_20250305",
|
||||
"name": "web_search",
|
||||
"max_uses": 10,
|
||||
"user_location": {"type": "approximate", "city": "Shanghai"},
|
||||
}
|
||||
|
||||
for dropped_field in (
|
||||
"response_format",
|
||||
"verbosity",
|
||||
"reasoning_effort",
|
||||
"web_search_options",
|
||||
"prompt_cache_key",
|
||||
"service_tier",
|
||||
"safety_identifier",
|
||||
):
|
||||
assert dropped_field not in out
|
||||
|
||||
validator = get_request_validator("claude:chat")
|
||||
assert validator is not None
|
||||
assert validator(out) == []
|
||||
|
||||
|
||||
def test_registry_canonical_request_openai_to_gemini_preserves_supported_fields() -> None:
|
||||
reg = _make_registry()
|
||||
|
||||
openai_req = {
|
||||
"model": "gpt-5",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"reasoning_effort": "medium",
|
||||
"n": 3,
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "answer_schema",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"answer": {"type": "string"}},
|
||||
"required": ["answer"],
|
||||
},
|
||||
"strict": True,
|
||||
},
|
||||
},
|
||||
"verbosity": "low",
|
||||
"web_search_options": {"search_context_size": "high"},
|
||||
"prompt_cache_key": "cache-key-456",
|
||||
"service_tier": "flex",
|
||||
"safety_identifier": "user-456",
|
||||
}
|
||||
|
||||
out = reg.convert_request(openai_req, "openai:chat", "gemini:chat")
|
||||
|
||||
generation_config = cast(dict[str, Any], out.get("generation_config") or {})
|
||||
assert generation_config["thinkingConfig"] == {
|
||||
"includeThoughts": True,
|
||||
"thinkingBudget": 2048,
|
||||
}
|
||||
assert generation_config["candidateCount"] == 3
|
||||
assert generation_config["responseMimeType"] == "application/json"
|
||||
assert generation_config["responseSchema"] == {
|
||||
"type": "object",
|
||||
"properties": {"answer": {"type": "string"}},
|
||||
"required": ["answer"],
|
||||
}
|
||||
assert out["tools"] == [{"googleSearch": {}}]
|
||||
|
||||
for dropped_field in (
|
||||
"verbosity",
|
||||
"reasoning_effort",
|
||||
"response_format",
|
||||
"web_search_options",
|
||||
"prompt_cache_key",
|
||||
"service_tier",
|
||||
"safety_identifier",
|
||||
):
|
||||
assert dropped_field not in out
|
||||
|
||||
validator = get_request_validator("gemini:chat")
|
||||
assert validator is not None
|
||||
assert validator(out) == []
|
||||
|
||||
Reference in New Issue
Block a user