Skip to content

feat: OpenAI-compatible tool_calls passthrough and model updates#38

Open
DeepExtrema wants to merge 2 commits intoRichardAtCT:mainfrom
DeepExtrema:feat/openai-passthrough-tools
Open

feat: OpenAI-compatible tool_calls passthrough and model updates#38
DeepExtrema wants to merge 2 commits intoRichardAtCT:mainfrom
DeepExtrema:feat/openai-passthrough-tools

Conversation

@DeepExtrema
Copy link

Summary

  • OpenAI tool_calls passthrough: When callers send tools in chat completion requests, the wrapper now detects passthrough mode and enables Claude's built-in tools (Read, Bash, WebSearch, etc.), translating ToolUseBlock responses into OpenAI-format streaming tool_calls
  • Model & schema fixes: Updates default model to claude-opus-4-6, fixes system_prompt format to prevent SDK crash, adds ToolCall/FunctionCall models for proper OpenAI response formatting
  • Tool message handling: message_adapter.py now processes tool role messages for multi-turn tool conversations
  • MCP bridge infrastructure: Adds openclaw_bridge.py as foundation for future MCP-based tool bridging

Test plan

  • All 438 existing tests pass
  • Verified basic text completion still works
  • Verified streaming responses work correctly
  • Manual testing of tool_calls passthrough with an OpenAI-compatible client
  • Verify system_prompt fix resolves SDK crash in production

🤖 Generated with Claude Code

- Add ToolCall/FunctionCall models for OpenAI-format tool_calls responses
- Detect passthrough mode when callers send tools in requests
- Enable Claude's built-in tools (Read, Bash, WebSearch, etc.) in passthrough mode
- Translate Claude ToolUseBlock responses to OpenAI streaming tool_calls format
- Handle `tool` role messages in message_adapter for multi-turn tool conversations
- Fix system_prompt format to use SDK-expected structure (prevents crash)
- Update default model to claude-opus-4-6
- Add openclaw_bridge.py for future MCP-based tool bridging
- All 438 tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings February 14, 2026 18:46
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the OpenAI-compatible wrapper to support “passthrough” tool usage (via OpenAI-style tools requests), refreshes default/model/schema handling for the Claude Agent SDK, and lays groundwork for an MCP/OpenClaw bridge.

Changes:

  • Adds passthrough-mode handling for tools requests, including streaming tool_calls emission and updated finish reasons.
  • Updates default model to claude-opus-4-6 and adjusts system prompt handling to avoid SDK crashes.
  • Extends request/response models and message adaptation to support OpenAI tool-conversation patterns (tool role, tool_calls, etc.).

Reviewed changes

Copilot reviewed 9 out of 12 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
tests/test_sdk_migration.py Updates default model assertion to claude-opus-4-6.
tests/test_claude_cli_unit.py Updates system prompt expectation to plain string format.
src/openclaw_bridge.py Adds OpenClaw/MCP bridge scaffolding and tool_call extraction/formatting helpers.
src/models.py Expands request/message schema (tools/tool roles/tool_calls) and finish reasons; adds ToolCall/FunctionCall.
src/message_adapter.py Adds prompt truncation and additional output filtering substitutions.
src/main.py Implements passthrough-mode tool enablement and tool_calls streaming/non-streaming response shaping.
src/constants.py Adds PASSTHROUGH_ALLOWED_TOOLS and updates supported/default models.
src/claude_cli.py Fixes system_prompt option format; adds mcp_servers param passthrough.
pyproject.toml Adds anthropic dependency.
poetry.lock Regenerates lockfile to include new dependency / Poetry version metadata changes.
package-lock.json Adds an (empty) npm lockfile.
.hypothesis/unicode_data/15.0.0/codec-utf-8.json.gz Adds Hypothesis cache artifact (should not be committed).
.hypothesis/unicode_data/15.0.0/charmap.json.gz Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/085e16c86601bbe6 Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/090841bf7b1e878c Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/176fc1cccfb67bf2 Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/1b5c5c0364f2d5af Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/1c3d3f4a26f40abd Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/483b79bd85591992 Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/51e66ffb8306a103 Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/5ecb8d27c15539fb Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/61575172bb6be502 Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/67b0a8ccf18bf5d2 Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/7e8b22c9c355ef48 Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/92b66ce282ffc534 Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/9adb793441356481 Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/b557a9a709d4c7cf Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/d14c45ee4f738a0e Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/eeb6e6047832521f Adds Hypothesis cache artifact (should not be committed).
.hypothesis/constants/f102fa85cdaff8e2 Adds Hypothesis cache artifact (should not be committed).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

# Some platforms rewrite responses containing "billing" + "credits"/"plans"
# to a billing error message. Replace with safe synonyms.
content = re.sub(r'\bbilling\b', 'invoicing', content, flags=re.IGNORECASE)
content = re.sub(r'\bBilling\b', 'Invoicing', content)
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The second substitution (re.sub(r"\\bBilling\\b", ...)) is effectively dead code because the prior re.sub(r"\\bbilling\\b", ..., flags=re.IGNORECASE) already replaces all case variants, including "Billing". If you need case-preserving replacements, consider using a replacement function; otherwise remove the redundant line.

Suggested change
content = re.sub(r'\bBilling\b', 'Invoicing', content)

Copilot uses AI. Check for mistakes.
Comment on lines +539 to +543
# Only passthrough tool calls for external (caller) tools
# Skip Claude's built-in tool calls (Read, Bash, etc.)
if external_tool_names and tc_name not in external_tool_names:
logger.debug(f"Skipping built-in tool call: {tc_name}")
continue
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

external_tool_names is referenced when filtering tool_use blocks, but it is never defined in this function (or imported). In passthrough mode this will raise a NameError on the first tool_use block. Define external_tool_names (e.g., derived from request.tools) or remove this filter if not needed yet.

Copilot uses AI. Check for mistakes.
Comment on lines 417 to 450
@@ -426,23 +434,39 @@ async def generate_streaming_response(
if claude_options.get("model"):
ParameterValidator.validate_model(claude_options["model"])

# Handle tools - disabled by default for OpenAI compatibility
if not request.enable_tools:
# Disable all tools by using CLAUDE_TOOLS constant
claude_options["disallowed_tools"] = CLAUDE_TOOLS
claude_options["max_turns"] = 1 # Single turn for Q&A
logger.info("Tools disabled (default behavior for OpenAI compatibility)")
else:
# Handle tools based on mode
if passthrough_mode:
# Passthrough mode: caller sent tools (e.g. OpenClaw agent framework).
# Enable Claude's built-in tools so it can fulfill requests using Read, Bash, etc.
# The caller's tool definitions are embedded in the system prompt text by the
# agent framework, so Claude sees them and outputs text-based tool invocations
# that the framework parses on its side.
claude_options["allowed_tools"] = PASSTHROUGH_ALLOWED_TOOLS
claude_options["permission_mode"] = "bypassPermissions"
# Allow many turns so the model can complete complex multi-step tasks
claude_options["max_turns"] = 50
logger.info(
f"Passthrough mode: {len(PASSTHROUGH_ALLOWED_TOOLS)} built-in tools enabled, "
f"{len(request.tools)} caller tools detected (handled via system prompt)"
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passthrough mode is enabled solely by the presence of request.tools (user-controlled input) and then sets permission_mode="bypassPermissions", max_turns=50, and enables a broad tool set including network + Task. Since verify_api_key allows unauthenticated requests when API_KEY is unset, this effectively enables arbitrary tool execution for any caller. Consider requiring an explicit server-side opt-in (env flag) and/or authenticated/authorized clients, and cap max_turns/tool set to a safe subset.

Copilot uses AI. Check for mistakes.
python-multipart = "^0.0.18"
claude-agent-sdk = "^0.1.18"
slowapi = "^0.1.9"
anthropic = "^0.79.0"
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

anthropic is added as a runtime dependency, but it doesn't appear to be imported/used anywhere in src/ right now. If it isn't required for this PR, consider removing it to avoid extra install size and dependency surface area.

Suggested change
anthropic = "^0.79.0"

Copilot uses AI. Check for mistakes.
Comment on lines +417 to +423
# Detect passthrough mode: when caller sends tools (e.g. OpenClaw agent framework)
passthrough_mode = bool(request.tools)

# Filter content for unsupported features (skip in passthrough mode)
if not passthrough_mode:
prompt = MessageAdapter.filter_content(prompt)
if system_prompt:
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New passthrough behavior (request.tools => tool enablement + tool_call streaming) is a significant behavioral change, but there are no tests covering passthrough-mode request parsing and the resulting tool_calls streaming/non-streaming responses. Adding focused unit tests (similar to existing tool execution tests) would help prevent regressions.

Copilot uses AI. Check for mistakes.
Comment on lines +929 to +935
# Inject tool_calls into the serialized response (bypasses Pydantic model)
if tool_calls_list:
resp_json = response.model_dump()
resp_json["choices"][0]["message"]["tool_calls"] = [
tc.model_dump() for tc in tool_calls_list
]
return JSONResponse(content=resp_json)
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This response path manually injects tool_calls into response.model_dump() and returns a JSONResponse, bypassing the Pydantic models/response typing. Since Message already has a tool_calls field, prefer populating it on response_message (or Choice.message) and letting normal serialization handle it; it will be easier to maintain and keeps response types consistent.

Copilot uses AI. Check for mistakes.
else:
self.content = f"{tool_result_prefix}{self.content}" if tool_result_prefix else self.content
# Handle null content (e.g. assistant messages with tool_calls)
if self.content is None:
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Message.normalize_content() forces content=None to become an empty string for all roles. That makes it impossible to produce OpenAI-compatible assistant messages where content is null when tool_calls are present, and it also defeats callers intentionally sending null content. Consider only coercing None to "" when building Claude prompts (adapter layer), or preserving None when tool_calls is set / when emitting responses.

Suggested change
if self.content is None:
if self.content is None:
# Preserve None when tool calls are present for OpenAI compatibility
if self.tool_calls:
return self

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant