From 8b012310a2317890c7fb77627d15df02ef424e32 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 22 Apr 2026 11:37:50 +0800 Subject: [PATCH] refactor: extract tooling policy helpers Move tool allowlist, tool_config, and tooling-context helpers into app/http/tooling_policy.py while keeping route behavior unchanged. Co-Authored-By: Claude Opus 4.7 --- .omc/handoffs/team-verify.md | 8 +-- app/http/tooling_policy.py | 120 ++++++++++++++++++++++++++++++++++ app/main.py | 121 +++-------------------------------- 3 files changed, 132 insertions(+), 117 deletions(-) create mode 100644 app/http/tooling_policy.py diff --git a/.omc/handoffs/team-verify.md b/.omc/handoffs/team-verify.md index d0e20ee..12139af 100644 --- a/.omc/handoffs/team-verify.md +++ b/.omc/handoffs/team-verify.md @@ -1,6 +1,6 @@ ## Handoff: team-verify → complete -- **Decided**: This phase freezes the current tool-call contract instead of expanding runtime behavior: Anthropic forced-tool fallback remains non-stream-only, and streaming keeps raw text when no upstream tool event exists. -- **Rejected**: No `app/main.py` behavior change, no Anthropic streaming fallback synthesis, and no extra refactor beyond test/schema/docs alignment. -- **Risks**: README / schema wording must stay aligned with runtime if tool-call support expands later; any future Anthropic streaming fallback work should update both docs and the new regression test together. -- **Files**: `tests/test_tool_call_bridge.py`, `app/anthropic_schema.py`, `DESIGN.md`, `README.md` +- **Decided**: This phase only extracts tooling-policy helpers out of `app/main.py` into `app/http/tooling_policy.py`; OpenAI / Anthropic tool allowlist, `tool_config`, and tooling-context behavior stay unchanged. +- **Rejected**: No protocol/runtime behavior change, no stream/non-stream bridge rewrite, and no session-cache or ask-mode semantic change beyond moving helper definitions. +- **Risks**: The new helper takes `settings` explicitly, so any future callers must pass the gateway settings object; if tooling policy expands later, keep helper/module boundaries aligned with the existing bridge regression suite. +- **Files**: `app/main.py`, `app/http/tooling_policy.py` - **Remaining**: Run git scope check, create the phase checkpoint commit, push to Gitea, and keep local `main` synced with `origin/main`. diff --git a/app/http/tooling_policy.py b/app/http/tooling_policy.py new file mode 100644 index 0000000..f4032d4 --- /dev/null +++ b/app/http/tooling_policy.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException + +from ..anthropic_schema import AnthropicMessagesRequest +from ..config import Settings +from ..openai_schema import ChatCompletionsRequest +from .tool_bridge import ( + _anthropic_forced_tool_name, + _anthropic_tool_name, + _openai_forced_tool_name, + _openai_tool_name, +) + + +def _tool_allowlist(settings: Settings) -> set[str]: + return {name.strip() for name in settings.tool_allowlist if isinstance(name, str) and name.strip()} + + +def _filter_allowed_tools( + tools: list[dict[str, Any]], *, provider: str, settings: Settings +) -> list[dict[str, Any]]: + allowlist = _tool_allowlist(settings) + if not allowlist: + return tools + name_fn = _openai_tool_name if provider == "openai" else _anthropic_tool_name + return [tool for tool in tools if (name := name_fn(tool)) and name in allowlist] + + +def _ensure_tool_choice_allowed(tool_choice: Any, *, provider: str, settings: Settings) -> None: + allowlist = _tool_allowlist(settings) + if not allowlist: + return + forced_name = ( + _openai_forced_tool_name(tool_choice) + if provider == "openai" + else _anthropic_forced_tool_name(tool_choice) + ) + if forced_name and forced_name not in allowlist: + raise HTTPException( + status_code=400, + detail={ + "error": { + "type": "invalid_request_error", + "message": f"tool '{forced_name}' is not allowed", + } + }, + ) + + +def _openai_tool_config(req: ChatCompletionsRequest, *, settings: Settings) -> dict[str, Any] | None: + if not settings.tool_forward_enabled: + return None + has_tools = isinstance(req.tools, list) and len(req.tools) > 0 + has_choice = req.tool_choice is not None + if not has_tools and not has_choice: + return None + _ensure_tool_choice_allowed(req.tool_choice, provider="openai", settings=settings) + tools = _filter_allowed_tools(req.tools or [], provider="openai", settings=settings) + return { + "provider": "openai", + "tools": tools, + "tool_choice": req.tool_choice, + } + + +def _anthropic_tool_config( + req: AnthropicMessagesRequest, *, settings: Settings +) -> dict[str, Any] | None: + if not settings.tool_forward_enabled: + return None + has_tools = isinstance(req.tools, list) and len(req.tools) > 0 + has_choice = req.tool_choice is not None + if not has_tools and not has_choice: + return None + _ensure_tool_choice_allowed(req.tool_choice, provider="anthropic", settings=settings) + tools = _filter_allowed_tools(req.tools or [], provider="anthropic", settings=settings) + return { + "provider": "anthropic", + "tools": tools, + "tool_choice": req.tool_choice, + } + + +def _openai_has_tooling_context(req: ChatCompletionsRequest, messages: list[dict[str, Any]]) -> bool: + if isinstance(req.tools, list) and len(req.tools) > 0: + return True + if req.tool_choice is not None: + return True + for m in messages: + role = m.get("role") + if role == "tool": + return True + if role == "assistant" and m.get("tool_calls"): + return True + return False + + +def _anthropic_content_has_tool_blocks(content: Any) -> bool: + if not isinstance(content, list): + return False + for item in content: + if isinstance(item, dict) and item.get("type") in {"tool_use", "tool_result"}: + return True + return False + + +def _anthropic_has_tooling_context(req: AnthropicMessagesRequest) -> bool: + if isinstance(req.tools, list) and len(req.tools) > 0: + return True + if req.tool_choice is not None: + return True + if _anthropic_content_has_tool_blocks(req.system): + return True + for m in req.messages: + if _anthropic_content_has_tool_blocks(m.content): + return True + return False diff --git a/app/main.py b/app/main.py index 46c16f2..a0c2dcc 100644 --- a/app/main.py +++ b/app/main.py @@ -35,7 +35,6 @@ from .http.tool_bridge import ( _allowed_stream_tool_event, _allowed_tool_events, _anthropic_forced_tool_name, - _anthropic_tool_name as _shared_anthropic_tool_name, _anthropic_tool_result_block, _anthropic_tool_use_block, _forced_tool_event_from_text, @@ -43,9 +42,14 @@ from .http.tool_bridge import ( _json_string, _openai_forced_tool_name, _openai_tool_call, - _openai_tool_name as _shared_openai_tool_name, _tool_code_single_arg_name, ) +from .http.tooling_policy import ( + _anthropic_has_tooling_context, + _anthropic_tool_config, + _openai_has_tooling_context, + _openai_tool_config, +) from .lingma_pool import LingmaPool, PoolInstance from .logging_config import configure_logging, get_logger, request_id_var from .model_map import build_model_name_map, flatten_model_keys, resolve_model @@ -379,115 +383,6 @@ def _include_usage(stream_options: dict | None) -> bool: return bool(stream_options.get("include_usage")) -def _tool_allowlist() -> set[str]: - return {name.strip() for name in settings.tool_allowlist if isinstance(name, str) and name.strip()} - - -def _openai_tool_name(tool: Any) -> str | None: - return _shared_openai_tool_name(tool) - - -def _anthropic_tool_name(tool: Any) -> str | None: - return _shared_anthropic_tool_name(tool) - - -def _filter_allowed_tools(tools: list[dict[str, Any]], *, provider: str) -> list[dict[str, Any]]: - allowlist = _tool_allowlist() - if not allowlist: - return tools - name_fn = _openai_tool_name if provider == "openai" else _anthropic_tool_name - return [tool for tool in tools if (name := name_fn(tool)) and name in allowlist] - - -def _ensure_tool_choice_allowed(tool_choice: Any, *, provider: str) -> None: - allowlist = _tool_allowlist() - if not allowlist: - return - forced_name = ( - _openai_forced_tool_name(tool_choice) - if provider == "openai" - else _anthropic_forced_tool_name(tool_choice) - ) - if forced_name and forced_name not in allowlist: - raise HTTPException( - status_code=400, - detail={ - "error": { - "type": "invalid_request_error", - "message": f"tool '{forced_name}' is not allowed", - } - }, - ) - - -def _openai_tool_config(req: ChatCompletionsRequest) -> dict[str, Any] | None: - if not settings.tool_forward_enabled: - return None - has_tools = isinstance(req.tools, list) and len(req.tools) > 0 - has_choice = req.tool_choice is not None - if not has_tools and not has_choice: - return None - _ensure_tool_choice_allowed(req.tool_choice, provider="openai") - tools = _filter_allowed_tools(req.tools or [], provider="openai") - return { - "provider": "openai", - "tools": tools, - "tool_choice": req.tool_choice, - } - - -def _anthropic_tool_config(req: AnthropicMessagesRequest) -> dict[str, Any] | None: - if not settings.tool_forward_enabled: - return None - has_tools = isinstance(req.tools, list) and len(req.tools) > 0 - has_choice = req.tool_choice is not None - if not has_tools and not has_choice: - return None - _ensure_tool_choice_allowed(req.tool_choice, provider="anthropic") - tools = _filter_allowed_tools(req.tools or [], provider="anthropic") - return { - "provider": "anthropic", - "tools": tools, - "tool_choice": req.tool_choice, - } - - -def _openai_has_tooling_context(req: ChatCompletionsRequest, messages: list[dict[str, Any]]) -> bool: - if isinstance(req.tools, list) and len(req.tools) > 0: - return True - if req.tool_choice is not None: - return True - for m in messages: - role = m.get("role") - if role == "tool": - return True - if role == "assistant" and m.get("tool_calls"): - return True - return False - - -def _anthropic_content_has_tool_blocks(content: Any) -> bool: - if not isinstance(content, list): - return False - for item in content: - if isinstance(item, dict) and item.get("type") in {"tool_use", "tool_result"}: - return True - return False - - -def _anthropic_has_tooling_context(req: AnthropicMessagesRequest) -> bool: - if isinstance(req.tools, list) and len(req.tools) > 0: - return True - if req.tool_choice is not None: - return True - if _anthropic_content_has_tool_blocks(req.system): - return True - for m in req.messages: - if _anthropic_content_has_tool_blocks(m.content): - return True - return False - - def _resolve_ask_mode(model: str, has_tooling_context: bool) -> str: return _shared_resolve_ask_mode( model, @@ -557,7 +452,7 @@ async def v1_chat_completions(req: ChatCompletionsRequest, request: Request): # 1. Reuse the upstream sessionId so Lingma/Qwen hits its KV cache. # 2. Send only the new user message instead of the whole history. # 3. Stick the request to the pool instance that originally served it. - tool_config = _openai_tool_config(req) + tool_config = _openai_tool_config(req, settings=settings) has_tooling_context = _openai_has_tooling_context(req, messages_dump) execution = await prepare_execution_context( protocol="chat", @@ -1023,7 +918,7 @@ async def v1_messages(req: AnthropicMessagesRequest, request: Request): # ------------------------------------------------------------- session reuse try: - tool_config = _anthropic_tool_config(req) + tool_config = _anthropic_tool_config(req, settings=settings) except HTTPException as exc: detail = exc.detail if isinstance(exc.detail, dict) else {} error = detail.get("error") if isinstance(detail.get("error"), dict) else {}