diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 8836a51f1a..c142883880 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1182,6 +1182,13 @@ "timeout": 120, "proxy": "", "custom_headers": {}, + "responses_web_search": False, + "responses_web_search_context_size": "medium", + "responses_web_search_allowed_domains": [], + "responses_file_search_vector_store_ids": [], + "responses_code_interpreter": False, + "responses_image_generation": False, + "responses_tool_choice": "auto", }, "Google Gemini": { "id": "google_gemini", @@ -1304,6 +1311,13 @@ "timeout": 120, "proxy": "", "custom_headers": {}, + "responses_web_search": False, + "responses_web_search_context_size": "medium", + "responses_web_search_allowed_domains": [], + "responses_file_search_vector_store_ids": [], + "responses_code_interpreter": False, + "responses_image_generation": False, + "responses_tool_choice": "auto", }, "DeepSeek": { "id": "deepseek", @@ -1328,6 +1342,13 @@ "timeout": 120, "proxy": "", "custom_headers": {}, + "responses_web_search": False, + "responses_web_search_context_size": "medium", + "responses_web_search_allowed_domains": [], + "responses_file_search_vector_store_ids": [], + "responses_code_interpreter": False, + "responses_image_generation": False, + "responses_tool_choice": "auto", }, "Zhipu": { "id": "zhipu", @@ -2026,6 +2047,58 @@ "type": "xai_chat_completion", }, }, + "responses_web_search": { + "description": "启用 Responses 原生网页搜索", + "type": "bool", + "hint": "通过 OpenAI Responses API 的 web_search 工具联网检索。仅对 openai_responses 提供商生效。", + "condition": {"type": "openai_responses"}, + }, + "responses_web_search_context_size": { + "description": "网页搜索上下文大小", + "type": "string", + "options": ["low", "medium", "high"], + "hint": "控制网页搜索为结果分配的上下文量。", + "condition": { + "type": "openai_responses", + "responses_web_search": True, + }, + }, + "responses_web_search_allowed_domains": { + "description": "网页搜索允许的域名", + "type": "list", + "items": {"type": "string"}, + "hint": "留空则不限制;填写域名后,网页搜索仅使用这些域名及其子域名。", + "condition": { + "type": "openai_responses", + "responses_web_search": True, + }, + }, + "responses_file_search_vector_store_ids": { + "description": "文件搜索 Vector Store IDs", + "type": "list", + "items": {"type": "string"}, + "hint": "填写 OpenAI Vector Store ID 以启用 file_search;留空则不启用。", + "condition": {"type": "openai_responses"}, + }, + "responses_code_interpreter": { + "description": "启用 Responses 原生代码解释器", + "type": "bool", + "hint": "通过 OpenAI 托管容器执行 Python 代码。", + "condition": {"type": "openai_responses"}, + }, + "responses_image_generation": { + "description": "启用 Responses 原生图像生成", + "type": "bool", + "hint": "允许支持的模型使用 image_generation 工具生成图片。", + "condition": {"type": "openai_responses"}, + }, + "responses_tool_choice": { + "description": "Responses 工具选择策略", + "type": "string", + "options": ["auto", "required", "none"], + "hint": "控制模型自动选择工具、强制调用工具或禁用所有工具。", + "condition": {"type": "openai_responses"}, + }, "rerank_api_base": { "description": "重排序模型 API Base URL", "type": "string", diff --git a/astrbot/core/provider/sources/openai_responses_source.py b/astrbot/core/provider/sources/openai_responses_source.py index c5cb9bdb82..90748a896d 100644 --- a/astrbot/core/provider/sources/openai_responses_source.py +++ b/astrbot/core/provider/sources/openai_responses_source.py @@ -56,6 +56,212 @@ def _field(value: Any, name: str, default: Any = None) -> Any: return value.get(name, default) return getattr(value, name, default) + @staticmethod + def _response_tool_key(tool: dict[str, Any]) -> tuple[str, str] | None: + """Return a stable key for tools that can safely be deduplicated. + + Args: + tool: A Responses API tool definition. + + Returns: + A key for native tools and named function tools, or ``None`` when + the tool must be preserved as-is. + """ + tool_type = tool.get("type") + if tool_type == "function": + name = tool.get("name") + if isinstance(name, str) and name: + return tool_type, name + return None + if tool_type in { + "web_search", + "file_search", + "code_interpreter", + "image_generation", + }: + return tool_type, "" + return None + + @classmethod + def _deduplicate_response_tools( + cls, + response_tools: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + """Keep the first configured definition of each native tool. + + Args: + response_tools: Responses API tools in precedence order. + + Returns: + Tools without duplicate native entries or function names. + """ + unique_tools: list[dict[str, Any]] = [] + seen_keys: set[tuple[str, str]] = set() + for tool in response_tools: + tool_key = cls._response_tool_key(tool) + if tool_key is not None: + if tool_key in seen_keys: + continue + seen_keys.add(tool_key) + unique_tools.append(tool) + return unique_tools + + def _build_response_tools( + self, + tools: ToolSet | None, + custom_tools: Any, + ) -> list[dict[str, Any]]: + """Build the Responses API tool list from AstrBot and native tools. + + Args: + tools: AstrBot function tools available for the request. + custom_tools: Backward-compatible raw Responses API tools from config. + + Returns: + The normalized tool entries to send to the Responses API. + """ + response_tools: list[dict[str, Any]] = [] + if tools: + for tool in tools.openai_schema(): + function = tool.get("function", {}) + response_tools.append({"type": "function", **function}) + + if self.provider_config.get("responses_web_search"): + web_search: dict[str, Any] = {"type": "web_search"} + context_size = self.provider_config.get( + "responses_web_search_context_size", + "medium", + ) + if context_size in {"low", "medium", "high"}: + web_search["search_context_size"] = context_size + allowed_domains = self.provider_config.get( + "responses_web_search_allowed_domains", + ) + if isinstance(allowed_domains, list): + domains = [ + domain.strip() + for domain in allowed_domains + if isinstance(domain, str) and domain.strip() + ] + if domains: + web_search["filters"] = {"allowed_domains": domains} + response_tools.append(web_search) + + vector_store_ids = self.provider_config.get( + "responses_file_search_vector_store_ids", + ) + if isinstance(vector_store_ids, list): + vector_store_ids = [ + vector_store_id.strip() + for vector_store_id in vector_store_ids + if isinstance(vector_store_id, str) and vector_store_id.strip() + ] + if vector_store_ids: + response_tools.append( + { + "type": "file_search", + "vector_store_ids": vector_store_ids, + } + ) + + if self.provider_config.get("responses_code_interpreter"): + response_tools.append( + { + "type": "code_interpreter", + "container": {"type": "auto"}, + } + ) + + if self.provider_config.get("responses_image_generation"): + response_tools.append({"type": "image_generation"}) + + if isinstance(custom_tools, list): + response_tools.extend( + tool for tool in custom_tools if isinstance(tool, dict) + ) + + return self._deduplicate_response_tools(response_tools) + + def _resolve_tool_choice( + self, + request_tool_choice: Any, + custom_tool_choice: Any, + ) -> str | dict[str, Any]: + """Resolve the Responses API tool choice without overriding custom values. + + Args: + request_tool_choice: Tool choice set for the current request. + custom_tool_choice: Backward-compatible tool choice from configuration. + + Returns: + A valid Responses API tool choice. + """ + configured_tool_choice = self.provider_config.get("responses_tool_choice") + if isinstance(configured_tool_choice, str) and configured_tool_choice in { + "required", + "none", + }: + return configured_tool_choice + + for tool_choice in (request_tool_choice, custom_tool_choice): + if isinstance(tool_choice, dict): + return tool_choice + if isinstance(tool_choice, str) and tool_choice in { + "auto", + "required", + "none", + }: + return tool_choice + + return "auto" + + def _prepare_response_request( + self, + payloads: dict[str, Any], + tools: ToolSet | None, + ) -> dict[str, Any]: + """Normalize a Responses API request before streaming or completion. + + Args: + payloads: Request payload that is updated in place. + tools: AstrBot function tools available for the request. + + Returns: + Extra request fields that are not SDK method parameters. + """ + extra_body: dict[str, Any] = {} + custom_extra_body = self.provider_config.get("custom_extra_body", {}) + if isinstance(custom_extra_body, dict): + extra_body.update(custom_extra_body) + + custom_tools = extra_body.pop("tools", None) + custom_tool_choice = extra_body.pop("tool_choice", None) + response_tools = self._build_response_tools(tools, custom_tools) + if response_tools: + payloads["tools"] = response_tools + payloads["tool_choice"] = self._resolve_tool_choice( + payloads.get("tool_choice"), + custom_tool_choice, + ) + + for key in list(payloads): + if key not in self.default_params: + extra_body[key] = payloads.pop(key) + + max_tokens = extra_body.pop("max_tokens", None) + if max_tokens is not None and "max_output_tokens" not in extra_body: + extra_body["max_output_tokens"] = max_tokens + reasoning_effort = extra_body.pop("reasoning_effort", None) + if reasoning_effort is not None and "reasoning" not in extra_body: + extra_body["reasoning"] = {"effort": reasoning_effort} + extra_body.pop("previous_response_id", None) + extra_body.pop("conversation", None) + extra_body.pop("store", None) + payloads.pop("previous_response_id", None) + payloads.pop("conversation", None) + payloads["store"] = False + return extra_body + def _convert_chat_messages_to_response_input( self, messages: list[dict], @@ -314,36 +520,7 @@ async def _query( Raises: TypeError: If the SDK returns an unexpected response type. """ - if tools: - response_tools = [] - for tool in tools.openai_schema(): - function = tool.get("function", {}) - response_tools.append({"type": "function", **function}) - if response_tools: - payloads["tools"] = response_tools - payloads["tool_choice"] = payloads.get("tool_choice", "auto") - - extra_body: dict[str, Any] = {} - custom_extra_body = self.provider_config.get("custom_extra_body", {}) - if isinstance(custom_extra_body, dict): - extra_body.update(custom_extra_body) - - for key in list(payloads): - if key not in self.default_params: - extra_body[key] = payloads.pop(key) - - max_tokens = extra_body.pop("max_tokens", None) - if max_tokens is not None and "max_output_tokens" not in extra_body: - extra_body["max_output_tokens"] = max_tokens - reasoning_effort = extra_body.pop("reasoning_effort", None) - if reasoning_effort is not None and "reasoning" not in extra_body: - extra_body["reasoning"] = {"effort": reasoning_effort} - extra_body.pop("previous_response_id", None) - extra_body.pop("conversation", None) - extra_body.pop("store", None) - payloads.pop("previous_response_id", None) - payloads.pop("conversation", None) - payloads["store"] = False + extra_body = self._prepare_response_request(payloads, tools) response = await retry_provider_request( "OpenAI Responses", @@ -383,36 +560,7 @@ async def _query_stream( Raises: EmptyModelOutputError: If the stream ends without a terminal event. """ - if tools: - response_tools = [] - for tool in tools.openai_schema(): - function = tool.get("function", {}) - response_tools.append({"type": "function", **function}) - if response_tools: - payloads["tools"] = response_tools - payloads["tool_choice"] = payloads.get("tool_choice", "auto") - - extra_body: dict[str, Any] = {} - custom_extra_body = self.provider_config.get("custom_extra_body", {}) - if isinstance(custom_extra_body, dict): - extra_body.update(custom_extra_body) - - for key in list(payloads): - if key not in self.default_params: - extra_body[key] = payloads.pop(key) - - max_tokens = extra_body.pop("max_tokens", None) - if max_tokens is not None and "max_output_tokens" not in extra_body: - extra_body["max_output_tokens"] = max_tokens - reasoning_effort = extra_body.pop("reasoning_effort", None) - if reasoning_effort is not None and "reasoning" not in extra_body: - extra_body["reasoning"] = {"effort": reasoning_effort} - extra_body.pop("previous_response_id", None) - extra_body.pop("conversation", None) - extra_body.pop("store", None) - payloads.pop("previous_response_id", None) - payloads.pop("conversation", None) - payloads["store"] = False + extra_body = self._prepare_response_request(payloads, tools) stream = await retry_provider_request( "OpenAI Responses", @@ -523,6 +671,9 @@ async def _parse_response( text_parts: list[str] = [] reasoning_parts: list[str] = [] serialized_reasoning_items: list[dict] = [] + citation_sources: dict[str, str] = {} + file_citation_sources: dict[str, str] = {} + generated_images: list[str] = [] for item in self._field(response, "output", []) or []: item_type = self._field(item, "type") @@ -531,6 +682,25 @@ async def _parse_response( content_type = self._field(content, "type") if content_type == "output_text": text_parts.append(str(self._field(content, "text", ""))) + for annotation in self._field(content, "annotations", []) or []: + annotation_type = self._field(annotation, "type") + if annotation_type == "url_citation": + url = self._field(annotation, "url") + if not isinstance(url, str) or not url: + continue + title = self._field(annotation, "title", "") + citation_sources.setdefault(url, str(title or url)) + elif annotation_type in { + "file_citation", + "container_file_citation", + }: + file_id = self._field(annotation, "file_id", "") + filename = self._field(annotation, "filename", "") + if isinstance(file_id, str) and file_id: + file_citation_sources.setdefault( + file_id, + str(filename or file_id), + ) elif content_type == "refusal": text_parts.append(str(self._field(content, "refusal", ""))) continue @@ -574,10 +744,33 @@ async def _parse_response( llm_response.tools_call_ids.append( str(self._field(item, "call_id", "")) ) + continue + + if item_type == "image_generation_call": + image_base64 = self._field(item, "result") + if isinstance(image_base64, str) and image_base64: + generated_images.append(image_base64) completion_text = "".join(text_parts) - if completion_text: - llm_response.result_chain = MessageChain().message(completion_text) + if completion_text or generated_images: + result_chain = MessageChain() + if completion_text: + result_chain.message(completion_text) + elif generated_images: + result_chain.message("[Image]") + for image_base64 in generated_images: + result_chain.base64_image(image_base64) + if citation_sources or file_citation_sources: + source_lines = ["Sources:"] + source_lines.extend( + f"- {title}: {url}" for url, title in citation_sources.items() + ) + source_lines.extend( + f"- {filename} ({file_id})" + for file_id, filename in file_citation_sources.items() + ) + result_chain.message("\n\n" + "\n".join(source_lines)) + llm_response.result_chain = result_chain if reasoning_parts: llm_response.reasoning_content = "\n".join(reasoning_parts) if serialized_reasoning_items: @@ -607,7 +800,12 @@ async def _parse_response( has_text = bool((llm_response.completion_text or "").strip()) has_reasoning = bool((llm_response.reasoning_content or "").strip()) - if not has_text and not has_reasoning and not llm_response.tools_call_args: + if ( + not has_text + and not generated_images + and not has_reasoning + and not llm_response.tools_call_args + ): raise EmptyModelOutputError( "Responses API returned no usable output. " f"response_id={response_id}, status={status}" diff --git a/dashboard/src/composables/useProviderSources.ts b/dashboard/src/composables/useProviderSources.ts index f2ff86ed61..16e3b96feb 100644 --- a/dashboard/src/composables/useProviderSources.ts +++ b/dashboard/src/composables/useProviderSources.ts @@ -393,6 +393,24 @@ export function useProviderSources(options: UseProviderSourcesOptions) { source.ollama_disable_thinking = false } + if (source.type === 'openai_responses') { + const responseToolDefaults = { + responses_web_search: false, + responses_web_search_context_size: 'medium', + responses_web_search_allowed_domains: [], + responses_file_search_vector_store_ids: [], + responses_code_interpreter: false, + responses_image_generation: false, + responses_tool_choice: 'auto' + } + + for (const [key, value] of Object.entries(responseToolDefaults)) { + if (source[key] === undefined) { + source[key] = value + } + } + } + return source } diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index fdd430c63e..6059622269 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1208,6 +1208,34 @@ "description": "Enable native search", "hint": "When enabled, uses xAI Chat Completions native Live Search for web queries (billed on demand). Only applies to xAI providers." }, + "responses_web_search": { + "description": "Enable Responses web search", + "hint": "Use the OpenAI Responses API web_search tool for online retrieval. Only applies to openai_responses providers." + }, + "responses_web_search_context_size": { + "description": "Web search context size", + "hint": "Controls how much context the web search tool allocates to its results." + }, + "responses_web_search_allowed_domains": { + "description": "Allowed web search domains", + "hint": "Leave empty for no restriction. When set, web search uses only these domains and their subdomains." + }, + "responses_file_search_vector_store_ids": { + "description": "File search Vector Store IDs", + "hint": "Set OpenAI Vector Store IDs to enable file_search; leave empty to disable it." + }, + "responses_code_interpreter": { + "description": "Enable Responses code interpreter", + "hint": "Run Python code in an OpenAI-hosted container." + }, + "responses_image_generation": { + "description": "Enable Responses image generation", + "hint": "Allow supported models to generate images with the image_generation tool." + }, + "responses_tool_choice": { + "description": "Responses tool choice", + "hint": "Choose whether the model may select tools automatically, must call a tool, or cannot call tools." + }, "rerank_api_base": { "description": "Rerank Model API Base URL", "hint": "The full request URL is formed by combining the Base URL and a path suffix (defaults to /v1/rerank)." diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index 3cd0104ca1..3510ba8d71 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -1209,6 +1209,34 @@ "description": "Включить нативный поиск", "hint": "Если включено, использует Live Search от xAI для веб-запросов (тарифицируется отдельно). Применимо только к провайдерам xAI." }, + "responses_web_search": { + "description": "Включить веб-поиск Responses", + "hint": "Использовать инструмент web_search API OpenAI Responses для поиска в интернете. Применимо только к провайдерам openai_responses." + }, + "responses_web_search_context_size": { + "description": "Размер контекста веб-поиска", + "hint": "Определяет объём контекста, выделяемого инструментом веб-поиска для результатов." + }, + "responses_web_search_allowed_domains": { + "description": "Разрешённые домены веб-поиска", + "hint": "Оставьте пустым без ограничений. При заполнении поиск выполняется только по указанным доменам и их поддоменам." + }, + "responses_file_search_vector_store_ids": { + "description": "Идентификаторы Vector Store для поиска по файлам", + "hint": "Укажите идентификаторы OpenAI Vector Store для включения file_search; оставьте пустым, чтобы отключить." + }, + "responses_code_interpreter": { + "description": "Включить интерпретатор кода Responses", + "hint": "Запускать код Python в контейнере OpenAI." + }, + "responses_image_generation": { + "description": "Включить генерацию изображений Responses", + "hint": "Разрешить поддерживаемым моделям создавать изображения с помощью инструмента image_generation." + }, + "responses_tool_choice": { + "description": "Выбор инструмента Responses", + "hint": "Выберите автоматический выбор инструментов, обязательный вызов инструмента или запрет вызова инструментов." + }, "rerank_api_base": { "description": "Base URL API модели Rerank", "hint": "Полный URL запроса формируется путём добавления суффикса к Base URL (по умолчанию /v1/rerank)." diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index e25cb8e0fb..7fce730d3d 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1210,6 +1210,34 @@ "description": "启用原生搜索功能", "hint": "启用后,将通过 xAI 的 Chat Completions 原生 Live Search 进行联网检索(按需计费)。仅对 xAI 提供商生效。" }, + "responses_web_search": { + "description": "启用 Responses 原生网页搜索", + "hint": "通过 OpenAI Responses API 的 web_search 工具联网检索。仅对 openai_responses 提供商生效。" + }, + "responses_web_search_context_size": { + "description": "网页搜索上下文大小", + "hint": "控制网页搜索为结果分配的上下文量。" + }, + "responses_web_search_allowed_domains": { + "description": "网页搜索允许的域名", + "hint": "留空则不限制;填写域名后,网页搜索仅使用这些域名及其子域名。" + }, + "responses_file_search_vector_store_ids": { + "description": "文件搜索 Vector Store IDs", + "hint": "填写 OpenAI Vector Store ID 以启用 file_search;留空则不启用。" + }, + "responses_code_interpreter": { + "description": "启用 Responses 原生代码解释器", + "hint": "通过 OpenAI 托管容器执行 Python 代码。" + }, + "responses_image_generation": { + "description": "启用 Responses 原生图像生成", + "hint": "允许支持的模型使用 image_generation 工具生成图片。" + }, + "responses_tool_choice": { + "description": "Responses 工具选择策略", + "hint": "控制模型自动选择工具、强制调用工具或禁用所有工具。" + }, "rerank_api_base": { "description": "重排序模型 API Base URL", "hint": "最终请求路径由 Base URL 和路径后缀拼接而成(默认为 /v1/rerank)。" diff --git a/tests/test_openai_responses_source.py b/tests/test_openai_responses_source.py index 6b2d5e6718..3155e2b36f 100644 --- a/tests/test_openai_responses_source.py +++ b/tests/test_openai_responses_source.py @@ -57,6 +57,8 @@ def test_responses_provider_templates_are_independent_and_stateless(): assert templates["OpenAI Responses"]["type"] == "openai_responses" assert templates["OpenAI Responses"]["api_base"] == "https://api.openai.com/v1" + assert templates["OpenAI Responses"]["responses_web_search"] is False + assert templates["OpenAI Responses"]["responses_tool_choice"] == "auto" assert templates["DeepSeek Responses"]["type"] == "openai_responses" assert templates["DeepSeek Responses"]["api_base"] == "https://api.deepseek.com/v1" assert templates["xAI"]["type"] == "openai_responses" @@ -291,6 +293,153 @@ async def fake_create(**kwargs): assert result.tools_call_ids == ["call_1"] +@pytest.mark.asyncio +async def test_query_combines_astrbot_and_responses_native_tools(monkeypatch): + provider = _make_provider( + { + "responses_web_search": True, + "custom_extra_body": { + "tool_choice": {"type": "function", "name": "weather"}, + }, + "responses_web_search_context_size": "high", + "responses_web_search_allowed_domains": [" example.com ", " "], + "responses_file_search_vector_store_ids": [" vs_1 "], + "responses_code_interpreter": True, + "responses_image_generation": True, + "responses_tool_choice": "required", + } + ) + captured: dict = {} + + async def fake_create(**kwargs): + captured.update(kwargs) + return _make_response( + [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [ + {"type": "output_text", "text": "done", "annotations": []}, + ], + } + ] + ) + + monkeypatch.setattr(provider.client.responses, "create", fake_create) + tools = SimpleNamespace( + openai_schema=lambda: [ + { + "type": "function", + "function": { + "name": "weather", + "description": "Get weather", + "parameters": {"type": "object"}, + }, + } + ] + ) + + await provider._query({"model": "gpt-test", "input": "hi"}, tools) + + assert captured["tool_choice"] == "required" + assert captured["tools"] == [ + { + "type": "function", + "name": "weather", + "description": "Get weather", + "parameters": {"type": "object"}, + }, + { + "type": "web_search", + "search_context_size": "high", + "filters": {"allowed_domains": ["example.com"]}, + }, + {"type": "file_search", "vector_store_ids": ["vs_1"]}, + {"type": "code_interpreter", "container": {"type": "auto"}}, + {"type": "image_generation"}, + ] + + +@pytest.mark.parametrize( + ("request_tool_choice", "custom_tool_choice", "expected_tool_choice"), + [ + ( + None, + {"type": "function", "name": "custom_weather"}, + {"type": "function", "name": "custom_weather"}, + ), + ( + {"type": "function", "name": "request_weather"}, + {"type": "function", "name": "custom_weather"}, + {"type": "function", "name": "request_weather"}, + ), + ], +) +def test_prepare_response_request_preserves_custom_tool_choice( + request_tool_choice, + custom_tool_choice, + expected_tool_choice, +): + provider = _make_provider( + { + "responses_web_search": True, + "custom_extra_body": {"tool_choice": custom_tool_choice}, + } + ) + payloads = {"model": "gpt-test", "input": "hi"} + if request_tool_choice is not None: + payloads["tool_choice"] = request_tool_choice + + provider._prepare_response_request(payloads, None) + + assert payloads["tool_choice"] == expected_tool_choice + + +def test_build_response_tools_deduplicates_configured_and_custom_tools(): + provider = _make_provider( + { + "responses_web_search": True, + "responses_file_search_vector_store_ids": ["vs_1"], + "responses_code_interpreter": True, + "responses_image_generation": True, + } + ) + tools = SimpleNamespace( + openai_schema=lambda: [ + { + "type": "function", + "function": { + "name": "weather", + "parameters": {"type": "object"}, + }, + } + ] + ) + + response_tools = provider._build_response_tools( + tools, + [ + {"type": "function", "name": "weather", "parameters": {}}, + {"type": "web_search", "search_context_size": "high"}, + {"type": "file_search", "vector_store_ids": ["vs_custom"]}, + {"type": "code_interpreter", "container": {"type": "auto"}}, + {"type": "image_generation"}, + {"type": "computer_use", "display_width": 1024}, + ], + ) + + assert response_tools == [ + {"type": "function", "name": "weather", "parameters": {"type": "object"}}, + {"type": "web_search", "search_context_size": "medium"}, + {"type": "file_search", "vector_store_ids": ["vs_1"]}, + {"type": "code_interpreter", "container": {"type": "auto"}}, + {"type": "image_generation"}, + {"type": "computer_use", "display_width": 1024}, + ] + + @pytest.mark.asyncio async def test_parse_response_extracts_text_reasoning_usage_and_replay_state(): provider = _make_provider() @@ -333,6 +482,78 @@ async def test_parse_response_extracts_text_reasoning_usage_and_replay_state(): ] +@pytest.mark.asyncio +async def test_parse_response_keeps_web_citations_and_generated_images(): + provider = _make_provider() + response = _make_response( + [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "The answer has a source.", + "annotations": [ + { + "type": "url_citation", + "start_index": 18, + "end_index": 24, + "url": "https://example.com/source", + "title": "Example source", + }, + { + "type": "file_citation", + "file_id": "file_1", + "filename": "notes.pdf", + "index": 0, + }, + ], + } + ], + }, + { + "type": "image_generation_call", + "id": "img_1", + "status": "completed", + "result": "aGVsbG8=", + }, + ] + ) + + result = await provider._parse_response(response, tools=None) + + assert "The answer has a source." in result.completion_text + assert "Example source: https://example.com/source" in result.completion_text + assert "notes.pdf (file_1)" in result.completion_text + assert len(result.result_chain.chain) == 3 + assert result.result_chain.chain[1].type == "Image" + + +@pytest.mark.asyncio +async def test_parse_response_keeps_generated_images_as_non_empty_output(): + provider = _make_provider() + response = _make_response( + [ + { + "type": "image_generation_call", + "id": "img_1", + "status": "completed", + "result": "aGVsbG8=", + } + ] + ) + + result = await provider._parse_response(response, tools=None) + + assert result.completion_text == "[Image]" + assert len(result.result_chain.chain) == 2 + assert result.result_chain.chain[0].type == "Plain" + assert result.result_chain.chain[1].type == "Image" + + @pytest.mark.asyncio async def test_query_stream_yields_semantic_deltas_and_final_response(monkeypatch): provider = _make_provider()