From 142a9201f4aed3224553eabff76fb670353f427a Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 3 Jun 2026 13:43:04 +0100 Subject: [PATCH] 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. --- application/agents/base.py | 29 ++ application/core/model_settings.py | 9 +- application/core/model_yaml.py | 33 ++ application/core/models/azure_openai.yaml | 31 -- application/core/models/openai.yaml | 2 + application/core/settings.py | 7 + application/llm/openai.py | 489 +++++++++++++++++++++- application/llm/providers/__init__.py | 2 - application/llm/providers/azure_openai.py | 30 -- tests/core/test_model_registry_yaml.py | 19 - tests/core/test_model_settings.py | 1 - tests/core/test_model_utils.py | 10 - tests/llm/test_openai.py | 39 -- tests/llm/test_openai_responses.py | 366 ++++++++++++++++ 14 files changed, 917 insertions(+), 150 deletions(-) delete mode 100644 application/core/models/azure_openai.yaml delete mode 100644 application/llm/providers/azure_openai.py create mode 100644 tests/llm/test_openai_responses.py diff --git a/application/agents/base.py b/application/agents/base.py index 22b808d9..e887c22d 100644 --- a/application/agents/base.py +++ b/application/agents/base.py @@ -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: diff --git a/application/core/model_settings.py b/application/core/model_settings.py index 49843e57..a9c9372d 100644 --- a/application/core/model_settings.py +++ b/application/core/model_settings.py @@ -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 diff --git a/application/core/model_yaml.py b/application/core/model_yaml.py index eb666c68..4a8323c4 100644 --- a/application/core/model_yaml.py +++ b/application/core/model_yaml.py @@ -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( diff --git a/application/core/models/azure_openai.yaml b/application/core/models/azure_openai.yaml deleted file mode 100644 index b796a6cd..00000000 --- a/application/core/models/azure_openai.yaml +++ /dev/null @@ -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 diff --git a/application/core/models/openai.yaml b/application/core/models/openai.yaml index 1f84e157..e0f209c5 100644 --- a/application/core/models/openai.yaml +++ b/application/core/models/openai.yaml @@ -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 diff --git a/application/core/settings.py b/application/core/settings.py index 0dee0db6..a5b88cf9 100644 --- a/application/core/settings.py +++ b/application/core/settings.py @@ -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. diff --git a/application/llm/openai.py b/application/llm/openai.py index 69228d01..dc17d5c8 100644 --- a/application/llm/openai.py +++ b/application/llm/openai.py @@ -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, - ) diff --git a/application/llm/providers/__init__.py b/application/llm/providers/__init__.py index 8b9315cb..c712c89e 100644 --- a/application/llm/providers/__init__.py +++ b/application/llm/providers/__init__.py @@ -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(), diff --git a/application/llm/providers/azure_openai.py b/application/llm/providers/azure_openai.py deleted file mode 100644 index 87658c7a..00000000 --- a/application/llm/providers/azure_openai.py +++ /dev/null @@ -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 diff --git a/tests/core/test_model_registry_yaml.py b/tests/core/test_model_registry_yaml.py index fdf6f6ae..df1105c3 100644 --- a/tests/core/test_model_registry_yaml.py +++ b/tests/core/test_model_registry_yaml.py @@ -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( diff --git a/tests/core/test_model_settings.py b/tests/core/test_model_settings.py index d4e1cec4..f00de9c4 100644 --- a/tests/core/test_model_settings.py +++ b/tests/core/test_model_settings.py @@ -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: diff --git a/tests/core/test_model_utils.py b/tests/core/test_model_utils.py index 7aefeeb5..4d6d65b7 100644 --- a/tests/core/test_model_utils.py +++ b/tests/core/test_model_utils.py @@ -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 ───────────────────────────────────────────────── diff --git a/tests/llm/test_openai.py b/tests/llm/test_openai.py index 973dbe2f..486c5ebf 100644 --- a/tests/llm/test_openai.py +++ b/tests/llm/test_openai.py @@ -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 diff --git a/tests/llm/test_openai_responses.py b/tests/llm/test_openai_responses.py new file mode 100644 index 00000000..a7505a49 --- /dev/null +++ b/tests/llm/test_openai_responses.py @@ -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"