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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
## Handoff: team-verify → complete
|
## 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.
|
- **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 `app/main.py` behavior change, no Anthropic streaming fallback synthesis, and no extra refactor beyond test/schema/docs alignment.
|
- **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**: 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.
|
- **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**: `tests/test_tool_call_bridge.py`, `app/anthropic_schema.py`, `DESIGN.md`, `README.md`
|
- **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`.
|
- **Remaining**: Run git scope check, create the phase checkpoint commit, push to Gitea, and keep local `main` synced with `origin/main`.
|
||||||
|
|||||||
120
app/http/tooling_policy.py
Normal file
120
app/http/tooling_policy.py
Normal file
@@ -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
|
||||||
121
app/main.py
121
app/main.py
@@ -35,7 +35,6 @@ from .http.tool_bridge import (
|
|||||||
_allowed_stream_tool_event,
|
_allowed_stream_tool_event,
|
||||||
_allowed_tool_events,
|
_allowed_tool_events,
|
||||||
_anthropic_forced_tool_name,
|
_anthropic_forced_tool_name,
|
||||||
_anthropic_tool_name as _shared_anthropic_tool_name,
|
|
||||||
_anthropic_tool_result_block,
|
_anthropic_tool_result_block,
|
||||||
_anthropic_tool_use_block,
|
_anthropic_tool_use_block,
|
||||||
_forced_tool_event_from_text,
|
_forced_tool_event_from_text,
|
||||||
@@ -43,9 +42,14 @@ from .http.tool_bridge import (
|
|||||||
_json_string,
|
_json_string,
|
||||||
_openai_forced_tool_name,
|
_openai_forced_tool_name,
|
||||||
_openai_tool_call,
|
_openai_tool_call,
|
||||||
_openai_tool_name as _shared_openai_tool_name,
|
|
||||||
_tool_code_single_arg_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 .lingma_pool import LingmaPool, PoolInstance
|
||||||
from .logging_config import configure_logging, get_logger, request_id_var
|
from .logging_config import configure_logging, get_logger, request_id_var
|
||||||
from .model_map import build_model_name_map, flatten_model_keys, resolve_model
|
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"))
|
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:
|
def _resolve_ask_mode(model: str, has_tooling_context: bool) -> str:
|
||||||
return _shared_resolve_ask_mode(
|
return _shared_resolve_ask_mode(
|
||||||
model,
|
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.
|
# 1. Reuse the upstream sessionId so Lingma/Qwen hits its KV cache.
|
||||||
# 2. Send only the new user message instead of the whole history.
|
# 2. Send only the new user message instead of the whole history.
|
||||||
# 3. Stick the request to the pool instance that originally served it.
|
# 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)
|
has_tooling_context = _openai_has_tooling_context(req, messages_dump)
|
||||||
execution = await prepare_execution_context(
|
execution = await prepare_execution_context(
|
||||||
protocol="chat",
|
protocol="chat",
|
||||||
@@ -1023,7 +918,7 @@ async def v1_messages(req: AnthropicMessagesRequest, request: Request):
|
|||||||
|
|
||||||
# ------------------------------------------------------------- session reuse
|
# ------------------------------------------------------------- session reuse
|
||||||
try:
|
try:
|
||||||
tool_config = _anthropic_tool_config(req)
|
tool_config = _anthropic_tool_config(req, settings=settings)
|
||||||
except HTTPException as exc:
|
except HTTPException as exc:
|
||||||
detail = exc.detail if isinstance(exc.detail, dict) else {}
|
detail = exc.detail if isinstance(exc.detail, dict) else {}
|
||||||
error = detail.get("error") if isinstance(detail.get("error"), dict) else {}
|
error = detail.get("error") if isinstance(detail.get("error"), dict) else {}
|
||||||
|
|||||||
Reference in New Issue
Block a user