Skip to content
Open
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
10 changes: 7 additions & 3 deletions chatmock/routes_ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@
)
from .transform import convert_ollama_messages, normalize_ollama_tools
from .upstream import normalize_model_name, start_upstream_request
from .utils import convert_chat_messages_to_responses_input, convert_tools_chat_to_responses
from .utils import (
convert_chat_messages_to_responses_input,
convert_tool_choice_chat_to_responses,
convert_tools_chat_to_responses,
)


ollama_bp = Blueprint("ollama", __name__)
Expand Down Expand Up @@ -195,7 +199,7 @@ def ollama_chat() -> Response:
stream_req = bool(stream_req)
tools_req = payload.get("tools") if isinstance(payload.get("tools"), list) else []
tools_responses = convert_tools_chat_to_responses(normalize_ollama_tools(tools_req))
tool_choice = payload.get("tool_choice", "auto")
tool_choice = convert_tool_choice_chat_to_responses(payload.get("tool_choice", "auto"))
parallel_tool_calls = bool(payload.get("parallel_tool_calls", False))

# Passthrough Responses API tools (web_search) via ChatMock extension fields
Expand Down Expand Up @@ -297,7 +301,7 @@ def ollama_chat() -> Response:
if verbose:
print("[Passthrough] Upstream rejected tools; retrying without extras (args redacted)")
base_tools_only = convert_tools_chat_to_responses(normalize_ollama_tools(tools_req))
safe_choice = payload.get("tool_choice", "auto")
safe_choice = convert_tool_choice_chat_to_responses(payload.get("tool_choice", "auto"))
upstream2, err2 = start_upstream_request(
normalize_model_name(model, current_app.config.get("DEBUG_MODEL")),
input_items,
Expand Down
5 changes: 3 additions & 2 deletions chatmock/routes_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from .upstream import normalize_model_name, start_upstream_raw_request, start_upstream_request
from .utils import (
convert_chat_messages_to_responses_input,
convert_tool_choice_chat_to_responses,
convert_tools_chat_to_responses,
sse_translate_chat,
sse_translate_text,
Expand Down Expand Up @@ -147,7 +148,7 @@ def chat_completions() -> Response:
include_usage = bool(stream_options.get("include_usage", False))

tools_responses = convert_tools_chat_to_responses(payload.get("tools"))
tool_choice = payload.get("tool_choice", "auto")
tool_choice = convert_tool_choice_chat_to_responses(payload.get("tool_choice", "auto"))
parallel_tool_calls = bool(payload.get("parallel_tool_calls", False))
responses_tools_payload = payload.get("responses_tools") if isinstance(payload.get("responses_tools"), list) else []
extra_tools: List[Dict[str, Any]] = []
Expand Down Expand Up @@ -246,7 +247,7 @@ def chat_completions() -> Response:
if verbose:
print("[Passthrough] Upstream rejected tools; retrying without extra tools (args redacted)")
base_tools_only = convert_tools_chat_to_responses(payload.get("tools"))
safe_choice = payload.get("tool_choice", "auto")
safe_choice = convert_tool_choice_chat_to_responses(payload.get("tool_choice", "auto"))
upstream2, err2 = start_upstream_request(
model,
input_items,
Expand Down
2 changes: 1 addition & 1 deletion chatmock/upstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def start_upstream_request(
"model": model,
"input": input_items,
"tools": tools or [],
"tool_choice": tool_choice if tool_choice in ("auto", "none") or isinstance(tool_choice, dict) else "auto",
"tool_choice": tool_choice if tool_choice in ("auto", "none", "required") or isinstance(tool_choice, dict) else "auto",
"parallel_tool_calls": bool(parallel_tool_calls),
"store": False,
"stream": True,
Expand Down
22 changes: 22 additions & 0 deletions chatmock/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,28 @@ def _normalize_image_data_url(url: str) -> str:
return input_items


def convert_tool_choice_chat_to_responses(tool_choice: Any) -> Any:
"""Translate a Chat Completions ``tool_choice`` into the Responses API shape.

Chat Completions forces a function with ``{"type": "function", "function": {"name": ...}}``
while the Responses API expects ``{"type": "function", "name": ...}``. String modes
(``auto``/``none``/``required``) pass through unchanged and anything unrecognised
falls back to ``auto``.
"""
if isinstance(tool_choice, str):
mode = tool_choice.strip().lower()
return mode if mode in ("auto", "none", "required") else "auto"
if isinstance(tool_choice, dict):
if tool_choice.get("type") == "function":
fn = tool_choice.get("function")
name = fn.get("name") if isinstance(fn, dict) else tool_choice.get("name")
if isinstance(name, str) and name.strip():
return {"type": "function", "name": name.strip()}
return "auto"
return tool_choice
return "auto"


def convert_tools_chat_to_responses(tools: Any) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
if not isinstance(tools, list):
Expand Down
52 changes: 52 additions & 0 deletions tests/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,58 @@ def test_chat_completions(self, mock_start) -> None:
self.assertEqual(body["choices"][0]["message"]["content"], "hello")
self.assertEqual(body["model"], "gpt5.4-mini")

@patch("chatmock.routes_openai.start_upstream_request")
def test_chat_completions_translates_forced_tool_choice(self, mock_start) -> None:
mock_start.return_value = (
FakeUpstream([{"type": "response.completed", "response": {"id": "resp-openai"}}]),
None,
)
tools = [{"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}}]
response = self.client.post(
"/v1/chat/completions",
json={
"model": "gpt-5.5",
"messages": [{"role": "user", "content": "hi"}],
"tools": tools,
"tool_choice": {"type": "function", "function": {"name": "lookup"}},
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(mock_start.call_args.kwargs["tool_choice"], {"type": "function", "name": "lookup"})

@patch("chatmock.routes_openai.start_upstream_request")
def test_chat_completions_passes_required_tool_choice(self, mock_start) -> None:
mock_start.return_value = (
FakeUpstream([{"type": "response.completed", "response": {"id": "resp-openai"}}]),
None,
)
response = self.client.post(
"/v1/chat/completions",
json={"model": "gpt-5.5", "messages": [{"role": "user", "content": "hi"}], "tool_choice": "required"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(mock_start.call_args.kwargs["tool_choice"], "required")

@patch("chatmock.routes_ollama.start_upstream_request")
def test_ollama_chat_translates_forced_tool_choice(self, mock_start) -> None:
mock_start.return_value = (
FakeUpstream([{"type": "response.completed", "response": {"id": "resp-ollama"}}]),
None,
)
tools = [{"type": "function", "function": {"name": "lookup", "parameters": {"type": "object"}}}]
response = self.client.post(
"/api/chat",
json={
"model": "gpt-5.5",
"messages": [{"role": "user", "content": "hi"}],
"tools": tools,
"tool_choice": {"type": "function", "function": {"name": "lookup"}},
"stream": False,
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(mock_start.call_args.kwargs["tool_choice"], {"type": "function", "name": "lookup"})

@patch("chatmock.routes_openai.start_upstream_request")
def test_chat_completions_honors_debug_model_override(self, mock_start) -> None:
app = create_app(debug_model="gpt-5.4", model_sync=False)
Expand Down