Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
82 changes: 0 additions & 82 deletions app/tool_schema.py

This file was deleted.

100 changes: 88 additions & 12 deletions app/upstream_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 2 additions & 4 deletions converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading