Current section

Files

Jump to
codex_sdk lib codex tools web_search_tool.ex
Raw

lib/codex/tools/web_search_tool.ex

defmodule Codex.Tools.WebSearchTool do
@moduledoc """
Hosted tool for performing web searches.
## Overview
WebSearchTool provides web search functionality with support for multiple
search providers. It can be used standalone or registered in the tool registry.
## Configuration
Requires a search provider to be configured. Supported providers:
* `:tavily` - Tavily Search API (requires `TAVILY_API_KEY`)
* `:serper` - Serper API (requires `SERPER_API_KEY`)
* `:mock` - Mock provider for testing (no API key needed)
* Custom callback via `:searcher` option
## Options
Options can be passed during registration or via context metadata:
* `:provider` - Search provider (default: `:tavily`)
* `:api_key` - API key (or from environment variable)
* `:max_results` - Maximum results to return (default: 10)
* `:searcher` - Custom search callback function (overrides provider)
## Usage
### With Mock Provider (Testing)
{:ok, _} = Codex.Tools.register(Codex.Tools.WebSearchTool,
provider: :mock
)
{:ok, result} = Codex.Tools.invoke("web_search", %{"query" => "elixir"}, %{})
# => %{"count" => 2, "results" => [...]}
### With Tavily Provider
{:ok, _} = Codex.Tools.register(Codex.Tools.WebSearchTool,
provider: :tavily,
api_key: System.get_env("TAVILY_API_KEY")
)
{:ok, result} = Codex.Tools.invoke("web_search",
%{"query" => "Elixir programming", "max_results" => 5},
%{}
)
### With Custom Searcher
searcher = fn args, _ctx, _meta ->
{:ok, %{"count" => 1, "results" => [%{"title" => args["query"]}]}}
end
{:ok, _} = Codex.Tools.register(Codex.Tools.WebSearchTool,
searcher: searcher
)
## Environment Variables
* `TAVILY_API_KEY` - API key for Tavily search provider
* `SERPER_API_KEY` - API key for Serper search provider
"""
@behaviour Codex.Tool
alias Codex.Tools.Hosted
@default_max_results 10
@impl true
def metadata do
%{
name: "web_search",
description: "Search the web for information",
schema: %{
"type" => "object",
"properties" => %{
"query" => %{
"type" => "string",
"description" => "The search query"
},
"max_results" => %{
"type" => "integer",
"description" => "Maximum number of results (default: 10)"
}
},
"required" => ["query"],
"additionalProperties" => false
}
}
end
@impl true
def invoke(args, context) do
metadata = Map.get(context, :metadata, %{})
# Check for custom searcher callback first (for backwards compatibility)
case Hosted.callback(metadata, :searcher) do
fun when is_function(fun) ->
invoke_with_searcher(fun, args, context, metadata)
nil ->
invoke_with_provider(args, context, metadata)
end
end
defp invoke_with_searcher(fun, args, context, metadata) do
case Hosted.safe_call(fun, args, context, metadata) do
{:ok, result} -> {:ok, result}
{:error, reason} -> {:error, reason}
other -> {:ok, other}
end
end
defp invoke_with_provider(args, _context, metadata) do
query = Map.fetch!(args, "query")
max_results = resolve_max_results(args, metadata)
provider = resolve_provider(metadata)
with {:ok, api_key} <- get_api_key(provider, metadata),
{:ok, results} <- search(provider, query, max_results, api_key) do
{:ok, format_results(results)}
end
end
defp resolve_max_results(args, metadata) do
Map.get(args, "max_results") ||
Hosted.metadata_value(metadata, :max_results, @default_max_results)
end
defp resolve_provider(metadata) do
Hosted.metadata_value(metadata, :provider, :tavily)
end
defp get_api_key(:mock, _metadata), do: {:ok, nil}
defp get_api_key(provider, metadata) do
key = Hosted.metadata_value(metadata, :api_key) || get_env_key(provider)
if key do
{:ok, key}
else
{:error, {:missing_api_key, provider}}
end
end
defp get_env_key(:tavily), do: System.get_env("TAVILY_API_KEY")
defp get_env_key(:serper), do: System.get_env("SERPER_API_KEY")
defp get_env_key(_), do: nil
# Mock provider for testing
defp search(:mock, query, max_results, _api_key) do
results = [
%{
title: "Mock Result 1 for #{query}",
url: "https://example.com/1",
snippet: "This is a mock search result for testing.",
score: 0.95
},
%{
title: "Mock Result 2 for #{query}",
url: "https://example.com/2",
snippet: "Another mock result with relevant content.",
score: 0.85
}
]
{:ok, Enum.take(results, max_results)}
end
# Tavily search provider
defp search(:tavily, query, max_results, api_key) do
url = "https://api.tavily.com/search"
body =
Jason.encode!(%{
api_key: api_key,
query: query,
max_results: max_results,
include_answer: false,
include_raw_content: false
})
headers = [{"Content-Type", "application/json"}]
case http_client().post(url, body, headers) do
{:ok, %{status: 200, body: resp_body}} ->
parse_tavily_response(resp_body)
{:ok, %{status: status, body: resp_body}} ->
{:error, {:api_error, status, resp_body}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
end
# Serper search provider
defp search(:serper, query, max_results, api_key) do
url = "https://google.serper.dev/search"
body =
Jason.encode!(%{
q: query,
num: max_results
})
headers = [
{"Content-Type", "application/json"},
{"X-API-KEY", api_key}
]
case http_client().post(url, body, headers) do
{:ok, %{status: 200, body: resp_body}} ->
parse_serper_response(resp_body)
{:ok, %{status: status, body: resp_body}} ->
{:error, {:api_error, status, resp_body}}
{:error, reason} ->
{:error, {:request_failed, reason}}
end
end
defp search(provider, _query, _max_results, _api_key) do
{:error, {:unknown_provider, provider}}
end
defp parse_tavily_response(body) when is_binary(body) do
case Jason.decode(body) do
{:ok, decoded} ->
parse_tavily_response(decoded)
{:error, reason} ->
{:error, {:parse_error, reason}}
end
end
defp parse_tavily_response(%{"results" => results}) when is_list(results) do
parsed =
Enum.map(results, fn r ->
%{
title: r["title"],
url: r["url"],
snippet: r["content"],
score: r["score"]
}
end)
{:ok, parsed}
end
defp parse_tavily_response(response) do
{:error, {:unexpected_response, response}}
end
defp parse_serper_response(body) when is_binary(body) do
case Jason.decode(body) do
{:ok, decoded} ->
parse_serper_response(decoded)
{:error, reason} ->
{:error, {:parse_error, reason}}
end
end
defp parse_serper_response(%{"organic" => results}) when is_list(results) do
parsed =
Enum.map(results, fn r ->
%{
title: r["title"],
url: r["link"],
snippet: r["snippet"],
score: nil
}
end)
{:ok, parsed}
end
defp parse_serper_response(response) do
{:error, {:unexpected_response, response}}
end
defp format_results(results) do
%{
"count" => length(results),
"results" =>
Enum.map(results, fn r ->
result = %{
"title" => r.title,
"url" => r.url,
"snippet" => r.snippet
}
if r.score do
Map.put(result, "score", r.score)
else
result
end
end)
}
end
defp http_client do
Application.get_env(:codex_sdk, :http_client, Codex.HTTPClient)
end
end