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:
fawney19
2026-03-16 14:21:14 +08:00
parent 025e979935
commit 791c9c98dc
9 changed files with 894 additions and 57 deletions

View File

@@ -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'")

View File

@@ -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()

View File

@@ -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) == []