mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-12 18:15:33 +00:00
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:
1 parent
84630d1314
commit
142a9201f4
14 files changed
+917
-150
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user