diff --git a/README.md b/README.md index 5c5c599..11a3a4e 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ The template binds to localhost only. Configure HTTPS and restrict network acces - **Model:** a public ID from the WebUI or `GET /v1/models`. - All accounts share these URLs; no region parameter is needed. Anthropic SDKs append `/v1/messages` themselves, so leave it out of the Base URL. - The search endpoints run web searches only — no model calls. See [Web search](docs/clients.md#web-search). -- Tool parameters: across all three inference protocols, an empty parameter root becomes an object schema. Legacy field maps with boolean `required` flags are wrapped in `properties`, with required names collected at the root. Roots containing schema keywords or `$`/`x-` extensions, and nested schemas, are left untouched. +- Tool parameters: across all three inference protocols, an empty parameter root becomes an object schema. Legacy field maps with boolean `required` flags are wrapped in `properties`, with required names collected at the root. A schema keyword such as `pattern` still counts as a field name when it holds a field. Standard schemas, roots with `$`/`x-` extensions, and nested schemas are left untouched. [Codex CLI, Claude Code / CC Switch and other client examples →](docs/clients.md) diff --git a/README.zh-CN.md b/README.zh-CN.md index 639d45a..2ad6253 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -52,7 +52,7 @@ Compose 默认拉取 `latest`(`CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebu - **模型**:使用 WebUI 中的对外 ID,或查询 `GET /v1/models`。 - 国内、国际账号共用这些地址,无需额外的地域参数。Anthropic SDK 会自行追加 `/v1/messages`,不要把它写入 Base URL。 - 搜索端点只做联网搜索,不调用模型,见[联网搜索](docs/clients.zh-CN.md#联网搜索)。 -- 工具参数:三种推理协议均会将空参数根转换为对象 Schema;带字段级布尔 `required` 标记的旧式参数映射会包入 `properties`,必填字段名汇总到根部。含 Schema 关键字或 `$`/`x-` 扩展的根,以及嵌套 Schema,保持原样。 +- 工具参数:三种推理协议均会将空参数根转换为对象 Schema;带字段级布尔 `required` 标记的旧式参数映射会包入 `properties`,必填字段名汇总到根部。`pattern` 等 Schema 关键字若对应的是字段,仍按字段名处理。标准 Schema、含 `$`/`x-` 扩展的根以及嵌套 Schema 保持原样。 [Codex CLI、Claude Code / CC Switch 等配置示例 →](docs/clients.zh-CN.md) diff --git a/app/tool_schema.py b/app/tool_schema.py deleted file mode 100644 index 6a42727..0000000 --- a/app/tool_schema.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Adapt empty tool roots and field maps with explicit legacy required flags.""" -from __future__ import annotations - -# A root containing schema keywords is not a legacy property-name -> schema map. -_SCHEMA_KEYS = frozenset(( - "$schema", "$id", "$ref", "$anchor", "$dynamicRef", "$dynamicAnchor", "$defs", - "$vocabulary", "$comment", "definitions", "type", "properties", "patternProperties", - "additionalProperties", "unevaluatedProperties", "propertyNames", "required", - "dependentRequired", "dependentSchemas", "dependencies", "minProperties", "maxProperties", - "allOf", "anyOf", "oneOf", "not", "if", "then", "else", "items", "prefixItems", - "additionalItems", "contains", "unevaluatedItems", "minItems", "maxItems", "uniqueItems", - "minContains", "maxContains", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", - "multipleOf", "minLength", "maxLength", "pattern", "format", "contentEncoding", - "contentMediaType", "contentSchema", "enum", "const", "title", "description", "default", - "examples", "deprecated", "readOnly", "writeOnly", -)) -_TYPES = frozenset(("string", "number", "integer", "boolean", "object", "array", "null")) - - -def _parameter_root(parameters): - if not isinstance(parameters, dict): - return parameters - if not parameters: - return {"type": "object", "properties": {}} - if (_SCHEMA_KEYS.intersection(parameters) - or any(name.startswith(("$", "x-")) for name in parameters) - or not any(isinstance(field, dict) and type(field.get("required")) is bool - for field in parameters.values())): - return parameters - if not all( - isinstance(field, dict) and isinstance(field.get("type"), str) and field["type"] in _TYPES - and ("required" not in field or type(field["required"]) is bool) - for field in parameters.values() - ): - return parameters - - properties, required = {}, [] - for name, field in parameters.items(): - field = dict(field) - if field.pop("required", False): - required.append(name) - properties[name] = field - result = {"type": "object", "properties": properties} - if required: - result["required"] = required - return result - - -def normalize_tool_parameter_roots(body: dict) -> dict: - """Copy only changed function declarations; preserve messages and nested schemas. - - Some clients send an empty root or a legacy parameter map with per-field - boolean ``required`` flags. Upstreams require an explicit object root. - Ambiguous/incomplete standard schemas are deliberately left untouched. - """ - result = body - for key in ("tools", "functions"): - tools = body.get(key) - if not isinstance(tools, list): - continue - normalized, changed = [], False - for tool in tools: - if not isinstance(tool, dict) or (key == "tools" and tool.get("type") != "function"): - normalized.append(tool) - continue - function = tool.get("function") if key == "tools" else tool - if not isinstance(function, dict) or "parameters" not in function: - normalized.append(tool) - continue - original = function["parameters"] - parameters = _parameter_root(original) - if parameters is original: - normalized.append(tool) - continue - function = {**function, "parameters": parameters} - normalized.append({**tool, "function": function} if key == "tools" else function) - changed = True - if changed: - if result is body: - result = dict(body) - result[key] = normalized - return result diff --git a/app/upstream_compat.py b/app/upstream_compat.py index 4ee82f9..8c324a9 100644 --- a/app/upstream_compat.py +++ b/app/upstream_compat.py @@ -12,6 +12,10 @@ _SCHEMA_CHILDREN = frozenset(("items", "additionalItems", "contains", "unevaluatedItems", "additionalProperties", "unevaluatedProperties", "propertyNames", "not", "if", "then", "else", "contentSchema")) +# Keywords whose values are schemas cannot be told apart from fields by shape alone. +_SCHEMA_VALUED = _SCHEMA_MAPS | _SCHEMA_CHILDREN +# Keywords holding arbitrary instance data are ambiguous whatever their shape. +_INSTANCE_KEYWORDS = frozenset(("const", "default")) # Backslashes allow the code to sit in JSON that upstream embedded as an escaped string. _CHANNEL_CODE = re.compile(r"""(['"])code\\*\1\s*:\s*\\*(['"]?)(?:11128|11-128)\\*\2(?!\d)""", re.IGNORECASE) @@ -72,22 +76,94 @@ def _has_underscore_escape(value) -> bool: return True -def normalize_tool_patterns(body: dict) -> dict: - """Normalize tool declarations and structured-output schemas, copying only what changes.""" +def _looks_like_field(value) -> bool: + """A legacy field carries a boolean required flag, a field type or an enum.""" + return (type(value.get("required")) is bool or isinstance(value.get("type"), (str, list)) + or isinstance(value.get("enum"), list)) + + +def _holds_field(name: str, value: dict) -> bool: + """Whether an object under this root key is a legacy field rather than a schema keyword value.""" + if name in _INSTANCE_KEYWORDS: + return False + if name in _SCHEMA_VALUED: + return _looks_like_field(value) + # Other keywords hold scalars or arrays, so an object under them can only be a field. + return True + + +def _parameter_root(parameters): + """Wrap an empty root or a field map with legacy boolean required flags in an object schema. + + A root is legacy only when every value is a field object and at least one field carries a boolean + ``required``. A keyword such as ``pattern`` or ``description`` holding an object is a field name; + schema-valued keywords must also look like a field, and ``const``/``default`` never count. Anything + else is passed through. + """ + if not isinstance(parameters, dict): + return parameters + if not parameters: + return {"type": "object", "properties": {}} + if (any(name.startswith(("$", "x-")) for name in parameters) + or not all(isinstance(field, dict) and type(field.get("required", False)) is bool + for field in parameters.values()) + or not any("required" in field for field in parameters.values()) + or not all(_holds_field(name, field) for name, field in parameters.items())): + return parameters + properties, required = {}, [] + for name, field in parameters.items(): + field = dict(field) + if field.pop("required", False): + required.append(name) + properties[name] = field + result = {"type": "object", "properties": properties} + if required: + result["required"] = required + return result + + +def _normalize_declaration(declaration, *, roots: bool): + """Return the declaration with normalized parameters, or the same object when nothing changes.""" + if not isinstance(declaration, dict) or "parameters" not in declaration: + return declaration + original = declaration["parameters"] + parameters = _parameter_root(original) if roots else original + if _has_underscore_escape(parameters): + parameters = deepcopy(parameters) + _normalize_schema_patterns(parameters) + return declaration if parameters is original else {**declaration, "parameters": parameters} + + +def _normalize_tool(tool, key: str): + if key == "functions": + return _normalize_declaration(tool, roots=True) + if not isinstance(tool, dict): + return tool + function = tool.get("function") + if isinstance(function, dict) and tool.get("type", "function") == "function": + normalized = _normalize_declaration(function, roots=True) + if normalized is not function or "type" not in tool: + # An explicit type lets named tool_choice and upstream recognize the declaration. + tool = {**tool, "type": "function", "function": normalized} + # Bare entries are not function declarations, but their patterns still reach the RE2 upstream. + return _normalize_declaration(tool, roots=False) + + +def normalize_tool_schemas(body: dict) -> dict: + """Make tool and structured-output schemas acceptable upstream, copying only what changes. + + Function parameter roots become explicit object schemas, and RE2-incompatible ``\\_`` escapes are + removed from schema patterns. Messages, nested schemas and tool metadata are preserved. + """ result = body for key in ("tools", "functions"): tools = body.get(key) - if not isinstance(tools, list) or not _has_underscore_escape(tools): + if not isinstance(tools, list): continue - result = result if result is not body else dict(body) - result[key] = tools = deepcopy(tools) - for tool in tools: - if not isinstance(tool, dict): - continue - function = tool.get("function") - if isinstance(function, dict): - _normalize_schema_patterns(function.get("parameters")) - _normalize_schema_patterns(tool.get("parameters")) + normalized = [_normalize_tool(tool, key) for tool in tools] + if any(new is not old for new, old in zip(normalized, tools)): + result = result if result is not body else dict(body) + result[key] = normalized response_format = body.get("response_format") if isinstance(response_format, dict) and _has_underscore_escape(response_format): result = result if result is not body else dict(body) diff --git a/converter.py b/converter.py index 0eebe3d..e2d8824 100644 --- a/converter.py +++ b/converter.py @@ -84,8 +84,7 @@ def desensitize_body(body, roles=("system",), desensitize_harness_user=False, from app.inference_auth import require_api_key from app.admin_auth import SessionStoreError from app.content_filter import ContentFilterDetector, is_filter_error -from app.tool_schema import normalize_tool_parameter_roots -from app.upstream_compat import (channel_fallback_url, is_channel_check_error, normalize_tool_patterns, +from app.upstream_compat import (channel_fallback_url, is_channel_check_error, normalize_tool_schemas, should_channel_fallback) from app.request_limits import ImageLimitError, apply_image_policy from app.safe_logging import format_log_body, sanitize_log_text @@ -3582,10 +3581,9 @@ def _prepare_chat_body(body: dict, *, region=None, session_payload=None) -> dict messages = [messages[system_index], *messages[:system_index], *messages[system_index + 1:]] message_indices.insert(0, message_indices.pop(system_index)) body["messages"] = normalize_chat_messages(messages, message_indices=message_indices) - body = normalize_tool_parameter_roots(body) + body = normalize_tool_schemas(body) _normalize_tool_choice(body) body["stream"] = True - body = normalize_tool_patterns(body) body.setdefault("stream_options", {"include_usage": True}) body = _chat_body_desensitize(body) _guard_request_size(body) diff --git a/tests/test_tool_schema.py b/tests/test_tool_schema.py index 9a8b289..869396a 100644 --- a/tests/test_tool_schema.py +++ b/tests/test_tool_schema.py @@ -5,12 +5,11 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from copy import deepcopy -import json import unittest from unittest.mock import patch import converter -from app.tool_schema import normalize_tool_parameter_roots +from app.upstream_compat import normalize_tool_schemas import test_region_routing as fixtures @@ -33,11 +32,11 @@ class ToolSchemaTests(unittest.TestCase): def test_legacy_roots_are_normalized_without_mutating_the_request(self): body = {"tools": legacy_tools(), "messages": [{"role": "user", "content": "keep text"}]} before = deepcopy(body) - result = normalize_tool_parameter_roots(body) + result = normalize_tool_schemas(body) self.assertEqual(result["tools"], expected_tools()) self.assertEqual(body, before) self.assertIs(result["messages"], body["messages"]) - self.assertIs(normalize_tool_parameter_roots(result), result) + self.assertIs(normalize_tool_schemas(result), result) def test_preserves_field_constraints_metadata_and_required_order(self): nested = {"type": "string", "enum": ["x", "y"], "pattern": "^[a-z]+$", "default": "x"} @@ -47,7 +46,7 @@ def test_preserves_field_constraints_metadata_and_required_order(self): body = {"tools": [tool("example", fields)]} body["tools"][0]["function"].update(description="Keep metadata", strict=False) before = deepcopy(body) - result = normalize_tool_parameter_roots(body) + result = normalize_tool_schemas(body) schema = result["tools"][0]["function"]["parameters"] self.assertEqual(schema["required"], ["target", "values"]) self.assertEqual(schema["properties"]["target"], nested) @@ -58,7 +57,7 @@ def test_preserves_field_constraints_metadata_and_required_order(self): self.assertEqual(body, before) def test_optional_only_map_has_no_root_required(self): - result = normalize_tool_parameter_roots({"tools": [tool("optional", {"value": {"type": "string", "required": False}})]}) + result = normalize_tool_schemas({"tools": [tool("optional", {"value": {"type": "string", "required": False}})]}) self.assertEqual(result["tools"][0]["function"]["parameters"], {"type": "object", "properties": {"value": {"type": "string"}}}) @@ -68,7 +67,7 @@ def test_standard_schema_and_nested_empty_schemas_are_untouched(self): "record": {"type": "object", "additionalProperties": {"type": "string"}}}, "required": ["free"], "additionalProperties": False, "x-vendor": {"keep": True}} body = {"tools": [tool("standard", schema)]} - self.assertIs(normalize_tool_parameter_roots(body), body) + self.assertIs(normalize_tool_schemas(body), body) self.assertIs(body["tools"][0]["function"]["parameters"], schema) def test_ambiguous_or_incomplete_standard_roots_are_not_guessed(self): @@ -84,23 +83,91 @@ def test_ambiguous_or_incomplete_standard_roots_are_not_guessed(self): for schema in schemas: with self.subTest(schema=schema): body = {"tools": [tool("untouched", schema)]} - self.assertIs(normalize_tool_parameter_roots(body), body) + self.assertIs(normalize_tool_schemas(body), body) + + def test_schema_keyword_field_names_do_not_hide_a_legacy_map(self): + fields = {"pattern": {"type": "string", "required": True}, "path": {"type": "string"}, + "description": {"type": "string", "required": False}} + result = normalize_tool_schemas({"tools": [tool("grep", fields)]}) + self.assertEqual(result["tools"][0]["function"]["parameters"], { + "type": "object", "required": ["pattern"], "properties": { + "pattern": {"type": "string"}, "path": {"type": "string"}, + "description": {"type": "string"}}}) + + def test_one_required_flag_is_enough_for_fields_without_a_single_type(self): + fields = {"target": {"type": "string", "required": True}, "note": {"type": ["string", "null"]}, + "mode": {"enum": ["a", "b"]}} + result = normalize_tool_schemas({"tools": [tool("mixed", fields)]}) + self.assertEqual(result["tools"][0]["function"]["parameters"], { + "type": "object", "required": ["target"], "properties": { + "target": {"type": "string"}, "note": {"type": ["string", "null"]}, + "mode": {"enum": ["a", "b"]}}}) + + def test_schema_keywords_holding_schema_values_keep_the_root_untouched(self): + for schema in [{"properties": {"a": {"type": "string"}}, "b": {"type": "string", "required": True}}, + {"description": "keep", "b": {"type": "string", "required": True}}]: + with self.subTest(schema=schema): + body = {"tools": [tool("standard", schema)]} + self.assertIs(normalize_tool_schemas(body), body) + + def test_instance_data_keywords_keep_the_root_untouched(self): + for schema in [{"const": {"type": "object", "required": True}}, + {"default": {"type": "string", "required": True}, "b": {"type": "string"}}]: + with self.subTest(schema=schema): + body = {"tools": [tool("standard", schema)]} + self.assertIs(normalize_tool_schemas(body), body) + + def test_enum_only_fields_under_schema_keywords_are_fields(self): + fields = {"items": {"enum": ["a", "b"]}, "format": {"enum": ["json", "text"]}, + "target": {"type": "string", "required": True}} + result = normalize_tool_schemas({"tools": [tool("export", fields)]}) + self.assertEqual(result["tools"][0]["function"]["parameters"], { + "type": "object", "required": ["target"], "properties": { + "items": {"enum": ["a", "b"]}, "format": {"enum": ["json", "text"]}, + "target": {"type": "string"}}}) + + def test_function_entries_without_a_type_are_normalized(self): + body = {"tools": [{"function": {"name": "list_workers", "parameters": {}}}, + {"function": {"name": "standard", "parameters": {"type": "object"}}}]} + result = normalize_tool_schemas(body) + self.assertEqual(result["tools"], [tool("list_workers", {"type": "object", "properties": {}}), + tool("standard", {"type": "object"})]) + self.assertNotIn("type", body["tools"][0]) + + def test_named_choice_selects_a_function_entry_without_a_type(self): + body = {"model": "auto", "messages": [{"role": "user", "content": "hi"}], + "tools": [{"function": {"name": "list_workers", "parameters": {}}}], + "tool_choice": {"type": "function", "function": {"name": "list_workers"}}} + with patch.dict(converter.CONFIG, {"model_guard": False, "desensitize": False}): + result = converter._prepare_chat_body(body) + self.assertEqual(result["tools"], [tool("list_workers", {"type": "object", "properties": {}})]) + self.assertEqual(result["tool_choice"], "required") + + def test_legacy_roots_and_pattern_escapes_are_normalized_together(self): + fields = {"run_id": {"type": "string", "pattern": r"^agent\_run\_", "required": True}} + body = {"tools": [tool("agent_run", fields)]} + before = deepcopy(body) + result = normalize_tool_schemas(body) + self.assertEqual(result["tools"][0]["function"]["parameters"], { + "type": "object", "required": ["run_id"], + "properties": {"run_id": {"type": "string", "pattern": "^agent_run_"}}}) + self.assertEqual(body, before) def test_non_function_tools_and_missing_parameters_are_untouched(self): for entry in [None, "unknown", {"type": "custom", "parameters": {}}, {"type": "function"}, {"type": "function", "function": {"name": "no_schema"}}]: with self.subTest(entry=entry): body = {"tools": [entry]} - self.assertIs(normalize_tool_parameter_roots(body), body) + self.assertIs(normalize_tool_schemas(body), body) for body in [{}, {"tools": None}, {"functions": {}}, {"tools": []}]: - self.assertIs(normalize_tool_parameter_roots(body), body) + self.assertIs(normalize_tool_schemas(body), body) def test_legacy_functions_and_mixed_declarations(self): valid = tool("valid", {"type": "object", "properties": {}}) body = {"tools": [valid, *legacy_tools()], "functions": [ {"name": "flat", "parameters": {"target": {"type": "string", "required": True}}}]} before = deepcopy(body) - result = normalize_tool_parameter_roots(body) + result = normalize_tool_schemas(body) self.assertIs(result["tools"][0], valid) self.assertEqual(result["tools"][1:], expected_tools()) self.assertEqual(result["functions"][0]["parameters"], expected_tools()[0]["function"]["parameters"]) diff --git a/tests/test_upstream_compat.py b/tests/test_upstream_compat.py index 771812f..ef8990f 100644 --- a/tests/test_upstream_compat.py +++ b/tests/test_upstream_compat.py @@ -15,7 +15,7 @@ import converter from app import buddy_task, growth_events, rate_measurement -from app.upstream_compat import channel_fallback_url, is_channel_check_error, normalize_tool_patterns +from app.upstream_compat import channel_fallback_url, is_channel_check_error, normalize_tool_schemas from tests import test_region_routing as fixtures CHANNEL_ERROR = { @@ -43,7 +43,7 @@ def test_nested_patterns_are_normalized_without_mutating_input_or_instance_data( "tools": [{"type": "function", "function": {"name": "agent_run", "parameters": schema}}], } before = deepcopy(body) - result = normalize_tool_patterns(body) + result = normalize_tool_schemas(body) actual = result["tools"][0]["function"]["parameters"] self.assertEqual(body, before) self.assertEqual(actual["properties"]["runId"]["pattern"], "^agent_run_") @@ -59,7 +59,7 @@ def test_legacy_and_bare_parameters_are_covered(self): "tools": [{"name": "bare", "parameters": {"pattern": r"a\_b"}}], "functions": [{"name": "legacy", "parameters": {"pattern": r"c\_d"}}], } - result = normalize_tool_patterns(body) + result = normalize_tool_schemas(body) self.assertEqual(result["tools"][0]["parameters"]["pattern"], "a_b") self.assertEqual(result["functions"][0]["parameters"]["pattern"], "c_d") @@ -72,7 +72,7 @@ def test_escape_parity_and_pattern_property_collisions_are_preserved(self): "triple": {"pattern": r"a\\\_b"}, }, }}]} - result = normalize_tool_patterns(body) + result = normalize_tool_schemas(body) schema = result["tools"][0]["parameters"] self.assertEqual(schema["patternProperties"]["a_b"], {"allOf": [{"const": "original"}, {"const": "existing"}]}) @@ -83,21 +83,21 @@ def test_escape_parity_and_pattern_property_collisions_are_preserved(self): def test_boolean_pattern_property_collisions_are_merged(self): body = {"tools": [{"name": "bare", "parameters": { "patternProperties": {r"^a\_b$": False, "^a_b$": {"type": "string"}}}}]} - result = normalize_tool_patterns(body) + result = normalize_tool_schemas(body) self.assertEqual(result["tools"][0]["parameters"]["patternProperties"], {"^a_b$": {"allOf": [False, {"type": "string"}]}}) def test_bodies_without_escapes_are_not_copied(self): body = {"tools": [{"name": "bare", "parameters": {"pattern": "a_b"}}], "response_format": {"type": "json_object"}} - result = normalize_tool_patterns(body) + result = normalize_tool_schemas(body) self.assertIs(result, body) self.assertIs(result["tools"], body["tools"]) def test_response_format_json_schema_is_normalized(self): body = {"response_format": {"type": "json_schema", "json_schema": { "name": "result", "schema": {"properties": {"runId": {"pattern": r"^agent\_run\_"}}}}}} - result = normalize_tool_patterns(body) + result = normalize_tool_schemas(body) schema = result["response_format"]["json_schema"]["schema"] self.assertEqual(schema["properties"]["runId"]["pattern"], "^agent_run_") self.assertEqual(body["response_format"]["json_schema"]["schema"]["properties"]["runId"]["pattern"], r"^agent\_run\_")