feat: add OpenAI Responses API support via per-model api_flavor

Route opted-in models to /v1/responses while keeping Chat Completions as
the default. The flavor is selected per-model in the catalog YAML and
threaded through ModelCapabilities (with a new reasoning_effort field).

- Translate chat-shaped messages/tools/structured-output into the
  Responses input / text.format / flattened-tool shapes at the provider
  boundary; stored history and intra-turn tool round-trips need no
  migration.
- Normalize the typed SSE event stream back into the existing handler
  contract (content / thought / tool-call chunks) so the streaming
  accumulator and OpenAI handler stay unchanged.
- Carry encrypted reasoning items across the in-turn tool loop
  (provider-local, keyed by call_id) for stateless reasoning continuity.
- OPENAI_RESPONSES_STORE opt-in enables server-side state and
  previous_response_id chaining across turns, persisted in message
  metadata.
- Set gpt-5.5 to api_flavor: responses, reasoning_effort: medium.
- Remove AzureOpenAILLM and the azure_openai provider/catalog.
This commit is contained in:
Alex committed 2026-06-03 13:43:04 +01:00
1 parent 84630d1314
commit 142a9201f4
14 files changed
+917 -150

No files matched your search

+29
View File
@@ -123,6 +123,26 @@ class BaseAgent(ABC):
self, query: str, log_context: LogContext = None
) -> Generator[Dict, None, None]:
yield from self._gen_inner(query, log_context)
yield from self._emit_responses_metadata()
def _emit_responses_metadata(self) -> Generator[Dict, None, None]:
"""Surface the latest Responses API id so the route can persist it in
message metadata for previous_response_id chaining across turns."""
if not settings.OPENAI_RESPONSES_STORE:
return
response_id = getattr(self.llm, "_last_response_id", None)
if response_id:
yield {"metadata": {"response_id": response_id}}
def _previous_response_id(self) -> Optional[str]:
"""Most recent stored Responses API id from chat history, if any."""
for turn in reversed(self.chat_history or []):
if not isinstance(turn, dict):
continue
meta = turn.get("metadata")
if isinstance(meta, dict) and meta.get("response_id"):
return meta["response_id"]
return None
@abstractmethod
def _gen_inner(
@@ -280,6 +300,7 @@ class BaseAgent(ABC):
yield {"sources": self.retrieved_docs}
yield {"tool_calls": self._get_truncated_tool_calls()}
yield from self._emit_responses_metadata()
# ---- Tool delegation (thin wrappers around ToolExecutor) ----
@@ -558,6 +579,14 @@ class BaseAgent(ABC):
gen_kwargs["response_format"] = structured_format
elif self.llm_name == "google":
gen_kwargs["response_schema"] = structured_format
if (
settings.OPENAI_RESPONSES_STORE
and hasattr(self.llm, "_uses_responses_api")
and self.llm._uses_responses_api()
):
previous_response_id = self._previous_response_id()
if previous_response_id:
gen_kwargs["previous_response_id"] = previous_response_id
resp = self.llm.gen_stream(**gen_kwargs)
if log_context:
+8 -1
View File
@@ -16,7 +16,6 @@ class ModelProvider(str, Enum):
OPENAI = "openai"
OPENAI_COMPATIBLE = "openai_compatible"
OPENROUTER = "openrouter"
AZURE_OPENAI = "azure_openai"
ANTHROPIC = "anthropic"
GROQ = "groq"
GOOGLE = "google"
@@ -37,6 +36,14 @@ class ModelCapabilities:
context_window: int = 128000
input_cost_per_token: Optional[float] = None
output_cost_per_token: Optional[float] = None
# OpenAI reasoning-model effort hint (none/minimal/low/medium/high/xhigh;
# the accepted subset is model-dependent). Consumed only by the
# Responses API path in OpenAILLM; ignored by other providers.
reasoning_effort: Optional[str] = None
# Which OpenAI wire protocol the model speaks: "chat_completions"
# (the default) or "responses" (the /v1/responses endpoint). Set per
# model so only models that actually support the Responses API opt in.
api_flavor: str = "chat_completions"
@dataclass
+33
View File
@@ -31,6 +31,15 @@ logger = logging.getLogger(__name__)
BUILTIN_MODELS_DIR = Path(__file__).parent / "models"
DEFAULTS_FILENAME = "_defaults.yaml"
# Accepted reasoning_effort values across the OpenAI reasoning lineup.
# Validated at YAML load so a typo aborts boot rather than surfacing as a
# provider 400. The subset a given model actually accepts is model-dependent.
VALID_REASONING_EFFORTS = frozenset(
{"none", "minimal", "low", "medium", "high", "xhigh"}
)
# Accepted api_flavor values: which OpenAI wire protocol a model speaks.
VALID_API_FLAVORS = frozenset({"chat_completions", "responses"})
class _DefaultsFile(BaseModel):
"""Schema for ``_defaults.yaml``. Currently just attachment aliases."""
@@ -56,6 +65,28 @@ class _CapabilityFields(BaseModel):
context_window: Optional[int] = None
input_cost_per_token: Optional[float] = None
output_cost_per_token: Optional[float] = None
reasoning_effort: Optional[str] = None
api_flavor: Optional[str] = None
@field_validator("reasoning_effort")
@classmethod
def _valid_reasoning_effort(cls, v: Optional[str]) -> Optional[str]:
if v is not None and v not in VALID_REASONING_EFFORTS:
valid = ", ".join(sorted(VALID_REASONING_EFFORTS))
raise ValueError(
f"reasoning_effort must be one of [{valid}], got {v!r}"
)
return v
@field_validator("api_flavor")
@classmethod
def _valid_api_flavor(cls, v: Optional[str]) -> Optional[str]:
if v is not None and v not in VALID_API_FLAVORS:
valid = ", ".join(sorted(VALID_API_FLAVORS))
raise ValueError(
f"api_flavor must be one of [{valid}], got {v!r}"
)
return v
class _ModelEntry(_CapabilityFields):
@@ -205,6 +236,8 @@ def _build_model(
context_window=pick("context_window", 128000),
input_cost_per_token=pick("input_cost_per_token", None),
output_cost_per_token=pick("output_cost_per_token", None),
reasoning_effort=pick("reasoning_effort", None),
api_flavor=pick("api_flavor", "chat_completions"),
)
return AvailableModel(
-31
View File
@@ -1,31 +0,0 @@
# Azure OpenAI catalog.
#
# IMPORTANT: For Azure OpenAI, the `id` field is the **deployment name**, not
# a model name. Deployment names are arbitrary strings the operator chooses
# in Azure portal (or via ARM/Bicep/Terraform) when they create a deployment
# for a given underlying model + version.
#
# The IDs below are sensible defaults that mirror the underlying OpenAI
# model name (prefixed with `azure-`). Operators almost always need to
# override them via `MODELS_CONFIG_DIR` to match the deployment names that
# actually exist in their Azure resource. The `display_name`, capability
# flags, and `context_window` reflect the underlying OpenAI model.
provider: azure_openai
defaults:
supports_tools: true
supports_structured_output: true
attachments: [image]
context_window: 400000
models:
- id: azure-gpt-5.5
display_name: Azure OpenAI GPT-5.5
description: Azure-hosted flagship frontier model for complex reasoning, coding, and agentic work with a 1M-token context window
context_window: 1050000
- id: azure-gpt-5.4-mini
display_name: Azure OpenAI GPT-5.4 Mini
description: Azure-hosted cost-efficient GPT-5.4-class model for high-volume coding, computer use, and subagent workloads
- id: azure-gpt-5.4-nano
display_name: Azure OpenAI GPT-5.4 Nano
description: Azure-hosted cheapest GPT-5.4-class model, optimized for simple high-volume tasks where speed and cost matter most
+2
View File
@@ -10,6 +10,8 @@ models:
display_name: GPT-5.5
description: Flagship frontier model for complex reasoning, coding, and agentic work with a 1M-token context window
context_window: 1050000
api_flavor: responses
reasoning_effort: medium
- id: gpt-5.4-mini
display_name: GPT-5.4 Mini
description: Cost-efficient GPT-5.4-class model for high-volume coding, computer use, and subagent workloads
+7
View File
@@ -189,6 +189,13 @@ class Settings(BaseSettings):
# Tool pre-fetch settings
ENABLE_TOOL_PREFETCH: bool = True
# When True, OpenAI Responses API calls are persisted server-side
# (store=true) so a previous_response_id can chain turns. When False
# (the default) Responses calls are stateless (store=false) and any
# reasoning is carried across the in-turn tool loop via encrypted
# reasoning items instead.
OPENAI_RESPONSES_STORE: bool = False
# Config-free tools on by default in agentless chats. ``scheduler`` is
# dual-registered (also in ``BUILTIN_AGENT_TOOLS``) so the same synthetic id
# resolves whether reached via defaults or the agent picker.
+472 -17
View File
@@ -60,6 +60,53 @@ def _truncate_base64_for_logging(messages):
return truncated
class _RespFunction:
"""Minimal stand-in for an OpenAI tool-call ``function`` object."""
def __init__(self, name, arguments):
self.name = name
self.arguments = arguments
class _RespToolCall:
"""Chat-Completions-shaped tool call synthesized from a Responses
``function_call`` item, so the existing OpenAI handler and the streaming
tool-call accumulator consume it unchanged."""
def __init__(self, id, index, name, arguments):
self.id = id
self.index = index
self.type = "function"
self.function = _RespFunction(name, arguments)
class _RespDelta:
"""Stand-in for a streaming chat ``choice.delta``."""
def __init__(self, content=None, tool_calls=None):
self.content = content
self.tool_calls = tool_calls
class _RespMessage:
"""Stand-in for a non-streaming chat ``choice.message``."""
def __init__(self, content=None, tool_calls=None):
self.content = content
self.tool_calls = tool_calls
class _RespChoice:
"""Stand-in for ``response.choices[0]`` (non-streaming) or a streaming
chunk's choice. ``parse_response`` reads ``.message`` or ``.delta`` plus
``.finish_reason``."""
def __init__(self, finish_reason, delta=None, message=None):
self.delta = delta
self.message = message
self.finish_reason = finish_reason
class OpenAILLM(BaseLLM):
provider_name = "openai"
@@ -102,6 +149,13 @@ class OpenAILLM(BaseLLM):
api_key=self.api_key, base_url=effective_base_url
)
self.storage = StorageCreator.get_storage()
# Per-instance state for the Responses API path. ``_reasoning_for_calls``
# maps a function-call id to the reasoning items that preceded it, so
# the model's chain-of-thought survives the in-turn tool round-trip.
# ``_last_response_id`` is the most recent /v1/responses id, used to
# chain turns when OPENAI_RESPONSES_STORE is enabled.
self._reasoning_for_calls = {}
self._last_response_id = None
def _clean_messages_openai(self, messages):
cleaned_messages = []
@@ -283,6 +337,17 @@ class OpenAILLM(BaseLLM):
if response_format and not self._supports_structured_output():
response_format = None
previous_response_id = kwargs.pop("previous_response_id", None)
if self._uses_responses_api():
return self._responses_gen(
model,
messages,
tools=tools,
response_format=response_format,
previous_response_id=previous_response_id,
**kwargs,
)
request_params = {
"model": model,
"messages": messages,
@@ -326,6 +391,18 @@ class OpenAILLM(BaseLLM):
if response_format and not self._supports_structured_output():
response_format = None
previous_response_id = kwargs.pop("previous_response_id", None)
if self._uses_responses_api():
yield from self._responses_gen_stream(
model,
messages,
tools=tools,
response_format=response_format,
previous_response_id=previous_response_id,
**kwargs,
)
return
request_params = {
"model": model,
"messages": messages,
@@ -366,6 +443,401 @@ class OpenAILLM(BaseLLM):
if hasattr(response, "close"):
response.close()
# ---- Responses API (/v1/responses) ----
def _uses_responses_api(self):
"""True when the model's registry capability opts it into the
``/v1/responses`` endpoint."""
return (
self.capabilities is not None
and getattr(self.capabilities, "api_flavor", "chat_completions")
== "responses"
)
@staticmethod
def _responses_content_parts(role, content):
"""Translate a cleaned chat ``content`` value into Responses content
parts (``input_text``/``output_text``/``input_image``/``input_file``)."""
text_type = "output_text" if role == "assistant" else "input_text"
parts = []
if content is None:
return parts
if isinstance(content, str):
if content:
parts.append({"type": text_type, "text": content})
return parts
if isinstance(content, list):
for item in content:
if not isinstance(item, dict):
continue
itype = item.get("type")
if itype == "text":
parts.append({"type": text_type, "text": item.get("text", "")})
elif itype == "image_url":
url = (item.get("image_url") or {}).get("url")
if url:
parts.append({"type": "input_image", "image_url": url})
elif itype == "file":
file_obj = item.get("file") or {}
file_part = {"type": "input_file"}
for key in ("file_id", "filename", "file_data"):
if file_obj.get(key):
file_part[key] = file_obj[key]
parts.append(file_part)
return parts
def _to_responses_input(self, messages):
"""Translate cleaned Chat-Completions messages into a Responses
``input`` item list.
Reasoning items captured during the in-turn tool loop are re-injected
ahead of the function calls they belong to (deduped by id) so the
model keeps its chain-of-thought across the round-trip.
"""
input_items = []
emitted_reasoning = set()
for message in messages:
role = message.get("role")
tool_calls = message.get("tool_calls")
if tool_calls and role == "assistant":
for tc in tool_calls:
call_id = tc.get("id", "")
for item in self._reasoning_for_calls.get(call_id, []):
item_id = item.get("id")
if item_id and item_id in emitted_reasoning:
continue
if item_id:
emitted_reasoning.add(item_id)
input_items.append(item)
func = tc.get("function", {})
input_items.append({
"type": "function_call",
"call_id": call_id,
"name": func.get("name", ""),
"arguments": func.get("arguments", "") or "{}",
})
continue
tool_call_id = message.get("tool_call_id")
if role == "tool" and tool_call_id is not None:
tool_content = message.get("content")
input_items.append({
"type": "function_call_output",
"call_id": tool_call_id,
"output": (
tool_content
if isinstance(tool_content, str)
else json.dumps(tool_content)
),
})
continue
parts = self._responses_content_parts(role, message.get("content"))
if parts:
input_items.append({"role": role, "content": parts})
return input_items
@staticmethod
def _trim_for_previous_response(messages):
"""When chaining via ``previous_response_id`` the server already holds
the earlier turns, so only system context plus everything after the
last completed assistant response needs to be sent again."""
last_assistant = -1
for i, message in enumerate(messages):
if message.get("role") == "assistant" and not message.get(
"tool_calls"
):
last_assistant = i
if last_assistant < 0:
return messages
head = [
m
for m in messages[: last_assistant + 1]
if m.get("role") == "system"
]
return head + messages[last_assistant + 1:]
@staticmethod
def _to_responses_tools(tools):
"""Flatten Chat-Completions tool defs into Responses tool defs.
``strict`` is left False so schemas that were valid on Chat
Completions are not newly rejected by the stricter Responses default.
"""
converted = []
for tool in tools or []:
if tool.get("type") == "function" and isinstance(
tool.get("function"), dict
):
fn = tool["function"]
converted.append({
"type": "function",
"name": fn.get("name", ""),
"description": fn.get("description", ""),
"parameters": fn.get("parameters", {}),
"strict": False,
})
else:
converted.append(tool)
return converted
@staticmethod
def _responses_text_format(response_format):
"""Map a Chat-Completions ``response_format`` to a Responses
``text.format`` object."""
if not isinstance(response_format, dict):
return None
if response_format.get("type") == "json_schema":
js = response_format.get("json_schema", {})
fmt = {"type": "json_schema", "name": js.get("name", "response")}
if "schema" in js:
fmt["schema"] = js["schema"]
if "strict" in js:
fmt["strict"] = js["strict"]
return fmt
if response_format.get("type") == "json_object":
return {"type": "json_object"}
return None
def _build_responses_params(
self,
model,
input_items,
tools,
response_format,
previous_response_id,
stream,
kwargs,
):
"""Assemble the kwargs for ``client.responses.create``. Only known,
Responses-compatible keys are forwarded — unknown chat-only kwargs
are dropped so the API does not reject the request."""
params = {"model": model, "input": input_items, "stream": stream}
max_out = kwargs.pop("max_completion_tokens", None)
if max_out is None:
max_out = kwargs.pop("max_tokens", None)
if max_out is not None:
params["max_output_tokens"] = max_out
effort = (
getattr(self.capabilities, "reasoning_effort", None)
if self.capabilities is not None
else None
)
if effort:
params["reasoning"] = {"effort": effort, "summary": "auto"}
if response_format:
fmt = self._responses_text_format(response_format)
if fmt:
params["text"] = {"format": fmt}
if tools:
params["tools"] = self._to_responses_tools(tools)
store = bool(settings.OPENAI_RESPONSES_STORE)
params["store"] = store
if store:
if previous_response_id:
params["previous_response_id"] = previous_response_id
else:
# Stateless: ask the API to return encrypted reasoning so it can
# be replayed across the in-turn tool loop without server-side
# retention of the conversation.
params["include"] = ["reasoning.encrypted_content"]
return params
@staticmethod
def _reasoning_item_to_dict(item):
"""Serialize a Responses ``reasoning`` output item into the input
shape needed to feed it back on the next call."""
result = {"type": "reasoning", "id": getattr(item, "id", None)}
encrypted = getattr(item, "encrypted_content", None)
if encrypted is not None:
result["encrypted_content"] = encrypted
summary = getattr(item, "summary", None) or []
serialized = []
for part in summary:
if isinstance(part, dict):
serialized.append(part)
else:
serialized.append({
"type": getattr(part, "type", "summary_text"),
"text": getattr(part, "text", ""),
})
result["summary"] = serialized
return result
def _record_responses_metadata(self, response):
rid = getattr(response, "id", None)
if rid:
self._last_response_id = rid
def _remember_reasoning(self, tool_calls, reasoning_items):
"""Key captured reasoning items by each function-call id for replay
on the next in-turn request."""
if not reasoning_items:
return
for tc in tool_calls:
self._reasoning_for_calls[tc.id] = reasoning_items
def _parse_responses_output(self, response):
"""Walk a non-streaming Responses ``output`` array into
``(content, tool_calls, reasoning_items)``."""
content_parts = []
tool_calls = []
reasoning_items = []
for item in getattr(response, "output", None) or []:
itype = getattr(item, "type", None)
if itype == "reasoning":
reasoning_items.append(self._reasoning_item_to_dict(item))
elif itype == "message":
for part in getattr(item, "content", None) or []:
if getattr(part, "type", None) == "output_text":
content_parts.append(getattr(part, "text", "") or "")
elif itype == "function_call":
tool_calls.append(_RespToolCall(
id=getattr(item, "call_id", "") or getattr(item, "id", ""),
index=len(tool_calls),
name=getattr(item, "name", "") or "",
arguments=getattr(item, "arguments", "") or "",
))
return "".join(content_parts), tool_calls, reasoning_items
def _responses_gen(
self,
model,
messages,
tools=None,
response_format=None,
previous_response_id=None,
**kwargs,
):
if previous_response_id and settings.OPENAI_RESPONSES_STORE:
messages = self._trim_for_previous_response(messages)
input_items = self._to_responses_input(messages)
params = self._build_responses_params(
model,
input_items,
tools,
response_format,
previous_response_id,
stream=False,
kwargs=kwargs,
)
response = self.client.responses.create(**params)
logging.info(f"OpenAI responses output: {getattr(response, 'output', None)}")
self._record_responses_metadata(response)
content, tool_calls, reasoning_items = self._parse_responses_output(
response
)
if tools:
self._remember_reasoning(tool_calls, reasoning_items)
message = _RespMessage(
content=content or None, tool_calls=tool_calls or None
)
return _RespChoice(
finish_reason="tool_calls" if tool_calls else "stop",
message=message,
)
return content or ""
def _responses_gen_stream(
self,
model,
messages,
tools=None,
response_format=None,
previous_response_id=None,
**kwargs,
):
if previous_response_id and settings.OPENAI_RESPONSES_STORE:
messages = self._trim_for_previous_response(messages)
input_items = self._to_responses_input(messages)
params = self._build_responses_params(
model,
input_items,
tools,
response_format,
previous_response_id,
stream=True,
kwargs=kwargs,
)
response = self.client.responses.create(**params)
func_calls = {}
reasoning_items = []
try:
for event in response:
etype = getattr(event, "type", "")
if etype == "response.output_text.delta":
delta = getattr(event, "delta", "")
if delta:
yield delta
elif etype == "response.reasoning_summary_text.delta":
delta = getattr(event, "delta", "")
if delta:
yield {"type": "thought", "thought": delta}
elif etype == "response.output_item.added":
item = getattr(event, "item", None)
if getattr(item, "type", None) == "function_call":
index = getattr(event, "output_index", len(func_calls))
func_calls[index] = {
"call_id": (
getattr(item, "call_id", "")
or getattr(item, "id", "")
),
"name": getattr(item, "name", "") or "",
"arguments": "",
}
elif etype == "response.function_call_arguments.delta":
index = getattr(event, "output_index", None)
if index in func_calls:
func_calls[index]["arguments"] += (
getattr(event, "delta", "") or ""
)
elif etype == "response.function_call_arguments.done":
index = getattr(event, "output_index", None)
if index in func_calls:
done_args = getattr(event, "arguments", None)
if done_args is not None:
func_calls[index]["arguments"] = done_args
elif etype == "response.output_item.done":
item = getattr(event, "item", None)
if getattr(item, "type", None) == "reasoning":
reasoning_items.append(
self._reasoning_item_to_dict(item)
)
elif etype == "response.completed":
self._record_responses_metadata(
getattr(event, "response", None)
)
if func_calls:
tool_calls = []
for position, index in enumerate(sorted(func_calls)):
entry = func_calls[index]
tool_calls.append(_RespToolCall(
id=entry["call_id"],
index=position,
name=entry["name"],
arguments=entry["arguments"],
))
self._remember_reasoning(tool_calls, reasoning_items)
yield _RespChoice(
finish_reason="tool_calls",
delta=_RespDelta(tool_calls=tool_calls),
)
elif etype in ("response.failed", "error"):
resp = getattr(event, "response", None)
err = (
getattr(resp, "error", None)
or getattr(event, "message", None)
or "Responses stream error"
)
raise RuntimeError(f"Responses API stream error: {err}")
finally:
if hasattr(response, "close"):
response.close()
def _supports_tools(self):
# When the LLM was constructed via LLMCreator with a registered
# AvailableModel, ``self.capabilities`` is the per-model record.
@@ -620,20 +1092,3 @@ class OpenAILLM(BaseLLM):
except Exception as e:
logging.error(f"Error uploading file to OpenAI: {e}", exc_info=True)
raise
class AzureOpenAILLM(OpenAILLM):
def __init__(self, api_key, user_api_key, *args, **kwargs):
super().__init__(api_key)
self.api_base = (settings.OPENAI_API_BASE,)
self.api_version = (settings.OPENAI_API_VERSION,)
self.deployment_name = (settings.AZURE_DEPLOYMENT_NAME,)
from openai import AzureOpenAI
self.client = AzureOpenAI(
api_key=api_key,
api_version=settings.OPENAI_API_VERSION,
azure_endpoint=settings.OPENAI_API_BASE,
)
-2
View File
@@ -11,7 +11,6 @@ from __future__ import annotations
from typing import Dict, List
from application.llm.providers.anthropic import AnthropicProvider
from application.llm.providers.azure_openai import AzureOpenAIProvider
from application.llm.providers.base import Provider
from application.llm.providers.docsgpt import DocsGPTProvider
from application.llm.providers.google import GoogleProvider
@@ -34,7 +33,6 @@ ALL_PROVIDERS: List[Provider] = [
DocsGPTProvider(),
OpenAIProvider(),
OpenAICompatibleProvider(),
AzureOpenAIProvider(),
AnthropicProvider(),
GoogleProvider(),
GroqProvider(),
-30
View File
@@ -1,30 +0,0 @@
from __future__ import annotations
from typing import Optional
from application.llm.openai import AzureOpenAILLM
from application.llm.providers.base import Provider
class AzureOpenAIProvider(Provider):
name = "azure_openai"
llm_class = AzureOpenAILLM
def get_api_key(self, settings) -> Optional[str]:
# Azure historically uses the generic API_KEY field.
return settings.API_KEY
def is_enabled(self, settings) -> bool:
if settings.OPENAI_API_BASE:
return True
return settings.LLM_PROVIDER == self.name and bool(settings.API_KEY)
def filter_yaml_models(self, settings, models):
# Mirrors _add_azure_openai_models: when LLM_PROVIDER==azure_openai
# and LLM_NAME matches a known model, narrow to that one model.
# Otherwise load the entire catalog.
if settings.LLM_PROVIDER == self.name and settings.LLM_NAME:
named = [m for m in models if m.id == settings.LLM_NAME]
if named:
return named
return models
-19
View File
@@ -60,11 +60,6 @@ EXPECTED_IDS = {
"deepseek-v4-flash",
"deepseek-v4-pro",
},
"azure_openai": {
"azure-gpt-5.5",
"azure-gpt-5.4-mini",
"azure-gpt-5.4-nano",
},
"docsgpt": {"docsgpt-local"},
"huggingface": {"huggingface-local"},
}
@@ -241,20 +236,6 @@ class TestRegistryPermutations:
ids = {m.id for m in reg.get_all_models()}
assert ids == EXPECTED_IDS["docsgpt"]
def test_azure_via_provider(self):
s = _make_settings(LLM_PROVIDER="azure_openai", API_KEY="key")
with patch("application.core.settings.settings", s):
reg = ModelRegistry()
ids = {m.id for m in reg.get_all_models()}
assert "azure-gpt-5.5" in ids
def test_azure_via_api_base(self):
s = _make_settings(OPENAI_API_BASE="https://x.openai.azure.com")
with patch("application.core.settings.settings", s):
reg = ModelRegistry()
ids = {m.id for m in reg.get_all_models()}
assert "azure-gpt-5.5" in ids
def test_everything_set(self, monkeypatch):
monkeypatch.setenv("DEEPSEEK_API_KEY", "x")
s = _make_settings(
-1
View File
@@ -37,7 +37,6 @@ class TestModelProvider:
assert ModelProvider.SAGEMAKER == "sagemaker"
assert ModelProvider.PREMAI == "premai"
assert ModelProvider.LLAMA_CPP == "llama.cpp"
assert ModelProvider.AZURE_OPENAI == "azure_openai"
class TestModelCapabilities:
-10
View File
@@ -156,16 +156,6 @@ class TestGetApiKeyForProvider:
assert get_api_key_for_provider("unknown_provider") == "sk-fallback"
@pytest.mark.unit
def test_azure_openai_key(self):
with patch("application.core.settings.settings") as mock_settings:
mock_settings.API_KEY = "sk-azure"
from application.core.model_utils import get_api_key_for_provider
assert get_api_key_for_provider("azure_openai") == "sk-azure"
# ── get_all_available_models ─────────────────────────────────────────────────
-39
View File
@@ -8,7 +8,6 @@ Extends coverage beyond test_openai_llm.py:
- _raw_gen with tools and response_format
- _raw_gen_stream tool_calls yielding
- prepare_structured_output_format nested schemas
- AzureOpenAILLM constructor
- _supports_tools / _supports_structured_output
- get_supported_attachment_types
- prepare_messages_with_attachments edge cases
@@ -751,44 +750,6 @@ class TestGetBase64Image:
llm._get_base64_image({"path": "/nonexistent"})
# AzureOpenAILLM
@pytest.mark.unit
class TestAzureOpenAILLM:
def test_constructor(self, monkeypatch):
monkeypatch.setattr(
"application.llm.openai.settings",
types.SimpleNamespace(
OPENAI_API_KEY="k",
API_KEY="k",
OPENAI_BASE_URL="",
OPENAI_API_BASE="https://my.azure.endpoint",
OPENAI_API_VERSION="2024-02-01",
AZURE_DEPLOYMENT_NAME="my-deployment",
),
)
monkeypatch.setattr(
"application.llm.openai.StorageCreator",
types.SimpleNamespace(get_storage=lambda: None),
)
from unittest.mock import MagicMock
monkeypatch.setattr("application.llm.openai.OpenAI", MagicMock())
mock_azure = MagicMock()
monkeypatch.setattr("openai.AzureOpenAI", mock_azure, raising=False)
# We need to reimport to get fresh class with mocked module
import importlib
import application.llm.openai as oai_mod
importlib.reload(oai_mod)
# Just verify the class exists and inherits from OpenAILLM
assert issubclass(oai_mod.AzureOpenAILLM, oai_mod.OpenAILLM)
# _truncate_base64_for_logging — additional edges
+366
View File
@@ -0,0 +1,366 @@
"""Unit tests for the OpenAI Responses API path in application/llm/openai.py.
Covers the api_flavor gating, Chat-Completions -> Responses request
translation, tool/structured-output mapping, reasoning-item carryover, the
streaming-event normalization into the existing handler contract, and the
previous_response_id trimming used for cross-turn chaining.
"""
import types
from unittest.mock import MagicMock
import pytest
from application.core.model_settings import ModelCapabilities
def _make_llm(monkeypatch, capabilities=None, store_responses=False):
monkeypatch.setattr("application.llm.openai.OpenAI", MagicMock())
monkeypatch.setattr(
"application.llm.openai.StorageCreator",
types.SimpleNamespace(get_storage=lambda: None),
)
monkeypatch.setattr(
"application.llm.openai.settings",
types.SimpleNamespace(
OPENAI_API_KEY="k",
API_KEY="k",
OPENAI_BASE_URL="",
AZURE_DEPLOYMENT_NAME="dep",
OPENAI_RESPONSES_STORE=store_responses,
),
)
from application.llm.openai import OpenAILLM
llm = OpenAILLM(api_key="k")
llm.capabilities = capabilities
return llm
def _ns(**kw):
return types.SimpleNamespace(**kw)
def _responses_caps(reasoning_effort=None):
return ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
api_flavor="responses",
reasoning_effort=reasoning_effort,
)
# ── api_flavor gating ────────────────────────────────────────────────────────
@pytest.mark.unit
def test_uses_responses_api_true(monkeypatch):
llm = _make_llm(monkeypatch, _responses_caps())
assert llm._uses_responses_api() is True
@pytest.mark.unit
def test_uses_responses_api_false_for_chat(monkeypatch):
caps = ModelCapabilities(api_flavor="chat_completions")
assert _make_llm(monkeypatch, caps)._uses_responses_api() is False
@pytest.mark.unit
def test_uses_responses_api_false_without_caps(monkeypatch):
assert _make_llm(monkeypatch, None)._uses_responses_api() is False
# ── message translation ──────────────────────────────────────────────────────
@pytest.mark.unit
def test_to_responses_input_tool_roundtrip(monkeypatch):
llm = _make_llm(monkeypatch, _responses_caps())
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": None,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "search", "arguments": '{"q":"x"}'},
}],
},
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
{"role": "assistant", "content": "final"},
]
items = llm._to_responses_input(messages)
assert items == [
{"role": "system", "content": [{"type": "input_text", "text": "sys"}]},
{"role": "user", "content": [{"type": "input_text", "text": "hi"}]},
{
"type": "function_call",
"call_id": "call_1",
"name": "search",
"arguments": '{"q":"x"}',
},
{"type": "function_call_output", "call_id": "call_1", "output": "result"},
{"role": "assistant", "content": [{"type": "output_text", "text": "final"}]},
]
@pytest.mark.unit
def test_to_responses_input_reinjects_reasoning(monkeypatch):
llm = _make_llm(monkeypatch, _responses_caps())
reasoning_item = {
"type": "reasoning", "id": "rs_1",
"encrypted_content": "enc", "summary": [],
}
llm._reasoning_for_calls = {"call_1": [reasoning_item]}
messages = [{
"role": "assistant",
"content": None,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "t", "arguments": "{}"},
}],
}]
items = llm._to_responses_input(messages)
# Reasoning item is emitted immediately before its function call.
assert items[0] == reasoning_item
assert items[1]["type"] == "function_call"
assert items[1]["call_id"] == "call_1"
@pytest.mark.unit
def test_to_responses_input_multimodal_image(monkeypatch):
llm = _make_llm(monkeypatch, _responses_caps())
messages = [{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,xx"}},
],
}]
items = llm._to_responses_input(messages)
assert items == [{
"role": "user",
"content": [
{"type": "input_text", "text": "look"},
{"type": "input_image", "image_url": "data:image/png;base64,xx"},
],
}]
@pytest.mark.unit
def test_to_responses_tools_flatten(monkeypatch):
llm = _make_llm(monkeypatch, _responses_caps())
tools = [{
"type": "function",
"function": {
"name": "search",
"description": "Search",
"parameters": {"type": "object", "properties": {}},
},
}]
assert llm._to_responses_tools(tools) == [{
"type": "function",
"name": "search",
"description": "Search",
"parameters": {"type": "object", "properties": {}},
"strict": False,
}]
@pytest.mark.unit
def test_responses_text_format_json_schema(monkeypatch):
llm = _make_llm(monkeypatch, _responses_caps())
rf = {
"type": "json_schema",
"json_schema": {"name": "out", "schema": {"type": "object"}, "strict": True},
}
assert llm._responses_text_format(rf) == {
"type": "json_schema", "name": "out",
"schema": {"type": "object"}, "strict": True,
}
@pytest.mark.unit
def test_trim_for_previous_response(monkeypatch):
llm = _make_llm(monkeypatch, _responses_caps())
messages = [
{"role": "system", "content": "sys"},
{"role": "user", "content": "old q"},
{"role": "assistant", "content": "old a"},
{"role": "user", "content": "new q"},
]
trimmed = llm._trim_for_previous_response(messages)
# System stays; everything up to and including the last assistant text
# is dropped (the server already holds it), leaving the new user turn.
assert trimmed == [
{"role": "system", "content": "sys"},
{"role": "user", "content": "new q"},
]
# ── request params ───────────────────────────────────────────────────────────
@pytest.mark.unit
def test_build_responses_params_stateless(monkeypatch):
llm = _make_llm(monkeypatch, _responses_caps(reasoning_effort="high"))
params = llm._build_responses_params(
"gpt-5.5", [{"role": "user", "content": []}], tools=None,
response_format=None, previous_response_id=None, stream=True,
kwargs={"max_completion_tokens": 256},
)
assert params["model"] == "gpt-5.5"
assert params["stream"] is True
assert params["max_output_tokens"] == 256
assert params["reasoning"] == {"effort": "high", "summary": "auto"}
assert params["store"] is False
assert params["include"] == ["reasoning.encrypted_content"]
assert "previous_response_id" not in params
@pytest.mark.unit
def test_build_responses_params_store_with_previous_id(monkeypatch):
llm = _make_llm(monkeypatch, _responses_caps(), store_responses=True)
params = llm._build_responses_params(
"gpt-5.5", [], tools=None, response_format=None,
previous_response_id="resp_abc", stream=False, kwargs={},
)
assert params["store"] is True
assert params["previous_response_id"] == "resp_abc"
assert "include" not in params
# ── streaming normalization into the existing handler contract ───────────────
@pytest.mark.unit
def test_responses_gen_stream_text_and_tools(monkeypatch):
from application.llm.handlers.openai import OpenAILLMHandler
llm = _make_llm(monkeypatch, _responses_caps())
events = [
_ns(type="response.output_text.delta", delta="Hel"),
_ns(type="response.output_text.delta", delta="lo"),
_ns(type="response.reasoning_summary_text.delta", delta="thinking"),
_ns(
type="response.output_item.added",
output_index=0,
item=_ns(type="function_call", call_id="call_1", name="search", id="fc_1"),
),
_ns(type="response.function_call_arguments.delta", output_index=0, delta='{"q":'),
_ns(
type="response.function_call_arguments.done",
output_index=0,
arguments='{"q":"hi"}',
),
_ns(
type="response.output_item.done",
item=_ns(type="reasoning", id="rs_1", encrypted_content="enc", summary=[]),
),
_ns(type="response.completed", response=_ns(id="resp_1")),
]
llm.client.responses.create = MagicMock(return_value=events)
out = list(llm._responses_gen_stream("gpt-5.5", [{"role": "user", "content": "hi"}], tools=[{"type": "function", "function": {"name": "search", "parameters": {}}}]))
assert "Hel" in out and "lo" in out
assert {"type": "thought", "thought": "thinking"} in out
choice = out[-1]
parsed = OpenAILLMHandler().parse_response(choice)
assert parsed.finish_reason == "tool_calls"
assert len(parsed.tool_calls) == 1
tc = parsed.tool_calls[0]
assert tc.id == "call_1"
assert tc.name == "search"
assert tc.arguments == '{"q":"hi"}'
# Reasoning captured for in-turn carryover, last response id recorded.
assert llm._reasoning_for_calls["call_1"][0]["encrypted_content"] == "enc"
assert llm._last_response_id == "resp_1"
@pytest.mark.unit
def test_responses_gen_stream_text_only(monkeypatch):
llm = _make_llm(monkeypatch, _responses_caps())
events = [
_ns(type="response.output_text.delta", delta="Answer"),
_ns(type="response.completed", response=_ns(id="resp_2")),
]
llm.client.responses.create = MagicMock(return_value=events)
out = list(llm._responses_gen_stream("gpt-5.5", [{"role": "user", "content": "hi"}], tools=None))
assert out == ["Answer"]
assert llm._last_response_id == "resp_2"
@pytest.mark.unit
def test_responses_gen_nonstream_tools(monkeypatch):
from application.llm.handlers.openai import OpenAILLMHandler
llm = _make_llm(monkeypatch, _responses_caps())
response = _ns(
id="resp_3",
output=[
_ns(type="reasoning", id="rs", encrypted_content="e", summary=[]),
_ns(type="message", content=[_ns(type="output_text", text="Answer")]),
_ns(type="function_call", call_id="c1", name="t", arguments="{}", id="fc"),
],
)
llm.client.responses.create = MagicMock(return_value=response)
choice = llm._responses_gen("gpt-5.5", [{"role": "user", "content": "hi"}], tools=[{"type": "function", "function": {"name": "t", "parameters": {}}}])
parsed = OpenAILLMHandler().parse_response(choice)
assert parsed.finish_reason == "tool_calls"
assert parsed.tool_calls[0].id == "c1"
assert llm._reasoning_for_calls["c1"][0]["encrypted_content"] == "e"
@pytest.mark.unit
def test_responses_gen_nonstream_text(monkeypatch):
llm = _make_llm(monkeypatch, _responses_caps())
response = _ns(
id="resp_4",
output=[_ns(type="message", content=[_ns(type="output_text", text="Hi there")])],
)
llm.client.responses.create = MagicMock(return_value=response)
result = llm._responses_gen("gpt-5.5", [{"role": "user", "content": "hi"}], tools=None)
assert result == "Hi there"
# ── capability plumbing / yaml ───────────────────────────────────────────────
@pytest.mark.unit
def test_capability_field_rejects_bad_api_flavor():
from application.core.model_yaml import _CapabilityFields
with pytest.raises(ValueError):
_CapabilityFields(api_flavor="grpc")
@pytest.mark.unit
def test_capability_field_rejects_bad_reasoning_effort():
from application.core.model_yaml import _CapabilityFields
with pytest.raises(ValueError):
_CapabilityFields(reasoning_effort="extreme")
@pytest.mark.unit
def test_builtin_gpt55_opts_into_responses():
from application.core.model_yaml import BUILTIN_MODELS_DIR, load_model_yamls
catalogs = load_model_yamls([BUILTIN_MODELS_DIR])
models = {m.id: m for c in catalogs for m in c.models}
gpt = models["gpt-5.5"]
assert gpt.capabilities.api_flavor == "responses"
assert gpt.capabilities.reasoning_effort == "medium"
@pytest.mark.unit
def test_builtin_default_models_stay_chat_completions():
from application.core.model_yaml import BUILTIN_MODELS_DIR, load_model_yamls
catalogs = load_model_yamls([BUILTIN_MODELS_DIR])
models = {m.id: m for c in catalogs for m in c.models}
assert models["gpt-5.4-mini"].capabilities.api_flavor == "chat_completions"