Support multiple web search backends: SearXNG, Staan, and Tavily (#948)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-09-04 22:14:29 +02:00
1 parent 4d8d30e15e
commit 4141e7c504
57 files changed
+3734 -1088

No files matched your search

@@ -0,0 +1,178 @@
using AIStudio.Tools.PluginSystem;
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Tavily;
/// <summary>
/// Searches through Tavily, a search service built for AI agents.
/// </summary>
/// <remarks>
/// This is the backend that asks the least of a user: an account without a credit card, a key
/// copied into the settings, and a thousand searches a month. Tavily can filter by language,
/// by time, and for explicit results, so nothing of a search has to be dropped. What it does
/// not offer is paging: it answers one result list per search and nothing beyond it.
/// </remarks>
public sealed class TavilySearchBackend : IWebSearchBackend
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(TavilySearchBackend).Namespace, nameof(TavilySearchBackend));
private const string SETTINGS_GROUP = "tavily";
private const string API_KEY_SETTING = $"{SETTINGS_GROUP}.apiKey";
private const string SEARCH_DEPTH_SETTING = $"{SETTINGS_GROUP}.searchDepth";
private const string SEARCH_DEPTH_BASIC = "basic";
private const string SEARCH_DEPTH_ADVANCED = "advanced";
private const string DEFAULT_SEARCH_DEPTH = SEARCH_DEPTH_BASIC;
/// <remarks>
/// Tavily knows two faster depths as well, and both silently drop the safe search parameter.
/// A search that quietly ignores a filtering policy is worse than a slower search, so they
/// are not offered.
/// </remarks>
private static readonly string[] SUPPORTED_SEARCH_DEPTHS = [SEARCH_DEPTH_BASIC, SEARCH_DEPTH_ADVANCED];
private const int MAX_RESULTS = 20;
/// <summary>
/// Tavily answers one result list per search and offers no way to ask for the next one.
/// </summary>
private const int MAX_PAGE = 1;
private readonly TavilySearchClient searchClient = new();
public WebSearchBackend Backend => WebSearchBackend.TAVILY;
public string SettingsGroup => SETTINGS_GROUP;
/// <remarks>
/// Every filter is available, and the two offered search depths are what keeps that true:
/// Tavily's faster depths drop the safe search parameter, and a capability claimed here has
/// to hold for every search this backend runs, not just for most of them.
/// </remarks>
public WebSearchCapabilities Capabilities { get; } = new(SupportsSafeSearch: true, SupportsTimeRange: true, SupportsLanguage: true, MaxPage: MAX_PAGE);
public void DeclareSettings(ToolSettingsSchemaBuilder builder) => builder
.InGroup(SETTINGS_GROUP)
.OptionalSecret(API_KEY_SETTING)
.OptionalEnum(SEARCH_DEPTH_SETTING, SUPPORTED_SEARCH_DEPTHS)
.InGroup(string.Empty);
public string GetSettingsGroupLabel() => TB("Tavily");
public IReadOnlyList<ToolSettingsGroupLink> GetSettingsGroupLinks() =>
[
new(TB("Create account"), "https://app.tavily.com"),
new(TB("Usage and billing"), "https://app.tavily.com/billing"),
];
public string GetSettingsFieldLabel(string fieldName) => fieldName switch
{
API_KEY_SETTING => TB("Tavily API Key"),
SEARCH_DEPTH_SETTING => TB("Tavily Search Depth"),
_ => fieldName,
};
public string GetSettingsFieldDescription(string fieldName) => fieldName switch
{
API_KEY_SETTING => TB("Your Tavily API key. It is kept in your operating system's keyring, not in a settings file. Tavily grants 1,000 requests per month without a credit card, which is enough for everyday use."),
SEARCH_DEPTH_SETTING => TB("How thoroughly Tavily searches. A basic search costs one of your monthly requests, an advanced search costs two and looks at more of each page before deciding how well it matches. Basic is the sensible choice unless you notice that results are missing the point."),
_ => string.Empty,
};
public string? GetSettingsFieldDefaultValue(string fieldName) => fieldName switch
{
SEARCH_DEPTH_SETTING => DEFAULT_SEARCH_DEPTH,
_ => null,
};
public bool IsConfigured(IReadOnlyDictionary<string, string> settingsValues) => !string.IsNullOrWhiteSpace(settingsValues.GetValueOrDefault(API_KEY_SETTING));
public bool TryValidateConfiguration(IReadOnlyDictionary<string, string> settingsValues, out string error)
{
error = string.Empty;
//
// The depth is picked from a list in the dialog, but a stored value can come from an
// organization's configuration. One of Tavily's faster depths would be accepted by the
// API and would then ignore the safe search policy without saying so:
//
var searchDepth = settingsValues.GetValueOrDefault(SEARCH_DEPTH_SETTING);
if (string.IsNullOrWhiteSpace(searchDepth) || IsSupportedSearchDepth(searchDepth))
return true;
error = string.Format(TB("The configured Tavily search depth '{0}' is not one this app supports. Please choose one of these: {1}."), searchDepth, string.Join(", ", SUPPORTED_SEARCH_DEPTHS));
return false;
}
public async Task<WebSearchBackendResult> SearchAsync(WebSearchQuery query, IReadOnlyDictionary<string, string> settingsValues, CancellationToken token = default)
{
var apiKey = settingsValues.GetValueOrDefault(API_KEY_SETTING);
if (string.IsNullOrWhiteSpace(apiKey))
throw new InvalidOperationException(TB("A Tavily API key is required."));
var notes = new List<string>();
var language = ResolveLanguage(query.Language, notes);
var searchRequest = new TavilySearchRequest
{
Query = query.Query,
SearchDepth = ResolveSearchDepth(settingsValues),
MaxResults = Math.Min(query.Limit, MAX_RESULTS),
TimeRange = string.IsNullOrWhiteSpace(query.TimeRange) ? null : query.TimeRange,
Language = language,
//
// Without this, Tavily treats the language as a preference and still returns pages
// in other languages. The tool promises to restrict the search, and the other
// backends do restrict, so a language asked for here is a requirement. Anyone who
// would rather have more hits than one language can choose any language instead.
//
FilterByLanguage = language is null ? null : true,
SafeSearch = query.SafeSearch?.ToTavilyValue(),
};
var response = await this.searchClient.SearchAsync(apiKey.Trim(), searchRequest, query.TimeoutSeconds, token);
var hits = response.Results.Select(result => new SearchHit(result.Url, result.Title, result.Content));
var candidates = SearchCandidateCollector.Collect(WebSearchBackend.TAVILY, hits, query.Limit, out var candidateCount);
return new WebSearchBackendResult(WebSearchBackend.TAVILY, candidates, candidateCount, notes);
}
/// <summary>
/// The language code to send, from the language tag the tool asked for.
/// </summary>
/// <remarks>
/// Tavily expects the language alone, so the region of a tag is dropped: Austrian German
/// searches as German. A tag whose language part is not one of the two-letter codes cannot
/// be translated, and Tavily would reject it, so the search runs unrestricted and says so.
/// </remarks>
private static string? ResolveLanguage(string? language, List<string> notes)
{
if (string.IsNullOrWhiteSpace(language) || string.Equals(language, ToolSettingsOptionSources.ANY_LANGUAGE, StringComparison.OrdinalIgnoreCase))
return null;
var languageCode = language.Split('-')[0].Trim().ToLowerInvariant();
if (languageCode.Length is 2 && languageCode.All(char.IsAsciiLetterLower))
return languageCode;
notes.Add($"Tavily could not read '{language}' as a language, so it searched without restricting the language of the results.");
return null;
}
/// <remarks>
/// Always sent rather than left out, so that a change of Tavily's own default cannot change
/// what a search costs here. It also decides whether the safe search policy is honoured at
/// all, which is reason enough not to leave it to the other side.
/// </remarks>
private static string ResolveSearchDepth(IReadOnlyDictionary<string, string> settingsValues)
{
var configuredSearchDepth = settingsValues.GetValueOrDefault(SEARCH_DEPTH_SETTING);
return IsSupportedSearchDepth(configuredSearchDepth) ? configuredSearchDepth!.Trim().ToLowerInvariant() : DEFAULT_SEARCH_DEPTH;
}
private static bool IsSupportedSearchDepth(string? searchDepth) => !string.IsNullOrWhiteSpace(searchDepth) && SUPPORTED_SEARCH_DEPTHS.Contains(searchDepth.Trim(), StringComparer.OrdinalIgnoreCase);
}
@@ -0,0 +1,146 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using AIStudio.Tools.Web;
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Tavily;
/// <summary>
/// Talks to Tavily's search API.
/// </summary>
/// <remarks>
/// Nothing here knows the tool's settings or its result shape: the client sends one request,
/// hands back what Tavily answered, and turns a failure into a message that says what to do
/// about it. How a search becomes Tavily's parameters is the backend's part.
/// </remarks>
internal sealed class TavilySearchClient
{
private const string SEARCH_URL = "https://api.tavily.com/search";
private const int MAX_RESPONSE_BYTES = 1024 * 1024;
/// <summary>
/// The month's included requests are used up, or the key has reached its own quota.
/// </summary>
/// <remarks>
/// Not a status code the framework knows, hence the cast. Tavily uses this range to separate
/// an exhausted budget from a rate limit, which is the difference between waiting a moment
/// and waiting until next month.
/// </remarks>
private const HttpStatusCode PLAN_LIMIT_STATUS_CODE = (HttpStatusCode)432;
/// <summary>
/// The spending limit of a pay-as-you-go account is reached.
/// </summary>
private const HttpStatusCode PAY_AS_YOU_GO_LIMIT_STATUS_CODE = (HttpStatusCode)433;
public async Task<TavilySearchResponse> SearchAsync(string apiKey, TavilySearchRequest searchRequest, int timeoutSeconds, CancellationToken token)
{
try
{
return await SearchInternalAsync(apiKey, searchRequest, timeoutSeconds, token);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception exception) when (exception is HttpRequestException or TimeoutException or InvalidOperationException or JsonException)
{
//
// The reason has to travel with the message. It reaches the user through the tool
// trace and the model through the tool result, and neither can act on "it failed":
// a rejected key, an exhausted budget, and a rate limit all need different answers.
//
throw new InvalidOperationException($"The Tavily search request failed: {exception.Message}", exception);
}
}
private static async Task<TavilySearchResponse> SearchInternalAsync(string apiKey, TavilySearchRequest searchRequest, int timeoutSeconds, CancellationToken token)
{
var searchUri = new Uri(SEARCH_URL);
//
// Tavily is a public service on the internet, so its certificate has to come from a root
// the system trusts. Custom roots exist for a self-hosted search instance behind a
// company's own certificate authority, which this is not:
//
using var httpClient = ExternalHttpClientTimeout.CreateHttpClient(searchUri, ExternalHttpTrustPolicy.SYSTEM_TRUST_ONLY);
httpClient.Timeout = Timeout.InfiniteTimeSpan;
using var request = new HttpRequestMessage(HttpMethod.Post, searchUri);
request.Content = JsonContent.Create(searchRequest, options: WebSearchJson.OPTIONS);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(token);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds));
using var response = await SendAsync(httpClient, request, timeoutCts.Token, timeoutSeconds, token);
var responseBody = await HttpContentReader.ReadAsStringWithLimitAsync(response.Content, MAX_RESPONSE_BYTES, timeoutCts.Token);
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException(BuildStatusCodeMessage(response.StatusCode, responseBody));
TavilySearchResponse? searchResponse;
try
{
searchResponse = JsonSerializer.Deserialize<TavilySearchResponse>(responseBody, WebSearchJson.OPTIONS);
}
catch (JsonException exception)
{
throw new InvalidOperationException($"The Tavily response was not valid JSON: {exception.Message}", exception);
}
if (searchResponse is null)
throw new InvalidOperationException("Tavily answered with an empty response body.");
return searchResponse;
}
/// <summary>
/// What a refused request means, in words the user and the model can act on.
/// </summary>
/// <remarks>
/// An exhausted budget is what makes these hints worth having: it is the expected end of the
/// free requests of a month, and without the hint it would read as a broken search service
/// and send the user looking for a fault that is not there.
/// </remarks>
private static string BuildStatusCodeMessage(HttpStatusCode statusCode, string responseBody)
{
var statusHint = statusCode switch
{
HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => " Tavily refused the API key. Check whether the key is complete and still active.",
PLAN_LIMIT_STATUS_CODE => " The included Tavily requests of this month are used up, or this API key has reached the quota set for it. Searching works again next month, or with a higher plan.",
PAY_AS_YOU_GO_LIMIT_STATUS_CODE => " The spending limit of the Tavily account is reached. Raising it in the Tavily account allows searching again.",
HttpStatusCode.TooManyRequests => " Tavily rate-limits this API key. Wait a moment before searching again.",
HttpStatusCode.BadRequest => " Tavily rejected the parameters of the request.",
_ => string.Empty,
};
return $"Tavily answered with status code {(int)statusCode} ({statusCode}).{statusHint}{SearchResponseExcerpt.CreateDetails(responseBody)}";
}
/// <remarks>
/// Two cancellation tokens, so one of them cannot be the last parameter: the request token
/// carries the search timeout, while the caller token says the user gave up. Telling them
/// apart is what turns a cancellation into either a timeout message or a silent abort.
/// </remarks>
private static async Task<HttpResponseMessage> SendAsync(
HttpClient httpClient,
HttpRequestMessage request,
CancellationToken requestToken,
int timeoutSeconds,
CancellationToken callerToken)
{
try
{
return await httpClient.SendAsync(request, requestToken);
}
catch (OperationCanceledException) when (!callerToken.IsCancellationRequested)
{
throw new TimeoutException($"The Tavily request timed out after {timeoutSeconds} seconds.");
}
catch (HttpRequestException exception)
{
throw new InvalidOperationException($"The Tavily request failed: {exception.Message}", exception);
}
}
}
@@ -0,0 +1,44 @@
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Tavily;
/// <summary>
/// The body of one Tavily search request.
/// </summary>
/// <remarks>
/// Only what this tool sends is declared. Tavily can also return an answer written by a model
/// and the raw content of every hit, both of which cost extra credits and would bypass this
/// tool's own page reader and its prompt injection filtering.
/// </remarks>
internal sealed record TavilySearchRequest
{
public required string Query { get; init; }
/// <summary>
/// How thoroughly to search. A basic search costs one credit, an advanced one costs two.
/// </summary>
public required string SearchDepth { get; init; }
/// <summary>
/// The most hits to return, at most 20.
/// </summary>
public int? MaxResults { get; init; }
/// <summary>
/// How far back to look: day, week, month, or year, or null for no restriction.
/// </summary>
public string? TimeRange { get; init; }
/// <summary>
/// The language to search in, as an ISO 639-1 code, or null for no restriction.
/// </summary>
public string? Language { get; init; }
/// <summary>
/// Whether the language is a requirement rather than a preference. Needs the language field.
/// </summary>
public bool? FilterByLanguage { get; init; }
/// <summary>
/// Whether to filter explicit results or null to leave the decision to Tavily.
/// </summary>
public bool? SafeSearch { get; init; }
}
@@ -0,0 +1,14 @@
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Tavily;
/// <summary>
/// What Tavily answers to a search.
/// </summary>
/// <remarks>
/// Declared down to what the tool reads. Tavily also returns how long the search took, an
/// identifier for the request, and the fields that were asked for through the parameters this
/// tool does not send.
/// </remarks>
internal sealed record TavilySearchResponse
{
public IReadOnlyList<TavilySearchResult> Results { get; init; } = [];
}
@@ -0,0 +1,22 @@
namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch.Tavily;
/// <summary>
/// One hit of a Tavily search.
/// </summary>
/// <remarks>
/// Tavily returns its hits already ranked and adds the relevance score it ranked them by, plus
/// an identifier and a favicon. Nothing here re-sorts them: unlike a SearXNG instance, Tavily
/// merges no engines whose rankings would have to be weighed against each other. For its
/// general search Tavily reports no publication date.
/// </remarks>
internal sealed record TavilySearchResult
{
public string Title { get; init; } = string.Empty;
public string Url { get; init; } = string.Empty;
/// <summary>
/// The excerpt of the page that matched, which is what other services call a snippet.
/// </summary>
public string Content { get; init; } = string.Empty;
}