You need to sign in or sign up before continuing.
Commit cad4524f authored by Kantz's avatar Kantz
Browse files

OpenAI reasoning intigiert

parent fa3e49fd
...@@ -6,15 +6,13 @@ from typing import Any, Callable ...@@ -6,15 +6,13 @@ from typing import Any, Callable
import ollama import ollama
from mistralai.client import Mistral from mistralai.client import Mistral
from openai import OpenAI from openai import BadRequestError, OpenAI
from app import config from app import config
from app.deterministic_services import llm_quota from app.deterministic_services import llm_quota
from app.deterministic_services.llm_tool_client import ( from app.deterministic_services.llm_tool_client import (
append_tool_trace_log, append_tool_trace_log,
resolve_mcp_base_url,
run_tool_chat_sync, run_tool_chat_sync,
with_mcp_session,
) )
...@@ -71,6 +69,110 @@ def _get_message_field(message: object, field: str) -> str: ...@@ -71,6 +69,110 @@ def _get_message_field(message: object, field: str) -> str:
return getattr(message, field) or "" return getattr(message, field) or ""
return "" return ""
# ---
# OpenAI response extraction helpers
# ---
def _field(value: object, name: str, default=None):
if isinstance(value, dict):
return value.get(name, default)
return getattr(value, name, default)
def _extract_block_text(blocks: list[object]) -> str:
return "".join(str(text) for block in blocks or [] if (text := _field(block, "text")))
def _extract_response_thinking(output: list[object]) -> str:
parts: list[str] = []
for item in output:
if _field(item, "type") != "reasoning":
continue
parts.append(_extract_block_text(_field(item, "summary", []) or []))
parts.append(_extract_block_text(_field(item, "content", []) or []))
return "\n".join(part for part in parts if part)
def _extract_response_openai(response) -> object:
output = _field(response, "output", []) or []
thinking = _extract_response_thinking(output)
if isinstance(response, dict):
output_text = response.get("output_text")
if output_text is not None:
message = {"role": "assistant", "content": output_text}
if thinking:
message["thinking"] = thinking
return message
else:
output_text = getattr(response, "output_text", None)
if output_text is not None:
message = {"role": "assistant", "content": output_text}
if thinking:
message["thinking"] = thinking
return message
parts: list[str] = []
for item in output:
content = item.get("content", []) if isinstance(item, dict) else getattr(item, "content", []) or []
for block in content or []:
block_type = block.get("type") if isinstance(block, dict) else getattr(block, "type", "")
text = block.get("text") if isinstance(block, dict) else getattr(block, "text", None)
if block_type in {"output_text", "text"} and text:
parts.append(str(text))
message = {"role": "assistant", "content": "".join(parts)}
if thinking:
message["thinking"] = thinking
return message
def _openai_reasoning() -> dict[str, str]:
if config.get_llm_thinking_enabled():
return {"effort": "high", "summary": "detailed"}
return {"effort": "none"}
def _message_content_text(content: object) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
text = _field(item, "text")
if text:
parts.append(str(text))
return "\n".join(parts)
return str(content or "")
def _messages_to_responses_input(messages: list[dict]) -> tuple[str | None, str]:
instructions = "\n\n".join(
_message_content_text(message.get("content"))
for message in messages
if message.get("role") == "system"
).strip() or None
turns = []
for message in messages:
role = message.get("role")
if role == "system":
continue
content = _message_content_text(message.get("content")).strip()
if content:
turns.append(f"{role or 'user'}: {content}")
return instructions, "\n\n".join(turns)
def _is_unsupported_temperature_error(exc: Exception) -> bool:
text = str(exc)
return (
isinstance(exc, BadRequestError)
and (getattr(exc, "param", None) == "temperature" or "temperature" in text)
and "Unsupported parameter" in text
)
# --- # ---
# Quota # Quota
...@@ -152,11 +254,21 @@ def _chat_openai_compatible( ...@@ -152,11 +254,21 @@ def _chat_openai_compatible(
settings = settings or _require_openai_chat_settings() settings = settings or _require_openai_chat_settings()
timeout = settings.timeout or 60.0 timeout = settings.timeout or 60.0
client = OpenAI(api_key=settings.api_key, base_url=settings.base_url, timeout=timeout) client = OpenAI(api_key=settings.api_key, base_url=settings.base_url, timeout=timeout)
kwargs = {"messages": messages, "model": settings.model} instructions, response_input = _messages_to_responses_input(messages)
kwargs = {"input": response_input, "model": settings.model}
if instructions:
kwargs["instructions"] = instructions
if settings.temperature is not None: if settings.temperature is not None:
kwargs["temperature"] = settings.temperature kwargs["temperature"] = settings.temperature
response = client.chat.completions.create(**kwargs) kwargs["reasoning"] = _openai_reasoning()
message = response.choices[0].message if response.choices else {} try:
response = client.responses.create(**kwargs)
except BadRequestError as exc:
if "temperature" not in kwargs or not _is_unsupported_temperature_error(exc):
raise
kwargs.pop("temperature")
response = client.responses.create(**kwargs)
message = _extract_response_openai(response)
return {"raw": response, "message": message} return {"raw": response, "message": message}
......
...@@ -6,7 +6,7 @@ import httpx ...@@ -6,7 +6,7 @@ import httpx
import ollama import ollama
from mcp.client.session import ClientSession from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamable_http_client from mcp.client.streamable_http import streamable_http_client
from openai import AsyncOpenAI from openai import AsyncOpenAI, BadRequestError
from app import config from app import config
from app.deterministic_services import tool_log_context from app.deterministic_services import tool_log_context
...@@ -14,6 +14,15 @@ from app.deterministic_services import tool_log_context ...@@ -14,6 +14,15 @@ from app.deterministic_services import tool_log_context
AUTH_HEADER = "X-MCP-Shared-Secret" AUTH_HEADER = "X-MCP-Shared-Secret"
def _is_unsupported_temperature_error(exc: Exception) -> bool:
text = str(exc)
return (
isinstance(exc, BadRequestError)
and (getattr(exc, "param", None) == "temperature" or "temperature" in text)
and "Unsupported parameter" in text
)
def _extract_message(response) -> object: def _extract_message(response) -> object:
if isinstance(response, dict) and "message" in response: if isinstance(response, dict) and "message" in response:
return response["message"] return response["message"]
...@@ -30,6 +39,38 @@ def _extract_tool_calls(message: object) -> list[Any]: ...@@ -30,6 +39,38 @@ def _extract_tool_calls(message: object) -> list[Any]:
return [] return []
def _extract_response_tool_calls(response: object) -> list[Any]:
output = response.get("output", []) if isinstance(response, dict) else getattr(response, "output", []) or []
return [
item for item in output
if (item.get("type") if isinstance(item, dict) else getattr(item, "type", "")) == "function_call"
]
def _extract_response_output(response: object) -> list[Any]:
return response.get("output", []) if isinstance(response, dict) else getattr(response, "output", []) or []
def _field(value: object, name: str, default=None):
if isinstance(value, dict):
return value.get(name, default)
return getattr(value, name, default)
def _extract_block_text(blocks: list[object]) -> str:
return "".join(str(text) for block in blocks or [] if (text := _field(block, "text")))
def _extract_response_thinking(response: object) -> str:
parts: list[str] = []
for item in _extract_response_output(response):
if _field(item, "type") != "reasoning":
continue
parts.append(_extract_block_text(_field(item, "summary", []) or []))
parts.append(_extract_block_text(_field(item, "content", []) or []))
return "\n".join(part for part in parts if part)
def _normalize_message(message: object) -> dict[str, Any]: def _normalize_message(message: object) -> dict[str, Any]:
if isinstance(message, dict): if isinstance(message, dict):
return dict(message) return dict(message)
...@@ -44,6 +85,10 @@ def _normalize_message(message: object) -> dict[str, Any]: ...@@ -44,6 +85,10 @@ def _normalize_message(message: object) -> dict[str, Any]:
def _extract_tool_call_name(tool_call: object) -> str: def _extract_tool_call_name(tool_call: object) -> str:
if hasattr(tool_call, "name"):
return str(getattr(tool_call, "name") or "")
if isinstance(tool_call, dict) and "name" in tool_call:
return str(tool_call.get("name") or "")
function = getattr(tool_call, "function", None) function = getattr(tool_call, "function", None)
if function is not None and hasattr(function, "name"): if function is not None and hasattr(function, "name"):
return getattr(function, "name") return getattr(function, "name")
...@@ -54,11 +99,16 @@ def _extract_tool_call_name(tool_call: object) -> str: ...@@ -54,11 +99,16 @@ def _extract_tool_call_name(tool_call: object) -> str:
def _extract_tool_call_arguments(tool_call: object) -> dict[str, Any]: def _extract_tool_call_arguments(tool_call: object) -> dict[str, Any]:
function = getattr(tool_call, "function", None)
raw_arguments: Any = None raw_arguments: Any = None
if function is not None and hasattr(function, "arguments"): if hasattr(tool_call, "arguments"):
raw_arguments = getattr(tool_call, "arguments")
elif isinstance(tool_call, dict) and "arguments" in tool_call:
raw_arguments = tool_call.get("arguments")
function = getattr(tool_call, "function", None)
if raw_arguments is None and function is not None and hasattr(function, "arguments"):
raw_arguments = getattr(function, "arguments") raw_arguments = getattr(function, "arguments")
elif isinstance(tool_call, dict): elif raw_arguments is None and isinstance(tool_call, dict):
function_dict = tool_call.get("function") or {} function_dict = tool_call.get("function") or {}
raw_arguments = function_dict.get("arguments") raw_arguments = function_dict.get("arguments")
...@@ -70,6 +120,10 @@ def _extract_tool_call_arguments(tool_call: object) -> dict[str, Any]: ...@@ -70,6 +120,10 @@ def _extract_tool_call_arguments(tool_call: object) -> dict[str, Any]:
def _extract_tool_call_id(tool_call: object) -> str: def _extract_tool_call_id(tool_call: object) -> str:
if hasattr(tool_call, "call_id"):
return str(getattr(tool_call, "call_id") or "")
if isinstance(tool_call, dict) and "call_id" in tool_call:
return str(tool_call.get("call_id") or "")
if hasattr(tool_call, "id"): if hasattr(tool_call, "id"):
return str(getattr(tool_call, "id") or "") return str(getattr(tool_call, "id") or "")
if isinstance(tool_call, dict): if isinstance(tool_call, dict):
...@@ -92,27 +146,46 @@ def _extract_structured_tool_output(tool_result: Any) -> Any: ...@@ -92,27 +146,46 @@ def _extract_structured_tool_output(tool_result: Any) -> Any:
def append_tool_trace_log(result: dict, provider: str) -> None: def append_tool_trace_log(result: dict, provider: str) -> None:
tool_trace = result.get("tool_trace") tool_trace = result.get("tool_trace") or []
if not tool_trace: raw = result.get("raw")
output = _extract_response_output(raw) if provider in {"openai", "gwdg"} else []
reasoning = _extract_response_thinking(raw) if output else ""
if not tool_trace and not output:
return return
tool_log = tool_log_context.get_active_tool_log() tool_log = tool_log_context.get_active_tool_log()
if tool_log is None: if tool_log is None:
return return
tool_log.append( if output:
{ message = result.get("message")
"name": "llm_tool_trace", tool_log.append(
"arguments": { {
"provider": provider, "name": "llm_response_metadata",
"tool_call_count": len(tool_trace), "arguments": {"provider": provider},
}, "response": {
"response": { "output_item_types": [_field(item, "type", "") for item in output],
"tool_trace": tool_trace, "reasoning_summary_chars": len(reasoning),
"response_ids": result.get("response_ids", []), "has_message_thinking": isinstance(message, dict) and bool(message.get("thinking")),
}, },
} }
) )
if tool_trace:
tool_log.append(
{
"name": "llm_tool_trace",
"arguments": {
"provider": provider,
"tool_call_count": len(tool_trace),
},
"response": {
"tool_trace": tool_trace,
"response_ids": result.get("response_ids", []),
},
}
)
def resolve_mcp_base_url(mcp_settings: dict[str, Any]) -> str: def resolve_mcp_base_url(mcp_settings: dict[str, Any]) -> str:
...@@ -171,18 +244,49 @@ def _tool_to_chat_tool(tool: Any) -> dict[str, Any]: ...@@ -171,18 +244,49 @@ def _tool_to_chat_tool(tool: Any) -> dict[str, Any]:
} }
def _append_openai_tool_message( def _tool_to_response_tool(tool: Any) -> dict[str, Any]:
messages: list[dict[str, Any]], return {
"type": "function",
"name": _tool_attr(tool, "name", "name"),
"description": _tool_attr(tool, "description", "description") or "",
"parameters": _tool_attr(tool, "input_schema", "inputSchema"),
"strict": False,
}
def _extract_response_message(response: object) -> dict[str, str]:
output_text = response.get("output_text") if isinstance(response, dict) else getattr(response, "output_text", None)
message = {"role": "assistant", "content": output_text or ""}
thinking = _extract_response_thinking(response)
if thinking:
message["thinking"] = thinking
return message
def _openai_reasoning() -> dict[str, str]:
if config.get_llm_thinking_enabled():
return {"effort": "high", "summary": "detailed"}
return {"effort": "none"}
def _response_input_item(item: Any) -> Any:
if isinstance(item, dict):
return item
if hasattr(item, "model_dump"):
return item.model_dump(exclude_none=True)
return item
def _append_response_tool_output(
items: list[dict[str, Any]],
tool_call_id: str, tool_call_id: str,
tool_name: str,
structured_output: Any, structured_output: Any,
) -> None: ) -> None:
messages.append( items.append(
{ {
"role": "tool", "type": "function_call_output",
"tool_call_id": tool_call_id, "call_id": tool_call_id,
"name": tool_name, "output": json.dumps(structured_output),
"content": json.dumps(structured_output),
} }
) )
...@@ -217,36 +321,42 @@ async def _run_openai_compatible_tool_loop( ...@@ -217,36 +321,42 @@ async def _run_openai_compatible_tool_loop(
client = openai_client or AsyncOpenAI(**client_kwargs) client = openai_client or AsyncOpenAI(**client_kwargs)
async def _handler(session, tools) -> dict: async def _handler(session, tools) -> dict:
chat_tools = [_tool_to_chat_tool(tool) for tool in tools] response_tools = [_tool_to_response_tool(tool) for tool in tools]
request_messages = [dict(message) for message in messages] request_input = [dict(message) for message in messages]
response_ids: list[str] = [] response_ids: list[str] = []
tool_trace: list[dict[str, Any]] = [] tool_trace: list[dict[str, Any]] = []
while True: while True:
request_kwargs: dict[str, Any] = { request_kwargs: dict[str, Any] = {
"model": settings.model, "model": settings.model,
"messages": request_messages, "input": request_input,
"tools": chat_tools, "tools": response_tools,
} }
if settings.temperature is not None: if settings.temperature is not None:
request_kwargs["temperature"] = settings.temperature request_kwargs["temperature"] = settings.temperature
request_kwargs["reasoning"] = _openai_reasoning()
response = await client.chat.completions.create(**request_kwargs)
response_id = getattr(response, "id", "") try:
response = await client.responses.create(**request_kwargs)
except BadRequestError as exc:
if "temperature" not in request_kwargs or not _is_unsupported_temperature_error(exc):
raise
request_kwargs.pop("temperature")
response = await client.responses.create(**request_kwargs)
response_id = response.get("id", "") if isinstance(response, dict) else getattr(response, "id", "")
if response_id: if response_id:
response_ids.append(response_id) response_ids.append(response_id)
message = response.choices[0].message if response.choices else {} tool_calls = _extract_response_tool_calls(response)
tool_calls = _extract_tool_calls(message)
if not tool_calls: if not tool_calls:
return { return {
"raw": response, "raw": response,
"message": message, "message": _extract_response_message(response),
"tool_trace": tool_trace, "tool_trace": tool_trace,
"response_ids": response_ids, "response_ids": response_ids,
} }
request_messages.append(_normalize_message(message)) request_input.extend(_response_input_item(item) for item in _extract_response_output(response))
for tool_call in tool_calls: for tool_call in tool_calls:
tool_name = _extract_tool_call_name(tool_call) tool_name = _extract_tool_call_name(tool_call)
arguments = _extract_tool_call_arguments(tool_call) arguments = _extract_tool_call_arguments(tool_call)
...@@ -259,10 +369,9 @@ async def _run_openai_compatible_tool_loop( ...@@ -259,10 +369,9 @@ async def _run_openai_compatible_tool_loop(
"output": structured_output, "output": structured_output,
} }
) )
_append_openai_tool_message( _append_response_tool_output(
request_messages, request_input,
_extract_tool_call_id(tool_call), _extract_tool_call_id(tool_call),
tool_name,
structured_output, structured_output,
) )
......
...@@ -25,6 +25,7 @@ ollama_stub.AsyncClient = object ...@@ -25,6 +25,7 @@ ollama_stub.AsyncClient = object
openai_stub = types.ModuleType("openai") openai_stub = types.ModuleType("openai")
openai_stub.OpenAI = object openai_stub.OpenAI = object
openai_stub.AsyncOpenAI = object openai_stub.AsyncOpenAI = object
openai_stub.BadRequestError = RuntimeError
mistralai_stub = types.ModuleType("mistralai") mistralai_stub = types.ModuleType("mistralai")
mistralai_client_stub = types.ModuleType("mistralai.client") mistralai_client_stub = types.ModuleType("mistralai.client")
...@@ -90,50 +91,84 @@ from app.deterministic_services.orchestrators.orchestrator_base import ChatState ...@@ -90,50 +91,84 @@ from app.deterministic_services.orchestrators.orchestrator_base import ChatState
MESSAGES = [{"role": "user", "content": "Hallo"}] MESSAGES = [{"role": "user", "content": "Hallo"}]
def _make_openai_response(message, response_id: str = "resp-1", total_tokens: int = 7): def _make_responses_response(
output_text: str = "",
*,
output: list[object] | None = None,
response_id: str = "resp-1",
total_tokens: int = 7,
):
usage = types.SimpleNamespace(total_tokens=total_tokens) usage = types.SimpleNamespace(total_tokens=total_tokens)
choice = types.SimpleNamespace(message=message) return types.SimpleNamespace(
return types.SimpleNamespace(id=response_id, choices=[choice], usage=usage) id=response_id,
output_text=output_text,
output=output or [],
usage=usage,
)
def _make_openai_tool_call( def _make_response_tool_call(
name: str, name: str,
arguments: str | dict, arguments: str | dict,
tool_call_id: str = "call-1", tool_call_id: str = "call-1",
): ):
function = types.SimpleNamespace(name=name, arguments=arguments) return types.SimpleNamespace(
return types.SimpleNamespace(id=tool_call_id, function=function) type="function_call",
call_id=tool_call_id,
name=name,
arguments=arguments,
)
def _make_reasoning_item(text: str):
return types.SimpleNamespace(
type="reasoning",
summary=[types.SimpleNamespace(type="summary_text", text=text)],
)
class _FakeOpenAICompletions:
class _FakeOpenAIResponses:
def __init__(self, responses: list[object]) -> None: def __init__(self, responses: list[object]) -> None:
self._responses = list(responses) self._responses = list(responses)
self.calls: list[dict] = [] self.calls: list[dict] = []
async def create(self, **kwargs): async def create(self, **kwargs):
self.calls.append(kwargs) self.calls.append(kwargs)
return self._responses.pop(0) response = self._responses.pop(0)
if isinstance(response, Exception):
raise response
return response
class _FakeOpenAIClient: class _FakeOpenAIClient:
def __init__(self, responses: list[object]) -> None: def __init__(self, responses: list[object]) -> None:
completions = _FakeOpenAICompletions(responses) self.responses = _FakeOpenAIResponses(responses)
self.chat = types.SimpleNamespace(completions=completions)
class _FakeSyncOpenAIClient: class _FakeSyncOpenAIClient:
def __init__(self, response: object) -> None: def __init__(self, response: object) -> None:
self.calls: list[dict] = [] self.calls: list[dict] = []
self.chat = types.SimpleNamespace( self.responses = types.SimpleNamespace(create=self._create)
completions=types.SimpleNamespace(create=self._create)
)
self._response = response self._response = response
def _create(self, **kwargs): def _create(self, **kwargs):
self.calls.append(kwargs) self.calls.append(kwargs)
if isinstance(self._response, list):
response = self._response.pop(0)
if isinstance(response, Exception):
raise response
return response
if isinstance(self._response, Exception):
raise self._response
return self._response return self._response
class _FakeBadRequestError(RuntimeError):
def __init__(self, message: str, param: str | None = None) -> None:
super().__init__(message)
self.param = param
class _FakeMistralClient: class _FakeMistralClient:
calls: list[dict] = [] calls: list[dict] = []
...@@ -489,14 +524,83 @@ class LLMClientProviderTest(unittest.TestCase): ...@@ -489,14 +524,83 @@ class LLMClientProviderTest(unittest.TestCase):
timeout=30.0, timeout=30.0,
temperature=None, temperature=None,
) )
fake_client = _FakeSyncOpenAIClient(_make_openai_response({"content": "done"})) fake_client = _FakeSyncOpenAIClient(_make_responses_response("done"))
with patch.dict(os.environ, {"LLM_THINKING_ENABLED": "true"}), patch.object( with patch.dict(os.environ, {"LLM_THINKING_ENABLED": "true"}), patch.object(
llm_client, "OpenAI", return_value=fake_client llm_client, "OpenAI", return_value=fake_client
): ):
llm_client._chat_openai_compatible(MESSAGES, settings) llm_client._chat_openai_compatible(MESSAGES, settings)
self.assertEqual(fake_client.calls[0]["reasoning_effort"], "medium") self.assertEqual(fake_client.calls[0]["reasoning"], {"effort": "high", "summary": "detailed"})
def test_openai_compatible_chat_sends_system_as_instructions(self) -> None:
"""Checks that plain Responses input uses text so reasoning summaries are returned."""
settings = config.OpenAIChatSettings(
base_url="https://example.com/v1",
api_key="key",
model="model",
timeout=30.0,
temperature=None,
)
fake_client = _FakeSyncOpenAIClient(_make_responses_response("done"))
with patch.object(llm_client, "OpenAI", return_value=fake_client):
llm_client._chat_openai_compatible(
[
{"role": "system", "content": "Be precise."},
{"role": "user", "content": "Hallo"},
{"role": "assistant", "content": "Hi"},
],
settings,
)
self.assertEqual(fake_client.calls[0]["instructions"], "Be precise.")
self.assertEqual(fake_client.calls[0]["input"], "user: Hallo\n\nassistant: Hi")
def test_openai_compatible_chat_formats_reasoning_as_thinking(self) -> None:
"""Checks that Responses reasoning summaries use the existing thinking format."""
settings = config.OpenAIChatSettings(
base_url="https://example.com/v1",
api_key="key",
model="model",
timeout=30.0,
temperature=None,
)
fake_client = _FakeSyncOpenAIClient(
_make_responses_response("answer", output=[_make_reasoning_item("work")])
)
with patch.dict(os.environ, {"LLM_THINKING_ENABLED": "true"}), patch.object(
llm_client, "OpenAI", return_value=fake_client
):
result = llm_client._chat_openai_compatible(MESSAGES, settings)
self.assertEqual(llm_client.get_message_content(result), "work</think>\n\nanswer")
def test_openai_compatible_chat_retries_without_unsupported_temperature(self) -> None:
"""Checks that Responses models which reject temperature still work."""
settings = config.OpenAIChatSettings(
base_url="https://example.com/v1",
api_key="key",
model="model",
timeout=30.0,
temperature=0.2,
)
fake_client = _FakeSyncOpenAIClient(
[
_FakeBadRequestError("Unsupported parameter: 'temperature'", "temperature"),
_make_responses_response("done"),
]
)
with patch.object(llm_client, "BadRequestError", _FakeBadRequestError), patch.object(
llm_client, "OpenAI", return_value=fake_client
):
result = llm_client._chat_openai_compatible(MESSAGES, settings)
self.assertEqual(result["message"]["content"], "done")
self.assertEqual(fake_client.calls[0]["temperature"], 0.2)
self.assertNotIn("temperature", fake_client.calls[1])
def test_mistral_chat_sets_reasoning_effort_from_env(self) -> None: def test_mistral_chat_sets_reasoning_effort_from_env(self) -> None:
"""Checks that Mistral chat maps thinking mode to reasoning effort.""" """Checks that Mistral chat maps thinking mode to reasoning effort."""
...@@ -663,8 +767,7 @@ class LLMClientToolHelpersTest(unittest.TestCase): ...@@ -663,8 +767,7 @@ class LLMClientToolHelpersTest(unittest.TestCase):
timeout=30.0, timeout=30.0,
temperature=0.2, temperature=0.2,
) )
final_message = types.SimpleNamespace(role="assistant", content="done", tool_calls=None) fake_client = _FakeOpenAIClient([_make_responses_response("done")])
fake_client = _FakeOpenAIClient([_make_openai_response(final_message)])
async def run_test(): async def run_test():
async def fake_with_mcp_session(mcp_settings, handler, http_client=None): async def fake_with_mcp_session(mcp_settings, handler, http_client=None):
...@@ -677,7 +780,7 @@ class LLMClientToolHelpersTest(unittest.TestCase): ...@@ -677,7 +780,7 @@ class LLMClientToolHelpersTest(unittest.TestCase):
openai_client=fake_client, openai_client=fake_client,
) )
self.assertEqual(result["message"].content, "done") self.assertEqual(result["message"]["content"], "done")
self.assertEqual(result["response_ids"], ["resp-1"]) self.assertEqual(result["response_ids"], ["resp-1"])
self.assertEqual(result["tool_trace"], []) self.assertEqual(result["tool_trace"], [])
...@@ -693,17 +796,11 @@ class LLMClientToolHelpersTest(unittest.TestCase): ...@@ -693,17 +796,11 @@ class LLMClientToolHelpersTest(unittest.TestCase):
timeout=30.0, timeout=30.0,
temperature=0.2, temperature=0.2,
) )
tool_call = _make_openai_tool_call("solve", "{\"x\": 2}") tool_call = _make_response_tool_call("solve", "{\"x\": 2}")
tool_message = types.SimpleNamespace(
role="assistant",
content="",
tool_calls=[tool_call],
)
final_message = types.SimpleNamespace(role="assistant", content="x=2", tool_calls=None)
fake_client = _FakeOpenAIClient( fake_client = _FakeOpenAIClient(
[ [
_make_openai_response(tool_message, response_id="resp-1"), _make_responses_response(output=[tool_call], response_id="resp-1"),
_make_openai_response(final_message, response_id="resp-2"), _make_responses_response("x=2", response_id="resp-2"),
] ]
) )
fake_tool = types.SimpleNamespace( fake_tool = types.SimpleNamespace(
...@@ -724,16 +821,81 @@ class LLMClientToolHelpersTest(unittest.TestCase): ...@@ -724,16 +821,81 @@ class LLMClientToolHelpersTest(unittest.TestCase):
openai_client=fake_client, openai_client=fake_client,
) )
self.assertEqual(result["message"].content, "x=2") self.assertEqual(result["message"]["content"], "x=2")
self.assertEqual(result["response_ids"], ["resp-1", "resp-2"]) self.assertEqual(result["response_ids"], ["resp-1", "resp-2"])
self.assertEqual( self.assertEqual(
result["tool_trace"], result["tool_trace"],
[{"name": "solve", "arguments": {"x": 2}, "output": {"result": 2}}], [{"name": "solve", "arguments": {"x": 2}, "output": {"result": 2}}],
) )
self.assertEqual(session.calls, [("solve", {"x": 2})]) self.assertEqual(session.calls, [("solve", {"x": 2})])
second_call_messages = fake_client.chat.completions.calls[1]["messages"] second_call_input = fake_client.responses.calls[1]["input"]
self.assertEqual(second_call_messages[-1]["role"], "tool") self.assertEqual(second_call_input[-1]["type"], "function_call_output")
self.assertEqual(second_call_messages[-1]["tool_call_id"], "call-1") self.assertEqual(second_call_input[-1]["call_id"], "call-1")
asyncio_result = __import__("asyncio").run(run_test())
self.assertIsNone(asyncio_result)
def test_openai_tool_loop_formats_reasoning_as_thinking(self) -> None:
"""Checks that tool-mode Responses reasoning summaries are preserved."""
settings = config.OpenAIChatSettings(
base_url="https://example.com/v1",
api_key="key",
model="model",
timeout=30.0,
temperature=0.2,
)
fake_client = _FakeOpenAIClient(
[_make_responses_response("done", output=[_make_reasoning_item("checked")])]
)
async def run_test():
async def fake_with_mcp_session(mcp_settings, handler, http_client=None):
return await handler(_FakeSession([]), [])
with patch.object(llm_tool_client, "with_mcp_session", side_effect=fake_with_mcp_session):
result = await llm_tool_client._run_openai_compatible_tool_loop(
MESSAGES,
settings,
openai_client=fake_client,
)
self.assertEqual(llm_client.get_message_content(result), "checked</think>\n\ndone")
asyncio_result = __import__("asyncio").run(run_test())
self.assertIsNone(asyncio_result)
def test_openai_tool_loop_retries_without_unsupported_temperature(self) -> None:
"""Checks that the tool path also tolerates models rejecting temperature."""
settings = config.OpenAIChatSettings(
base_url="https://example.com/v1",
api_key="key",
model="model",
timeout=30.0,
temperature=0.2,
)
fake_client = _FakeOpenAIClient(
[
_FakeBadRequestError("Unsupported parameter: 'temperature'", "temperature"),
_make_responses_response("done"),
]
)
async def run_test():
async def fake_with_mcp_session(mcp_settings, handler, http_client=None):
return await handler(_FakeSession([]), [])
with patch.object(llm_tool_client, "BadRequestError", _FakeBadRequestError), patch.object(
llm_tool_client, "with_mcp_session", side_effect=fake_with_mcp_session
):
result = await llm_tool_client._run_openai_compatible_tool_loop(
MESSAGES,
settings,
openai_client=fake_client,
)
self.assertEqual(result["message"]["content"], "done")
self.assertEqual(fake_client.responses.calls[0]["temperature"], 0.2)
self.assertNotIn("temperature", fake_client.responses.calls[1])
asyncio_result = __import__("asyncio").run(run_test()) asyncio_result = __import__("asyncio").run(run_test())
self.assertIsNone(asyncio_result) self.assertIsNone(asyncio_result)
...@@ -748,15 +910,13 @@ class LLMClientToolHelpersTest(unittest.TestCase): ...@@ -748,15 +910,13 @@ class LLMClientToolHelpersTest(unittest.TestCase):
temperature=0.2, temperature=0.2,
) )
tool_calls = [ tool_calls = [
_make_openai_tool_call("first", {"a": 1}, tool_call_id="call-1"), _make_response_tool_call("first", {"a": 1}, tool_call_id="call-1"),
_make_openai_tool_call("second", "{\"b\": 2}", tool_call_id="call-2"), _make_response_tool_call("second", "{\"b\": 2}", tool_call_id="call-2"),
] ]
tool_message = types.SimpleNamespace(role="assistant", content="", tool_calls=tool_calls)
final_message = types.SimpleNamespace(role="assistant", content="done", tool_calls=None)
fake_client = _FakeOpenAIClient( fake_client = _FakeOpenAIClient(
[ [
_make_openai_response(tool_message, response_id="resp-1"), _make_responses_response(output=tool_calls, response_id="resp-1"),
_make_openai_response(final_message, response_id="resp-2"), _make_responses_response("done", response_id="resp-2"),
] ]
) )
fake_tools = [ fake_tools = [
...@@ -781,7 +941,7 @@ class LLMClientToolHelpersTest(unittest.TestCase): ...@@ -781,7 +941,7 @@ class LLMClientToolHelpersTest(unittest.TestCase):
openai_client=fake_client, openai_client=fake_client,
) )
self.assertEqual(result["message"].content, "done") self.assertEqual(result["message"]["content"], "done")
self.assertEqual( self.assertEqual(
result["tool_trace"], result["tool_trace"],
[ [
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment