"""Tests for the unified ``web_search`` built-in tool.""" from __future__ import annotations import json from unittest.mock import MagicMock, patch import httpx import pytest from omnigent.tools.base import ToolContext from omnigent.tools.builtins import get_builtin_tool from omnigent.tools.builtins.web_search import WebSearchTool from omnigent.tools.builtins.web_search_keenable import ( _resolve_max_results as _resolve_max_results_keenable, ) from omnigent.tools.builtins.web_search_nimble import _resolve_max_results from omnigent.tools.builtins.web_search_tavily import ( _resolve_max_results as _resolve_max_results_tavily, ) # ── Registry ───────────────────────────────────────── def test_get_builtin_tool_returns_web_search() -> None: """``get_builtin_tool("web_search")`` returns a WebSearchTool.""" tool = get_builtin_tool("web_search") assert isinstance(tool, WebSearchTool), f"Expected WebSearchTool, got {type(tool).__name__}." def test_get_builtin_tool_unknown_returns_none() -> None: """``get_builtin_tool`` returns ``None`` for unregistered names.""" assert get_builtin_tool("nonexistent") is None def test_old_provider_names_not_registered() -> None: """Provider-specific names are removed from the registry.""" for name in ("web_search_openai", "web_search_google", "web_search_perplexity"): assert get_builtin_tool(name) is None, ( f"{name!r} should not be in the registry — use 'web_search' instead." ) # ── OpenAI passthrough mode ───────────────────────── def test_openai_mode_schema_is_passthrough() -> None: """ When llm_provider is 'openai', schema is the native ``web_search_preview`` passthrough (not a function schema). """ tool = WebSearchTool(llm_provider="openai") schema = tool.get_schema() assert schema == {"type": "web_search_preview"}, ( f"Expected passthrough schema for OpenAI, got {schema}." ) def test_openai_mode_invoke_raises(tool_ctx: ToolContext) -> None: """ In OpenAI mode, invoke() raises because execution is server-side. """ tool = WebSearchTool(llm_provider="openai") with pytest.raises(RuntimeError, match="passthrough"): tool.invoke("{}", tool_ctx) # ── Function tool mode (non-OpenAI) ───────────────── def test_non_openai_schema_is_function() -> None: """ For non-OpenAI providers, schema is a standard function schema. """ tool = WebSearchTool(llm_provider="anthropic") schema = tool.get_schema() assert schema["type"] == "function" func = schema["function"] assert func["name"] == "web_search" assert "query" in func["parameters"]["required"] def test_no_provider_defaults_to_function() -> None: """When llm_provider is None, tool defaults to function mode.""" tool = WebSearchTool() schema = tool.get_schema() assert schema["type"] == "function" def test_missing_query_returns_error(tool_ctx: ToolContext) -> None: """Tool returns error when query param is missing.""" tool = WebSearchTool( config={"search_provider": "perplexity", "api_key": "k"}, llm_provider="anthropic", ) result = tool.invoke(json.dumps({}), tool_ctx) assert "query" in result.lower() # ── search_provider: google ────────────────────────── def test_google_backend_via_spec_config(tool_ctx: ToolContext) -> None: """ With search_provider=google and credentials in spec config, the tool delegates to Google Custom Search. """ fake_response = MagicMock() fake_response.json.return_value = { "items": [ { "title": "Python Docs", "link": "https://docs.python.org", "snippet": "Welcome to Python.", }, ], } tool = WebSearchTool( config={ "search_provider": "google", "api_key": "spec-key", "engine_id": "spec-engine", }, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_google.httpx.get") as mock_get: mock_get.return_value = fake_response result = tool.invoke(json.dumps({"query": "python"}), tool_ctx) # Google result made it through the unified tool pipeline. assert "1. Python Docs" in result assert "https://docs.python.org" in result def test_google_missing_credentials_returns_error(tool_ctx: ToolContext) -> None: """ With search_provider=google but no api_key/engine_id, returns a clear error (not a crash). """ tool = WebSearchTool( config={"search_provider": "google"}, llm_provider="anthropic", ) result = tool.invoke(json.dumps({"query": "test"}), tool_ctx) # Error message tells the user what's missing. assert "api_key" in result assert "engine_id" in result # ── search_provider: perplexity ────────────────────── def test_perplexity_backend_via_spec_config(tool_ctx: ToolContext) -> None: """ With search_provider=perplexity and api_key in spec config, the tool delegates to Perplexity. """ fake_response = MagicMock() fake_response.json.return_value = { "choices": [ {"message": {"content": "Python is a language."}}, ], "citations": ["https://python.org"], } tool = WebSearchTool( config={ "search_provider": "perplexity", "api_key": "spec-pplx-key", }, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_perplexity.httpx.post") as mock_post: mock_post.return_value = fake_response result = tool.invoke(json.dumps({"query": "python"}), tool_ctx) # Perplexity answer + citation made it through. assert "Python is a language." in result assert "[1] https://python.org" in result def test_perplexity_missing_key_returns_error(tool_ctx: ToolContext) -> None: """ With search_provider=perplexity but no api_key, returns error. """ tool = WebSearchTool( config={"search_provider": "perplexity"}, llm_provider="anthropic", ) result = tool.invoke(json.dumps({"query": "test"}), tool_ctx) assert "api_key" in result # ── search_provider: nimble ────────────────────────── def test_nimble_backend_via_spec_config(tool_ctx: ToolContext) -> None: """ With search_provider=nimble and api_key in spec config, the tool delegates to Nimble AI web search. """ fake_response = MagicMock() fake_response.json.return_value = { "results": [ { "title": "Nimble Docs", "url": "https://docs.nimbleway.com", "description": "Web data platform.", }, ], "answer": None, "total_results": 1, } tool = WebSearchTool( config={ "search_provider": "nimble", "api_key": "spec-nimble-key", }, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_nimble.httpx.post") as mock_post: mock_post.return_value = fake_response result = tool.invoke(json.dumps({"query": "nimble"}), tool_ctx) # Nimble result list made it through the unified tool pipeline. assert "1. Nimble Docs" in result assert "https://docs.nimbleway.com" in result assert "Web data platform." in result def test_nimble_answer_shown_first_when_present(tool_ctx: ToolContext) -> None: """ A non-null ``answer`` is shown before the result list. """ fake_response = MagicMock() fake_response.json.return_value = { "answer": "Nimble is a web data platform.", "results": [ {"title": "Home", "url": "https://nimbleway.com", "description": "..."}, ], } tool = WebSearchTool( config={"search_provider": "nimble", "api_key": "k"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_nimble.httpx.post") as mock_post: mock_post.return_value = fake_response result = tool.invoke(json.dumps({"query": "nimble"}), tool_ctx) assert result.startswith("Nimble is a web data platform.") assert "1. Home" in result def test_nimble_missing_key_returns_error(tool_ctx: ToolContext) -> None: """ With search_provider=nimble but no api_key, returns error. """ tool = WebSearchTool( config={"search_provider": "nimble"}, llm_provider="anthropic", ) result = tool.invoke(json.dumps({"query": "test"}), tool_ctx) assert "api_key" in result def test_nimble_spec_config_used_in_http_call(tool_ctx: ToolContext) -> None: """ api_key from spec config is sent as a Bearer header, and the request body carries query / max_results / search_depth. """ fake_response = MagicMock() fake_response.json.return_value = {"results": []} tool = WebSearchTool( config={ "search_provider": "nimble", "api_key": "spec-nimble", "max_results": "7", }, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_nimble.httpx.post") as mock_post: mock_post.return_value = fake_response tool.invoke(json.dumps({"query": "test"}), tool_ctx) headers = mock_post.call_args.kwargs["headers"] assert headers["Authorization"] == "Bearer spec-nimble", ( f"Expected spec config api_key in header, got {headers['Authorization']!r}" ) body = mock_post.call_args.kwargs["json"] assert body["query"] == "test" # max_results comes from config as a str ("7") and must be coerced to int. assert body["max_results"] == 7, f"Expected int 7, got {body['max_results']!r}" # Default tier is 'lite'. assert body["search_depth"] == "lite" def test_nimble_sends_x_client_source_header(tool_ctx: ToolContext) -> None: """Every request carries the ``X-Client-Source`` header identifying Omnigent.""" fake_response = MagicMock() fake_response.json.return_value = {"results": []} tool = WebSearchTool( config={"search_provider": "nimble", "api_key": "spec-nimble"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_nimble.httpx.post") as mock_post: mock_post.return_value = fake_response tool.invoke(json.dumps({"query": "test"}), tool_ctx) headers = mock_post.call_args.kwargs["headers"] assert headers["X-Client-Source"] == "omnigent", ( f"Expected X-Client-Source 'omnigent', got {headers.get('X-Client-Source')!r}" ) def test_nimble_http_error_returns_error_string(tool_ctx: ToolContext) -> None: """An HTTP error (e.g. 401) is returned as a string, never raised.""" fake_response = MagicMock() fake_response.status_code = 401 tool = WebSearchTool( config={"search_provider": "nimble", "api_key": "k"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_nimble.httpx.post") as mock_post: mock_post.side_effect = httpx.HTTPStatusError( "401", request=MagicMock(), response=fake_response ) result = tool.invoke(json.dumps({"query": "test"}), tool_ctx) assert "Nimble search error" in result assert "401" in result def test_nimble_answer_kept_when_no_results(tool_ctx: ToolContext) -> None: """A non-null ``answer`` is returned even when ``results`` is empty.""" fake_response = MagicMock() fake_response.json.return_value = {"answer": "Direct answer.", "results": []} tool = WebSearchTool( config={"search_provider": "nimble", "api_key": "k"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_nimble.httpx.post") as mock_post: mock_post.return_value = fake_response result = tool.invoke(json.dumps({"query": "test"}), tool_ctx) assert result == "Direct answer.", f"Answer must not be dropped, got {result!r}" def test_nimble_rejects_unsupported_search_depth(tool_ctx: ToolContext) -> None: """An unsupported ``search_depth`` is rejected with a clear error, no HTTP call.""" tool = WebSearchTool( config={"search_provider": "nimble", "api_key": "k", "search_depth": "fast"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_nimble.httpx.post") as mock_post: result = tool.invoke(json.dumps({"query": "test"}), tool_ctx) assert "search_depth" in result assert mock_post.call_count == 0, "Must not call the API for an invalid search_depth." def test_nimble_max_results_clamped() -> None: """``max_results`` is coerced + clamped to Nimble's 1-100 range; junk → default.""" assert _resolve_max_results({}) == 5 # missing → default assert _resolve_max_results({"max_results": "0"}) == 1 # below min → clamped up assert _resolve_max_results({"max_results": "500"}) == 100 # above max → clamped down assert _resolve_max_results({"max_results": "abc"}) == 5 # non-numeric → default # ── search_provider: tavily ────────────────────────── def test_tavily_backend_via_spec_config(tool_ctx: ToolContext) -> None: """ With search_provider=tavily and api_key in spec config, the tool delegates to Tavily web search. """ fake_response = MagicMock() fake_response.json.return_value = { "results": [ { "title": "Tavily Docs", "url": "https://docs.tavily.com", "content": "Search API for agents.", }, ], "answer": None, } tool = WebSearchTool( config={ "search_provider": "tavily", "api_key": "spec-tavily-key", }, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_tavily.httpx.post") as mock_post: mock_post.return_value = fake_response result = tool.invoke(json.dumps({"query": "tavily"}), tool_ctx) # Tavily result list made it through the unified tool pipeline. assert "1. Tavily Docs" in result assert "https://docs.tavily.com" in result assert "Search API for agents." in result def test_tavily_answer_shown_first_when_present(tool_ctx: ToolContext) -> None: """A non-null ``answer`` is shown before the result list.""" fake_response = MagicMock() fake_response.json.return_value = { "answer": "Tavily is a search API for AI agents.", "results": [ {"title": "Home", "url": "https://tavily.com", "content": "..."}, ], } tool = WebSearchTool( config={"search_provider": "tavily", "api_key": "k"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_tavily.httpx.post") as mock_post: mock_post.return_value = fake_response result = tool.invoke(json.dumps({"query": "tavily"}), tool_ctx) assert result.startswith("Tavily is a search API for AI agents.") assert "1. Home" in result def test_tavily_missing_key_returns_error(tool_ctx: ToolContext) -> None: """With search_provider=tavily but no api_key, returns error.""" tool = WebSearchTool( config={"search_provider": "tavily"}, llm_provider="anthropic", ) result = tool.invoke(json.dumps({"query": "test"}), tool_ctx) assert "api_key" in result def test_tavily_spec_config_used_in_http_call(tool_ctx: ToolContext) -> None: """ api_key from spec config is sent as a Bearer header, and the request body carries query / max_results / search_depth. """ fake_response = MagicMock() fake_response.json.return_value = {"results": []} tool = WebSearchTool( config={ "search_provider": "tavily", "api_key": "spec-tavily", "max_results": "7", }, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_tavily.httpx.post") as mock_post: mock_post.return_value = fake_response tool.invoke(json.dumps({"query": "test"}), tool_ctx) headers = mock_post.call_args.kwargs["headers"] assert headers["Authorization"] == "Bearer spec-tavily", ( f"Expected spec config api_key in header, got {headers['Authorization']!r}" ) body = mock_post.call_args.kwargs["json"] assert body["query"] == "test" # max_results comes from config as a str ("7") and must be coerced to int. assert body["max_results"] == 7, f"Expected int 7, got {body['max_results']!r}" # Default tier is 'basic'. assert body["search_depth"] == "basic" def test_tavily_sends_x_client_source_header(tool_ctx: ToolContext) -> None: """Every request carries the ``X-Client-Source`` header identifying Omnigent.""" fake_response = MagicMock() fake_response.json.return_value = {"results": []} tool = WebSearchTool( config={"search_provider": "tavily", "api_key": "spec-tavily"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_tavily.httpx.post") as mock_post: mock_post.return_value = fake_response tool.invoke(json.dumps({"query": "test"}), tool_ctx) headers = mock_post.call_args.kwargs["headers"] assert headers["X-Client-Source"] == "omnigent", ( f"Expected X-Client-Source 'omnigent', got {headers.get('X-Client-Source')!r}" ) def test_tavily_http_error_returns_error_string(tool_ctx: ToolContext) -> None: """An HTTP error (e.g. 401) is returned as a string, never raised.""" fake_response = MagicMock() fake_response.status_code = 401 tool = WebSearchTool( config={"search_provider": "tavily", "api_key": "k"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_tavily.httpx.post") as mock_post: mock_post.side_effect = httpx.HTTPStatusError( "401", request=MagicMock(), response=fake_response ) result = tool.invoke(json.dumps({"query": "test"}), tool_ctx) assert "Tavily search error" in result assert "401" in result def test_tavily_empty_results_returns_no_results(tool_ctx: ToolContext) -> None: """An empty result list with no answer returns the no-results message.""" fake_response = MagicMock() fake_response.json.return_value = {"results": [], "answer": None} tool = WebSearchTool( config={"search_provider": "tavily", "api_key": "k"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_tavily.httpx.post") as mock_post: mock_post.return_value = fake_response result = tool.invoke(json.dumps({"query": "test"}), tool_ctx) assert result == "No results found." def test_tavily_answer_kept_when_no_results(tool_ctx: ToolContext) -> None: """A non-null ``answer`` is returned even when ``results`` is empty.""" fake_response = MagicMock() fake_response.json.return_value = {"answer": "Direct answer.", "results": []} tool = WebSearchTool( config={"search_provider": "tavily", "api_key": "k"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_tavily.httpx.post") as mock_post: mock_post.return_value = fake_response result = tool.invoke(json.dumps({"query": "test"}), tool_ctx) assert result == "Direct answer.", f"Answer must not be dropped, got {result!r}" def test_tavily_rejects_unsupported_search_depth(tool_ctx: ToolContext) -> None: """An unsupported ``search_depth`` is rejected with a clear error, no HTTP call.""" tool = WebSearchTool( config={"search_provider": "tavily", "api_key": "k", "search_depth": "fast"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_tavily.httpx.post") as mock_post: result = tool.invoke(json.dumps({"query": "test"}), tool_ctx) assert "search_depth" in result assert mock_post.call_count == 0, "Must not call the API for an invalid search_depth." def test_tavily_max_results_clamped() -> None: """``max_results`` is coerced + clamped to Tavily's 1-20 range; junk → default.""" assert _resolve_max_results_tavily({}) == 5 # missing → default assert _resolve_max_results_tavily({"max_results": "0"}) == 1 # below min → clamped up assert _resolve_max_results_tavily({"max_results": "500"}) == 20 # above max → clamped down assert _resolve_max_results_tavily({"max_results": "abc"}) == 5 # non-numeric → default # ── No search_provider set ─────────────────────────── def test_no_search_provider_fails_loudly( tool_ctx: ToolContext, monkeypatch: pytest.MonkeyPatch ) -> None: """ Without ``search_provider``, web_search returns a loud, helpful error naming the available engines rather than silently picking one — so it is always explicit which engine ran (per maintainer review). The DDG backend must not be invoked. """ import omnigent.tools.builtins.web_search_duckduckgo as ddg monkeypatch.setattr( ddg, "_search_duckduckgo", lambda q, c: pytest.fail("must not auto-run DDG") ) tool = WebSearchTool(llm_provider="anthropic") result = tool.invoke(json.dumps({"query": "test query"}), tool_ctx) assert result.startswith("web_search error: no search_provider") # The error names every available engine so the choice is explicit. assert "duckduckgo" in result.lower() assert "google" in result.lower() assert "perplexity" in result.lower() assert "nimble" in result.lower() assert "tavily" in result.lower() assert "keenable" in result.lower() # ── search_provider: keenable ──────────────────────── def test_keenable_backend_via_spec_config(tool_ctx: ToolContext) -> None: """ With search_provider=keenable, the tool delegates to Keenable web search and the result list flows through the unified pipeline. """ fake_response = MagicMock() fake_response.json.return_value = { "results": [ { "title": "Keenable Docs", "url": "https://docs.keenable.ai", "description": "Web search API for AI agents.", }, ], } tool = WebSearchTool( config={"search_provider": "keenable"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_keenable.httpx.post") as mock_post: mock_post.return_value = fake_response result = tool.invoke(json.dumps({"query": "keenable"}), tool_ctx) assert "1. Keenable Docs" in result assert "https://docs.keenable.ai" in result assert "Web search API for AI agents." in result def test_keenable_keyless_by_default(tool_ctx: ToolContext) -> None: """ Without api_key, Keenable hits the keyless public endpoint and sends no auth header — it must NOT error like the other backends do. """ fake_response = MagicMock() fake_response.json.return_value = {"results": []} tool = WebSearchTool( config={"search_provider": "keenable"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_keenable.httpx.post") as mock_post: mock_post.return_value = fake_response result = tool.invoke(json.dumps({"query": "test"}), tool_ctx) url = mock_post.call_args.args[0] headers = mock_post.call_args.kwargs["headers"] assert url.endswith("/v1/search/public"), f"Expected keyless endpoint, got {url!r}" assert "X-API-Key" not in headers assert "api_key" not in result # no "missing api_key" error def test_keenable_keyed_uses_x_api_key_and_authed_endpoint(tool_ctx: ToolContext) -> None: """With an api_key, Keenable uses /v1/search and the X-API-Key header.""" fake_response = MagicMock() fake_response.json.return_value = {"results": []} tool = WebSearchTool( config={"search_provider": "keenable", "api_key": "spec-keenable"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_keenable.httpx.post") as mock_post: mock_post.return_value = fake_response tool.invoke(json.dumps({"query": "test"}), tool_ctx) url = mock_post.call_args.args[0] headers = mock_post.call_args.kwargs["headers"] body = mock_post.call_args.kwargs["json"] assert url.endswith("/v1/search"), f"Expected authed endpoint, got {url!r}" assert headers["X-API-Key"] == "spec-keenable" assert body["query"] == "test" assert body["mode"] == "pro" def test_keenable_sends_x_keenable_title_header(tool_ctx: ToolContext) -> None: """Keenable tags traffic with the ``X-Keenable-Title`` attribution header.""" fake_response = MagicMock() fake_response.json.return_value = {"results": []} tool = WebSearchTool( config={"search_provider": "keenable"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_keenable.httpx.post") as mock_post: mock_post.return_value = fake_response tool.invoke(json.dumps({"query": "test"}), tool_ctx) headers = mock_post.call_args.kwargs["headers"] assert headers["X-Keenable-Title"] == "Omnigent" def test_keenable_http_error_returns_error_string(tool_ctx: ToolContext) -> None: """An HTTP error from Keenable is returned as a readable string, not raised.""" fake_response = MagicMock() fake_response.raise_for_status.side_effect = httpx.HTTPStatusError( "boom", request=MagicMock(), response=MagicMock(status_code=500) ) tool = WebSearchTool( config={"search_provider": "keenable"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_keenable.httpx.post") as mock_post: mock_post.return_value = fake_response result = tool.invoke(json.dumps({"query": "test"}), tool_ctx) assert "Keenable search error" in result def test_keenable_empty_results_returns_no_results(tool_ctx: ToolContext) -> None: """An empty result list yields the 'No results found.' message.""" fake_response = MagicMock() fake_response.json.return_value = {"results": []} tool = WebSearchTool( config={"search_provider": "keenable"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_keenable.httpx.post") as mock_post: mock_post.return_value = fake_response result = tool.invoke(json.dumps({"query": "test"}), tool_ctx) assert result == "No results found." def test_keenable_max_results_slices_output(tool_ctx: ToolContext) -> None: """``max_results`` limits how many results are rendered.""" fake_response = MagicMock() fake_response.json.return_value = { "results": [ {"title": f"R{i}", "url": f"https://e.com/{i}", "description": "d"} for i in range(10) ], } tool = WebSearchTool( config={"search_provider": "keenable", "max_results": "2"}, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_keenable.httpx.post") as mock_post: mock_post.return_value = fake_response result = tool.invoke(json.dumps({"query": "test"}), tool_ctx) assert "1. R0" in result assert "2. R1" in result assert "R2" not in result def test_keenable_max_results_clamped() -> None: """``max_results`` is coerced + clamped to a 1-20 range; junk → default.""" assert _resolve_max_results_keenable({}) == 5 # missing → default assert _resolve_max_results_keenable({"max_results": "0"}) == 1 # below min → clamped up assert _resolve_max_results_keenable({"max_results": "500"}) == 20 # above max → clamped down assert _resolve_max_results_keenable({"max_results": "abc"}) == 5 # non-numeric → default # ── Spec config passed through ─────────────────────── def test_google_spec_config_used_in_http_call(tool_ctx: ToolContext) -> None: """ api_key and engine_id from spec config are passed to the Google HTTP call (not from env vars). """ fake_response = MagicMock() fake_response.json.return_value = {"items": []} tool = WebSearchTool( config={ "search_provider": "google", "api_key": "spec-key", "engine_id": "spec-engine", }, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_google.httpx.get") as mock_get: mock_get.return_value = fake_response tool.invoke(json.dumps({"query": "test"}), tool_ctx) params = mock_get.call_args.kwargs["params"] assert params["key"] == "spec-key", f"Expected spec config api_key, got {params['key']!r}" assert params["cx"] == "spec-engine", f"Expected spec config engine_id, got {params['cx']!r}" def test_perplexity_spec_config_used_in_http_call(tool_ctx: ToolContext) -> None: """ api_key from spec config is passed to the Perplexity HTTP call. """ fake_response = MagicMock() fake_response.json.return_value = { "choices": [{"message": {"content": "answer"}}], } tool = WebSearchTool( config={ "search_provider": "perplexity", "api_key": "spec-pplx", }, llm_provider="anthropic", ) with patch("omnigent.tools.builtins.web_search_perplexity.httpx.post") as mock_post: mock_post.return_value = fake_response tool.invoke(json.dumps({"query": "test"}), tool_ctx) headers = mock_post.call_args.kwargs["headers"] assert headers["Authorization"] == "Bearer spec-pplx", ( f"Expected spec config api_key in header, got {headers['Authorization']!r}" ) def test_tool_name_is_web_search() -> None: """Tool name is 'web_search' regardless of mode.""" assert WebSearchTool.name() == "web_search" assert WebSearchTool(llm_provider="openai").name() == "web_search" # ── Async dispatch contract ─────────────────────────────────────────────── def test_non_openai_mode_is_sync_in_sessions_native_mode() -> None: """ ``web_search.is_async()`` returns ``False`` for non-OpenAI mode after the DBOS removal. The previous non-OpenAI path dispatched a ``kind="tool"`` background DBOS workflow per search via ``_dispatch_server_tool_async``; that helper and the workflow were deleted with the durability layer. Until a sessions-native async dispatch surface is wired, ``web_search`` runs through the synchronous ``invoke`` path for every backend. """ tool = WebSearchTool( config={"search_provider": "perplexity", "api_key": "k"}, llm_provider="anthropic", ) assert tool.is_async() is False def test_openai_mode_is_not_async() -> None: """OpenAI passthrough mode should not enter Omnigent async dispatch.""" tool = WebSearchTool(llm_provider="openai") assert tool.is_async() is False