From 7fe407297ddcf43e5aaf69669b209c99c6615b97 Mon Sep 17 00:00:00 2001 From: Daniel Butler <72151886+DanielButler1@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:25:08 +0100 Subject: [PATCH 1/6] feat(api): add TinyFish web search engine --- .changeset/add-tinyfish-search.md | 5 + .../pipeline/surfaces/server-tools.test.ts | 92 +++++++++++ .../api/src/pipeline/surfaces/server-tools.ts | 154 +++++++++++++++++- apps/api/src/runtime/env.binding-keys.ts | 2 + apps/api/src/runtime/env.types.ts | 2 + .../v1/guides/server-tools/web-search.mdx | 11 +- apps/docs/v1/guides/tool-calling.mdx | 3 +- 7 files changed, 264 insertions(+), 5 deletions(-) create mode 100644 .changeset/add-tinyfish-search.md diff --git a/.changeset/add-tinyfish-search.md b/.changeset/add-tinyfish-search.md new file mode 100644 index 0000000000..feb87b828c --- /dev/null +++ b/.changeset/add-tinyfish-search.md @@ -0,0 +1,5 @@ +--- +"@phaseo/gateway-api": patch +--- + +Add TinyFish Search as an optional managed `phaseo:web_search` engine with localized, paginated queries, translated domain filters, and free-provider cost handling. diff --git a/apps/api/src/pipeline/surfaces/server-tools.test.ts b/apps/api/src/pipeline/surfaces/server-tools.test.ts index 9f0902cf94..929f1e7b02 100644 --- a/apps/api/src/pipeline/surfaces/server-tools.test.ts +++ b/apps/api/src/pipeline/surfaces/server-tools.test.ts @@ -1433,6 +1433,98 @@ describe("buildServerToolContinuation", () => { }); }); + it("executes configured TinyFish Search calls", async () => { + getBindingsMock.mockReturnValue({ + TINYFISH_API_KEY: "tinyfish_test_key", + TINYFISH_SEARCH_BASE_URL: "https://api.search.tinyfish.ai", + }); + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ + query: "latest AI policy site:example.com -site:reddit.com", + results: [ + { + position: 1, + site_name: "Example", + title: "Example result", + snippet: "A TinyFish search result.", + url: "https://example.com/result", + }, + ], + total_results: 1, + }), { status: 200, headers: { "Content-Type": "application/json" } }), + ); + vi.stubGlobal("fetch", fetchMock); + + try { + const continuation = await buildServerToolContinuation( + { + choices: [{ + message: { + role: "assistant", + content: [], + toolCalls: [{ + id: "call_tinyfish", + name: "phaseo_web_search", + arguments: JSON.stringify({ + query: "latest AI policy", + engine: "tinyfish", + max_results: 1, + allowed_domains: ["example.com"], + excluded_domains: ["reddit.com"], + user_location: { country: "GB" }, + language: "en", + page: 2, + }), + }], + }, + finishReason: "tool_calls", + }], + } as any, + { + enabled: true, + datetimeDefaultTimezones: ["UTC"], + webSearchEnabled: true, + webSearchMaxResults: 5, + webSearchIncludeText: false, + webSearchIncludeHighlights: true, + webFetchEnabled: false, + webFetchMaxChars: 12000, + }, + ); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("https://api.search.tinyfish.ai/"), + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ "X-API-Key": "tinyfish_test_key" }), + }), + ); + const requestUrl = new URL(String(fetchMock.mock.calls[0]?.[0])); + expect(requestUrl.searchParams.get("query")).toBe("latest AI policy (site:example.com) -site:reddit.com"); + expect(requestUrl.searchParams.get("location")).toBe("GB"); + expect(requestUrl.searchParams.get("language")).toBe("en"); + expect(requestUrl.searchParams.get("page")).toBe("2"); + expect(continuation?.usage).toMatchObject({ + webSearchRequests: 1, + webSearchResults: 1, + webSearchExtraResults: 0, + webFetchRequests: 0, + }); + const parsed = JSON.parse(String(continuation?.toolResults[0]?.content)); + expect(parsed).toMatchObject({ + provider: "tinyfish", + engine: "tinyfish", + results: [{ + title: "Example result", + url: "https://example.com/result", + highlights: ["A TinyFish search result."], + }], + }); + } finally { + vi.unstubAllGlobals(); + } + }); + it("executes configured Parallel web search calls", async () => { getBindingsMock.mockReturnValue({ PARALLEL_API_KEY: "parallel_test_key", diff --git a/apps/api/src/pipeline/surfaces/server-tools.ts b/apps/api/src/pipeline/surfaces/server-tools.ts index 86b0a70bee..32324b4eb1 100644 --- a/apps/api/src/pipeline/surfaces/server-tools.ts +++ b/apps/api/src/pipeline/surfaces/server-tools.ts @@ -44,11 +44,12 @@ const DEFAULT_EXA_BASE_URL = "https://api.exa.ai"; const DEFAULT_PARALLEL_BASE_URL = "https://api.parallel.ai"; const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev"; const DEFAULT_PERPLEXITY_BASE_URL = "https://api.perplexity.ai"; +const DEFAULT_TINYFISH_SEARCH_BASE_URL = "https://api.search.tinyfish.ai"; const DEFAULT_WEB_SEARCH_ENGINE = "exa"; const DEFAULT_WEB_FETCH_ENGINE = "direct"; const DEFAULT_IMAGE_GENERATION_MODEL = "openai/gpt-image-2"; -const WEB_SEARCH_ENGINE_VALUES = ["auto", "native", "exa", "firecrawl", "parallel", "perplexity"] as const; +const WEB_SEARCH_ENGINE_VALUES = ["auto", "native", "exa", "firecrawl", "parallel", "perplexity", "tinyfish"] as const; const WEB_FETCH_ENGINE_VALUES = ["auto", "native", "direct", "exa", "firecrawl", "parallel"] as const; const DATETIME_TOOL_DESCRIPTION = @@ -121,6 +122,16 @@ const WEB_SEARCH_TOOL_PARAMETERS = { description: "Approximate user location for engines that support localized search.", additionalProperties: true, }, + language: { + type: "string", + description: "Preferred result language code for search engines that support localized search.", + }, + page: { + type: "integer", + minimum: 0, + maximum: 10, + description: "Result page for search engines that support pagination. TinyFish accepts pages 0 through 10.", + }, }, required: ["query"], additionalProperties: false, @@ -1746,6 +1757,37 @@ function resolvePerplexitySearchConfig(): { apiKey: string; baseUrl: string } | } } +function resolveTinyfishSearchConfig(): { apiKey: string; baseUrl: string } | null { + try { + const bindings = getBindings(); + const apiKey = String(bindings.TINYFISH_API_KEY ?? "").trim(); + if (!apiKey) return null; + const baseUrl = String(bindings.TINYFISH_SEARCH_BASE_URL ?? DEFAULT_TINYFISH_SEARCH_BASE_URL).trim() || DEFAULT_TINYFISH_SEARCH_BASE_URL; + return { apiKey, baseUrl: baseUrl.replace(/\/+$/, "") }; + } catch { + return null; + } +} + +function readTinyfishPage(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 0; + return Math.max(0, Math.min(10, Math.floor(value))); +} + +function buildTinyfishSearchQuery(query: string, allowedDomains: string[], excludedDomains: string[]): string { + const allowed = allowedDomains + .map((domain) => domain.trim().replace(/^\*\./, "")) + .filter(Boolean); + const excluded = excludedDomains + .map((domain) => domain.trim().replace(/^\*\./, "")) + .filter(Boolean); + const operators = [ + ...(allowed.length > 0 ? [`(${allowed.map((domain) => `site:${domain}`).join(" OR ")})`] : []), + ...excluded.map((domain) => `-site:${domain}`), + ]; + return [query, ...operators].join(" "); +} + function hostnameMatchesDomain(hostname: string, domain: string): boolean { const normalizedHost = hostname.toLowerCase(); const normalizedDomain = domain.trim().toLowerCase().replace(/^\*\./, ""); @@ -2770,6 +2812,116 @@ async function executeWebSearchToolCall( const searchContextSize = readWebSearchContextSize(args.search_context_size ?? config.webSearchContextSize ?? "medium"); const maxCharacters = parseWebSearchMaxCharacters(args, config); const { allowedDomains, excludedDomains } = resolveSearchDomains(args, config); + if (engine === "tinyfish") { + const searchConfig = resolveTinyfishSearchConfig(); + if (!searchConfig) { + return { + toolResult: { + toolCallId: call.id, + isError: true, + content: JSON.stringify({ + error: "search_not_configured", + engine, + message: "TinyFish Search is not configured", + }), + }, + webFetchRequests: 0, + webSearchResults: 0, + webSearchExtraResults: 0, + }; + } + + const userLocation = args.user_location && typeof args.user_location === "object" + ? args.user_location as Record + : {}; + const location = toNonEmptyString(userLocation.country ?? userLocation.country_code); + const language = toNonEmptyString(args.language ?? userLocation.language); + const page = readTinyfishPage(args.page); + const searchQuery = buildTinyfishSearchQuery(query, allowedDomains, excludedDomains); + const requestUrl = new URL(searchConfig.baseUrl); + requestUrl.searchParams.set("query", searchQuery); + if (location) requestUrl.searchParams.set("location", location); + if (language) requestUrl.searchParams.set("language", language); + requestUrl.searchParams.set("page", String(page)); + + try { + const response = await fetchWithTimeout(requestUrl.toString(), { + method: "GET", + headers: { + Accept: "application/json", + "X-API-Key": searchConfig.apiKey, + }, + }); + if (!response.ok) { + const failureText = await response.text(); + return { + toolResult: { + toolCallId: call.id, + isError: true, + content: JSON.stringify({ + error: "search_request_failed", + engine, + status: response.status, + message: failureText.slice(0, 1000), + }), + }, + webFetchRequests: 0, + webSearchResults: 0, + webSearchExtraResults: 0, + }; + } + + const json = await response.json() as Record; + const rawResults = Array.isArray(json.results) ? json.results : []; + const normalizedResults = rawResults.slice(0, maxResults).map((result: any) => { + const snippet = toNonEmptyString(result?.snippet) ?? null; + return { + position: typeof result?.position === "number" ? result.position : null, + site_name: toNonEmptyString(result?.site_name) ?? null, + title: toNonEmptyString(result?.title) ?? null, + url: toNonEmptyString(result?.url) ?? null, + snippet, + highlights: includeHighlights && snippet ? [snippet] : [], + text: null, + }; + }); + const webSearchResults = normalizedResults.length; + return { + toolResult: { + toolCallId: call.id, + content: JSON.stringify({ + provider: "tinyfish", + engine, + request_id: null, + query, + search_query: searchQuery, + location, + language, + page, + results: normalizedResults, + }), + }, + webFetchRequests: 0, + webSearchResults, + webSearchExtraResults: 0, + }; + } catch (error) { + return { + toolResult: { + toolCallId: call.id, + isError: true, + content: JSON.stringify({ + error: "search_request_error", + engine, + message: error instanceof Error ? error.message : String(error), + }), + }, + webFetchRequests: 0, + webSearchResults: 0, + webSearchExtraResults: 0, + }; + } + } if (engine === "perplexity") { const searchConfig = resolvePerplexitySearchConfig(); if (!searchConfig) return { toolResult: { toolCallId: call.id, isError: true, content: JSON.stringify({ error: "search_not_configured", engine }) }, webFetchRequests: 0, webSearchResults: 0, webSearchExtraResults: 0 }; diff --git a/apps/api/src/runtime/env.binding-keys.ts b/apps/api/src/runtime/env.binding-keys.ts index 5c5df98d00..4ef33cd608 100644 --- a/apps/api/src/runtime/env.binding-keys.ts +++ b/apps/api/src/runtime/env.binding-keys.ts @@ -266,6 +266,8 @@ export const BINDING_KEYS: Array = [ "FAL_QUEUE_BASE_URL", "PERPLEXITY_API_KEY", "PERPLEXITY_BASE_URL", + "TINYFISH_API_KEY", + "TINYFISH_SEARCH_BASE_URL", "POOLSIDE_API_KEY", "POOLSIDE_BASE_URL", "PHALA_API_KEY", diff --git a/apps/api/src/runtime/env.types.ts b/apps/api/src/runtime/env.types.ts index e1da578b36..35f23e94a2 100644 --- a/apps/api/src/runtime/env.types.ts +++ b/apps/api/src/runtime/env.types.ts @@ -253,6 +253,8 @@ export type GatewayBindings = { FAL_QUEUE_BASE_URL?: string; PERPLEXITY_API_KEY?: string; PERPLEXITY_BASE_URL?: string; + TINYFISH_API_KEY?: string; + TINYFISH_SEARCH_BASE_URL?: string; POOLSIDE_API_KEY?: string; POOLSIDE_BASE_URL?: string; PHALA_API_KEY?: string; diff --git a/apps/docs/v1/guides/server-tools/web-search.mdx b/apps/docs/v1/guides/server-tools/web-search.mdx index 3e1ac0a35b..c811a4ba71 100644 --- a/apps/docs/v1/guides/server-tools/web-search.mdx +++ b/apps/docs/v1/guides/server-tools/web-search.mdx @@ -9,7 +9,7 @@ Use `phaseo:web_search` when the model needs current or source-backed informatio Phaseo returns URLs, titles, snippets, highlights, and optional page text to the model so it can write a grounded response. -Google native web search is not enabled in Phaseo server tools yet. Use managed search or provider-native search on supported OpenAI and Anthropic routes. +TinyFish Search is available as an optional managed engine. Provider-native search remains available through `engine: "native"` on supported routes. ## How it works @@ -58,7 +58,7 @@ curl https://api.phaseo.app/v1/chat/completions \ | Parameter | Type | Default | Description | | --- | --- | --- | --- | -| `engine` | string | `exa` | Search engine: `auto`, `native`, `exa`, `parallel`, `firecrawl`, or `perplexity`. | +| `engine` | string | `exa` | Search engine: `auto`, `native`, `exa`, `parallel`, `firecrawl`, `perplexity`, or `tinyfish`. | | `max_results` | integer | `5` | Maximum results returned per search call. | | `max_total_results` | integer | `10` | Maximum cumulative results across the server-tool loop. | | `max_uses` | integer | `10` | Maximum search calls across the server-tool loop. | @@ -69,6 +69,8 @@ curl https://api.phaseo.app/v1/chat/completions \ | `include_highlights` | boolean | `true` | Include engine-provided highlights/snippets when available. | | `include_text` | boolean | `false` | Include fuller result text when the engine supports it. | | `user_location` | object | none | Optional location hint for engines that support localized search. | +| `language` | string | none | TinyFish language hint, such as `en`. | +| `page` | integer | `0` | TinyFish result page, from `0` through `10`. | ## Engine selection @@ -79,6 +81,7 @@ curl https://api.phaseo.app/v1/chat/completions \ | `parallel` | Uses Parallel search when configured. | | `firecrawl` | Uses Firecrawl search when configured. | | `perplexity` | Uses the first-party Perplexity Search API when configured. Supports ranked results, `search_context_size`, regional search from `user_location.country`, and either allowed or excluded domains. | +| `tinyfish` | Uses TinyFish Search when `TINYFISH_API_KEY` is configured. Supports localized, paginated ranked results and translates `allowed_domains` / `excluded_domains` into search operators. TinyFish Search does not supply full page text; use `phaseo:web_fetch` when fuller content is needed. | | `native` | Converts the declaration to a provider-native web-search tool before the upstream model call when the request surface supports it. | Use `engine: "native"` only in the tool declaration. If a model tries to pass `engine: "native"` inside an already-emitted gateway search call, Phaseo returns a tool error because native tools must be selected before the upstream request is sent. @@ -98,7 +101,9 @@ Use `allowed_domains` when the answer must come from a controlled source set: Use `excluded_domains` when broad web search is allowed but specific domains should be removed from the result set. -Perplexity and Firecrawl accept either `allowed_domains` or `excluded_domains` in a single search call, not both. Perplexity accepts up to 20 domain filters per request and maps excluded domains to its denylist format. +Perplexity and Firecrawl accept either `allowed_domains` or `excluded_domains` in a single search call, not both. Perplexity accepts up to 20 domain filters per request and maps excluded domains to its denylist format. TinyFish translates both arrays to `site:` and `-site:` operators in the query. + +TinyFish Search is free on its published plans, so Phaseo does not add a provider usage charge for `engine: "tinyfish"`; normal Phaseo request and token pricing still applies. ## Responses API diff --git a/apps/docs/v1/guides/tool-calling.mdx b/apps/docs/v1/guides/tool-calling.mdx index 0f9c7cc21a..8e338ac114 100644 --- a/apps/docs/v1/guides/tool-calling.mdx +++ b/apps/docs/v1/guides/tool-calling.mdx @@ -139,7 +139,8 @@ Notes: Notes: - The model supplies the search query when it calls the tool. -- `engine: "auto"` resolves to managed Exa search. `engine: "exa"`, `engine: "parallel"`, and `engine: "firecrawl"` run managed gateway search when the matching provider key is configured. +- `engine: "auto"` resolves to managed Exa search. `engine: "exa"`, `engine: "parallel"`, `engine: "firecrawl"`, and `engine: "tinyfish"` run managed gateway search when the matching provider key is configured. +- TinyFish Search supports localized, paginated ranked results and is free on its published plans; use `language` and `page` in the tool parameters when needed. - `engine: "native"` on `phaseo:web_search` is converted to the provider-native web search tool for the request surface, such as OpenAI `web_search_preview` or Anthropic `web_search_20250305`. - `max_results` caps each search call; `max_total_results` caps cumulative results across the server-tool loop. - Managed search supports `allowed_domains` / `excluded_domains`, `search_context_size`, and `max_characters` where the selected engine exposes matching controls. From 7a7e9756eec1291ecf9171b5c9450cbd3c6d7d96 Mon Sep 17 00:00:00 2001 From: Daniel Butler <72151886+DanielButler1@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:09:38 +0100 Subject: [PATCH 2/6] fix(api): complete TinyFish search contract --- .../__tests__/schemas-text-validation.test.ts | 22 +++++++ apps/api/src/core/schemas.ts | 8 ++- .../pipeline/surfaces/server-tools.test.ts | 37 +++++++++++- .../api/src/pipeline/surfaces/server-tools.ts | 22 ++++++- .../src/pipeline/surfaces/text-generate.ts | 19 ++++-- apps/docs/openapi/v1/openapi.public.yaml | 32 ++++++++++ apps/docs/openapi/v1/openapi.yaml | 32 ++++++++++ packages/sdk/sdk-cpp/src/gen/models.hpp | 3 + packages/sdk/sdk-csharp/src/gen/Models.cs | 9 +++ packages/sdk/sdk-go/src/gen/models.go | 3 + .../sdk-java/src/app/phaseo/gen/Models.java | 3 + packages/sdk/sdk-php/src/gen/Models.php | 6 ++ packages/sdk/sdk-py/src/gen/models.py | 3 + packages/sdk/sdk-ruby/lib/gen/models.rb | 8 ++- packages/sdk/sdk-rust/src/gen/models.rs | 3 + .../sdk/sdk-ts/src/oapi-gen/client/default.ts | 60 +++++++++++++++++++ .../models/AnthropicMessagesRequest.ts | 20 +++++++ .../oapi-gen/models/ChatCompletionsRequest.ts | 20 +++++++ .../models/GatewayWebSearchToolDefinition.ts | 20 +++++++ .../src/oapi-gen/models/ResponsesRequest.ts | 20 +++++++ .../src/oapi-gen/models/TextGenerateTool.ts | 20 +++++++ 21 files changed, 359 insertions(+), 11 deletions(-) diff --git a/apps/api/src/core/__tests__/schemas-text-validation.test.ts b/apps/api/src/core/__tests__/schemas-text-validation.test.ts index c79048ecef..f5119a980d 100644 --- a/apps/api/src/core/__tests__/schemas-text-validation.test.ts +++ b/apps/api/src/core/__tests__/schemas-text-validation.test.ts @@ -99,6 +99,28 @@ describe("text request schema validation", () => { expect(parsed.success).toBe(true); }); + it("accepts TinyFish web search parameters on chat requests", () => { + const parsed = ChatCompletionsSchema.safeParse({ + model: "gpt-4.1", + messages: [{ role: "user", content: "find recent AI news" }], + tools: [{ + type: "phaseo:web_search", + parameters: { + engine: "tinyfish", + language: "en", + page: 2, + }, + }], + }); + + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.tools?.[0]).toMatchObject({ + parameters: { engine: "tinyfish", language: "en", page: 2 }, + }); + } + }); + it("accepts Phaseo web fetch server tool on chat requests", () => { const parsed = ChatCompletionsSchema.safeParse({ model: "gpt-4.1", diff --git a/apps/api/src/core/schemas.ts b/apps/api/src/core/schemas.ts index dc201ea70b..82bcc95f08 100644 --- a/apps/api/src/core/schemas.ts +++ b/apps/api/src/core/schemas.ts @@ -600,7 +600,7 @@ const GatewayDatetimeToolSchema = z.object({ const GatewayWebSearchToolSchema = z.object({ type: z.enum(["phaseo:web_search", "gateway:web_search"]), parameters: z.object({ - engine: z.enum(["auto", "native", "exa", "firecrawl", "parallel", "perplexity"]).optional(), + engine: z.enum(["auto", "native", "exa", "firecrawl", "parallel", "perplexity", "tinyfish"]).optional(), max_results: z.number().int().positive().max(25).optional(), max_total_results: z.number().int().positive().max(100).optional(), search_context_size: z.enum(["low", "medium", "high"]).optional(), @@ -613,8 +613,10 @@ const GatewayWebSearchToolSchema = z.object({ include_text: z.boolean().optional(), include_highlights: z.boolean().optional(), user_location: z.record(z.string(), z.any()).optional(), + language: z.string().optional(), + page: z.number().int().min(0).max(10).optional(), }).optional(), - engine: z.enum(["auto", "native", "exa", "firecrawl", "parallel", "perplexity"]).optional(), + engine: z.enum(["auto", "native", "exa", "firecrawl", "parallel", "perplexity", "tinyfish"]).optional(), max_results: z.number().int().positive().max(25).optional(), max_total_results: z.number().int().positive().max(100).optional(), search_context_size: z.enum(["low", "medium", "high"]).optional(), @@ -627,6 +629,8 @@ const GatewayWebSearchToolSchema = z.object({ include_text: z.boolean().optional(), include_highlights: z.boolean().optional(), user_location: z.record(z.string(), z.any()).optional(), + language: z.string().optional(), + page: z.number().int().min(0).max(10).optional(), }); const GatewayWebFetchToolSchema = z.object({ diff --git a/apps/api/src/pipeline/surfaces/server-tools.test.ts b/apps/api/src/pipeline/surfaces/server-tools.test.ts index 929f1e7b02..1c8c81de65 100644 --- a/apps/api/src/pipeline/surfaces/server-tools.test.ts +++ b/apps/api/src/pipeline/surfaces/server-tools.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { buildServerToolContinuation, prepareServerToolsForTextRequest } from "./server-tools"; +import { + attachServerToolUsageToRawUsage, + buildServerToolContinuation, + prepareServerToolsForTextRequest, +} from "./server-tools"; const getBindingsMock = vi.fn(); @@ -1506,6 +1510,7 @@ describe("buildServerToolContinuation", () => { expect(requestUrl.searchParams.get("page")).toBe("2"); expect(continuation?.usage).toMatchObject({ webSearchRequests: 1, + billableWebSearchRequests: 0, webSearchResults: 1, webSearchExtraResults: 0, webFetchRequests: 0, @@ -2112,3 +2117,33 @@ describe("buildServerToolContinuation", () => { } }); }); + +describe("server tool billing", () => { + it("keeps free TinyFish usage observable without adding a billable search meter", () => { + const usage = attachServerToolUsageToRawUsage( + { prompt_tokens: 10 }, + { + datetimeRequests: 0, + webSearchRequests: 1, + billableWebSearchRequests: 0, + webSearchResults: 3, + webSearchExtraResults: 0, + webFetchRequests: 0, + advisorRequests: 0, + imageGenerationRequests: 0, + applyPatchRequests: 0, + subagentRequests: 0, + fusionRequests: 0, + searchModelsRequests: 0, + }, + ); + + expect(usage).toMatchObject({ + server_tool_use: { + web_search_requests: 1, + web_search_results: 3, + }, + server_tool_web_search_requests: 0, + }); + }); +}); diff --git a/apps/api/src/pipeline/surfaces/server-tools.ts b/apps/api/src/pipeline/surfaces/server-tools.ts index 32324b4eb1..85d2a7bbba 100644 --- a/apps/api/src/pipeline/surfaces/server-tools.ts +++ b/apps/api/src/pipeline/surfaces/server-tools.ts @@ -426,6 +426,7 @@ type PrepareRequestResult = export type ServerToolExecutionMetrics = { datetimeRequests: number; webSearchRequests: number; + billableWebSearchRequests?: number; webSearchResults: number; webSearchExtraResults: number; webFetchRequests: number; @@ -2746,7 +2747,13 @@ async function executeWebSearchToolCall( call: { id: string; arguments: string }, config: ServerToolConfig, remainingResults: number, -): Promise<{ toolResult: IRToolResult; webFetchRequests: number; webSearchResults: number; webSearchExtraResults: number }> { +): Promise<{ + toolResult: IRToolResult; + webFetchRequests: number; + webSearchResults: number; + webSearchExtraResults: number; + billableWebSearchRequests?: number; +}> { const args = parseJsonObject(call.arguments); const query = parseWebSearchQuery(args); if (!query) { @@ -2828,6 +2835,7 @@ async function executeWebSearchToolCall( webFetchRequests: 0, webSearchResults: 0, webSearchExtraResults: 0, + billableWebSearchRequests: 0, }; } @@ -2868,6 +2876,7 @@ async function executeWebSearchToolCall( webFetchRequests: 0, webSearchResults: 0, webSearchExtraResults: 0, + billableWebSearchRequests: 0, }; } @@ -2904,6 +2913,7 @@ async function executeWebSearchToolCall( webFetchRequests: 0, webSearchResults, webSearchExtraResults: 0, + billableWebSearchRequests: 0, }; } catch (error) { return { @@ -2919,6 +2929,7 @@ async function executeWebSearchToolCall( webFetchRequests: 0, webSearchResults: 0, webSearchExtraResults: 0, + billableWebSearchRequests: 0, }; } } @@ -3225,7 +3236,14 @@ export async function buildServerToolContinuation( remainingSearchResults, ); toolResults.push(executed.toolResult); + const webSearchRequestsBefore = usage.webSearchRequests; usage.webSearchRequests += 1; + if (typeof executed.billableWebSearchRequests === "number") { + usage.billableWebSearchRequests = + (usage.billableWebSearchRequests ?? webSearchRequestsBefore) + Math.max(0, executed.billableWebSearchRequests); + } else if (usage.billableWebSearchRequests !== undefined) { + usage.billableWebSearchRequests += 1; + } usage.webSearchResults += Math.max(0, executed.webSearchResults); usage.webSearchExtraResults += Math.max(0, executed.webSearchExtraResults); usage.webFetchRequests += Math.max(0, executed.webFetchRequests); @@ -3716,7 +3734,7 @@ export function attachServerToolUsageToRawUsage( search_models_requests: (Number(existing?.search_models_requests ?? 0) || 0) + Math.max(0, args.searchModelsRequests), }; base.server_tool_web_search_requests = - (Number(base.server_tool_web_search_requests ?? 0) || 0) + Math.max(0, args.webSearchRequests); + (Number(base.server_tool_web_search_requests ?? 0) || 0) + Math.max(0, args.billableWebSearchRequests ?? args.webSearchRequests); base.server_tool_web_search_extra_results = (Number(base.server_tool_web_search_extra_results ?? 0) || 0) + Math.max(0, args.webSearchExtraResults); base.server_tool_web_fetch_requests = diff --git a/apps/api/src/pipeline/surfaces/text-generate.ts b/apps/api/src/pipeline/surfaces/text-generate.ts index ce5224f213..6e9d7a2846 100644 --- a/apps/api/src/pipeline/surfaces/text-generate.ts +++ b/apps/api/src/pipeline/surfaces/text-generate.ts @@ -859,6 +859,7 @@ export async function runTextGeneratePipeline(args: PipelineRunnerArgs): Promise const maxServerToolCalls = Math.min(100, Math.max(1, irForExecution.maxToolCalls ?? 30)); let serverToolRounds = 0; let serverToolCalls = 0; + let billableWebSearchRequests: number | undefined; const serverToolUsage = { datetimeRequests: 0, webSearchRequests: 0, @@ -963,7 +964,14 @@ export async function runTextGeneratePipeline(args: PipelineRunnerArgs): Promise } serverToolRounds += 1; serverToolUsage.datetimeRequests += continuation.usage.datetimeRequests ?? 0; + const webSearchRequestsBefore = serverToolUsage.webSearchRequests; serverToolUsage.webSearchRequests += continuation.usage.webSearchRequests ?? 0; + if (typeof continuation.usage.billableWebSearchRequests === "number") { + billableWebSearchRequests = + (billableWebSearchRequests ?? webSearchRequestsBefore) + continuation.usage.billableWebSearchRequests; + } else if (billableWebSearchRequests !== undefined) { + billableWebSearchRequests += continuation.usage.webSearchRequests ?? 0; + } serverToolUsage.webSearchResults += continuation.usage.webSearchResults ?? 0; serverToolUsage.webSearchExtraResults += continuation.usage.webSearchExtraResults ?? 0; serverToolUsage.webFetchRequests += continuation.usage.webFetchRequests ?? 0; @@ -1156,21 +1164,22 @@ export async function runTextGeneratePipeline(args: PipelineRunnerArgs): Promise serverToolUsage.fusionRequests > 0 || serverToolUsage.searchModelsRequests > 0 ) { - const mergedUsage = attachServerToolUsage(aggregateUsage, { - ...serverToolUsage, - }); + const usageMetrics = billableWebSearchRequests === undefined + ? { ...serverToolUsage } + : { ...serverToolUsage, billableWebSearchRequests }; + const mergedUsage = attachServerToolUsage(aggregateUsage, usageMetrics); if (exec.result.ir) { (exec.result.ir as IRChatResponse).usage = mergedUsage; } exec.result.bill.usage = attachServerToolUsageToRawUsage( (exec.result.bill.usage as Record | undefined) ?? undefined, - { ...serverToolUsage }, + usageMetrics, ); if (exec.result.rawResponse && typeof exec.result.rawResponse === "object") { const rawResponse = { ...(exec.result.rawResponse as Record) }; rawResponse.usage = attachServerToolUsageToRawUsage( rawResponse.usage as Record | undefined, - { ...serverToolUsage }, + usageMetrics, ); exec.result.rawResponse = rawResponse; } diff --git a/apps/docs/openapi/v1/openapi.public.yaml b/apps/docs/openapi/v1/openapi.public.yaml index 0a9a8709f1..1266472b70 100644 --- a/apps/docs/openapi/v1/openapi.public.yaml +++ b/apps/docs/openapi/v1/openapi.public.yaml @@ -11955,10 +11955,26 @@ components: parameters: type: object properties: + engine: + type: string + enum: + - auto + - native + - exa + - firecrawl + - parallel + - perplexity + - tinyfish max_results: type: integer minimum: 1 maximum: 10 + language: + type: string + page: + type: integer + minimum: 0 + maximum: 10 include_text: type: boolean include_highlights: @@ -11968,6 +11984,22 @@ components: type: integer minimum: 1 maximum: 10 + engine: + type: string + enum: + - auto + - native + - exa + - firecrawl + - parallel + - perplexity + - tinyfish + language: + type: string + page: + type: integer + minimum: 0 + maximum: 10 include_text: type: boolean include_highlights: diff --git a/apps/docs/openapi/v1/openapi.yaml b/apps/docs/openapi/v1/openapi.yaml index 4c5d31a737..1c16ab2e87 100644 --- a/apps/docs/openapi/v1/openapi.yaml +++ b/apps/docs/openapi/v1/openapi.yaml @@ -9307,10 +9307,26 @@ components: parameters: type: object properties: + engine: + type: string + enum: + - auto + - native + - exa + - firecrawl + - parallel + - perplexity + - tinyfish max_results: type: integer minimum: 1 maximum: 10 + language: + type: string + page: + type: integer + minimum: 0 + maximum: 10 include_text: type: boolean include_highlights: @@ -9320,6 +9336,22 @@ components: type: integer minimum: 1 maximum: 10 + engine: + type: string + enum: + - auto + - native + - exa + - firecrawl + - parallel + - perplexity + - tinyfish + language: + type: string + page: + type: integer + minimum: 0 + maximum: 10 include_text: type: boolean include_highlights: diff --git a/packages/sdk/sdk-cpp/src/gen/models.hpp b/packages/sdk/sdk-cpp/src/gen/models.hpp index 00c76ec771..137363f0b8 100644 --- a/packages/sdk/sdk-cpp/src/gen/models.hpp +++ b/packages/sdk/sdk-cpp/src/gen/models.hpp @@ -1180,9 +1180,12 @@ struct GatewayWebFetchToolDefinition { }; struct GatewayWebSearchToolDefinition { + std::any engine; std::optional include_highlights; std::optional include_text; + std::string language; std::optional max_results; + std::optional page; std::map parameters; std::any type; }; diff --git a/packages/sdk/sdk-csharp/src/gen/Models.cs b/packages/sdk/sdk-csharp/src/gen/Models.cs index 57b7f1f9d6..20d7a9e09d 100644 --- a/packages/sdk/sdk-csharp/src/gen/Models.cs +++ b/packages/sdk/sdk-csharp/src/gen/Models.cs @@ -2868,15 +2868,24 @@ public sealed class GatewayWebFetchToolDefinition public sealed class GatewayWebSearchToolDefinition { + [JsonPropertyName("engine")] + public string? Engine { get; set; } + [JsonPropertyName("include_highlights")] public bool? IncludeHighlights { get; set; } [JsonPropertyName("include_text")] public bool? IncludeText { get; set; } + [JsonPropertyName("language")] + public string? Language { get; set; } + [JsonPropertyName("max_results")] public long? MaxResults { get; set; } + [JsonPropertyName("page")] + public long? Page { get; set; } + [JsonPropertyName("parameters")] public Dictionary? Parameters { get; set; } diff --git a/packages/sdk/sdk-go/src/gen/models.go b/packages/sdk/sdk-go/src/gen/models.go index 3d1c7aeed9..78c90449ac 100644 --- a/packages/sdk/sdk-go/src/gen/models.go +++ b/packages/sdk/sdk-go/src/gen/models.go @@ -1890,9 +1890,12 @@ type GatewayWebFetchToolDefinition struct { } type GatewayWebSearchToolDefinition struct { + Engine *string `json:"engine,omitempty"` IncludeHighlights *bool `json:"include_highlights,omitempty"` IncludeText *bool `json:"include_text,omitempty"` + Language *string `json:"language,omitempty"` MaxResults *int `json:"max_results,omitempty"` + Page *int `json:"page,omitempty"` Parameters *map[string]interface{} `json:"parameters,omitempty"` Type string `json:"type"` } diff --git a/packages/sdk/sdk-java/src/app/phaseo/gen/Models.java b/packages/sdk/sdk-java/src/app/phaseo/gen/Models.java index c9c80b76fa..5480e578c0 100644 --- a/packages/sdk/sdk-java/src/app/phaseo/gen/Models.java +++ b/packages/sdk/sdk-java/src/app/phaseo/gen/Models.java @@ -1185,9 +1185,12 @@ public static class GatewayWebFetchToolDefinition { } public static class GatewayWebSearchToolDefinition { + public Object engine; public Boolean include_highlights; public Boolean include_text; + public String language; public Integer max_results; + public Integer page; public Object parameters; public Object type; } diff --git a/packages/sdk/sdk-php/src/gen/Models.php b/packages/sdk/sdk-php/src/gen/Models.php index 77cc9b90b8..20cf08c9a4 100644 --- a/packages/sdk/sdk-php/src/gen/Models.php +++ b/packages/sdk/sdk-php/src/gen/Models.php @@ -2085,12 +2085,18 @@ class GatewayWebFetchToolDefinition class GatewayWebSearchToolDefinition { + /** @var string|null */ + public $engine; /** @var bool|null */ public $include_highlights; /** @var bool|null */ public $include_text; + /** @var string|null */ + public $language; /** @var int|null */ public $max_results; + /** @var int|null */ + public $page; /** @var array|null */ public $parameters; /** @var string */ diff --git a/packages/sdk/sdk-py/src/gen/models.py b/packages/sdk/sdk-py/src/gen/models.py index 81e7988f9e..4258acfb79 100644 --- a/packages/sdk/sdk-py/src/gen/models.py +++ b/packages/sdk/sdk-py/src/gen/models.py @@ -1053,9 +1053,12 @@ class GatewayWebFetchToolDefinition(TypedDict): type: Literal["phaseo:web_fetch", "gateway:web_fetch"] class GatewayWebSearchToolDefinition(TypedDict): + engine: NotRequired[Literal["auto", "native", "exa", "firecrawl", "parallel", "perplexity", "tinyfish"]] include_highlights: NotRequired[bool] include_text: NotRequired[bool] + language: NotRequired[str] max_results: NotRequired[int] + page: NotRequired[int] parameters: NotRequired[Dict[str, Any]] type: Literal["phaseo:web_search", "gateway:web_search"] diff --git a/packages/sdk/sdk-ruby/lib/gen/models.rb b/packages/sdk/sdk-ruby/lib/gen/models.rb index ebd6b7ff12..de6358a29d 100644 --- a/packages/sdk/sdk-ruby/lib/gen/models.rb +++ b/packages/sdk/sdk-ruby/lib/gen/models.rb @@ -1697,17 +1697,23 @@ module Gen # @!attribute [rw] type # @return [String] GatewayWebFetchToolDefinition = Struct.new(:max_chars, :parameters, :type, keyword_init: true) + # @!attribute [rw] engine + # @return [String, nil] # @!attribute [rw] include_highlights # @return [Boolean, nil] # @!attribute [rw] include_text # @return [Boolean, nil] + # @!attribute [rw] language + # @return [String, nil] # @!attribute [rw] max_results # @return [Integer, nil] + # @!attribute [rw] page + # @return [Integer, nil] # @!attribute [rw] parameters # @return [Hash{String => Object}, nil] # @!attribute [rw] type # @return [String] - GatewayWebSearchToolDefinition = Struct.new(:include_highlights, :include_text, :max_results, :parameters, :type, keyword_init: true) + GatewayWebSearchToolDefinition = Struct.new(:engine, :include_highlights, :include_text, :language, :max_results, :page, :parameters, :type, keyword_init: true) # @!attribute [rw] app_id # @return [String, nil] # @!attribute [rw] byok diff --git a/packages/sdk/sdk-rust/src/gen/models.rs b/packages/sdk/sdk-rust/src/gen/models.rs index 9115aebb3d..171a1b1a3d 100644 --- a/packages/sdk/sdk-rust/src/gen/models.rs +++ b/packages/sdk/sdk-rust/src/gen/models.rs @@ -1176,9 +1176,12 @@ pub struct GatewayWebFetchToolDefinition { } pub struct GatewayWebSearchToolDefinition { + pub engine: Option, pub include_highlights: Option, pub include_text: Option, + pub language: Option, pub max_results: Option, + pub page: Option, pub parameters: Option>, pub r#type: String, } diff --git a/packages/sdk/sdk-ts/src/oapi-gen/client/default.ts b/packages/sdk/sdk-ts/src/oapi-gen/client/default.ts index d09346e92f..b066e74d27 100644 --- a/packages/sdk/sdk-ts/src/oapi-gen/client/default.ts +++ b/packages/sdk/sdk-ts/src/oapi-gen/client/default.ts @@ -968,13 +968,33 @@ export type CreateAnthropicMessageParams = { type: "phaseo:datetime" | "gateway:datetime"; } | { + engine?: + | "auto" + | "native" + | "exa" + | "firecrawl" + | "parallel" + | "perplexity" + | "tinyfish"; include_highlights?: boolean; include_text?: boolean; + language?: string; max_results?: number; + page?: number; parameters?: { + engine?: + | "auto" + | "native" + | "exa" + | "firecrawl" + | "parallel" + | "perplexity" + | "tinyfish"; include_highlights?: boolean; include_text?: boolean; + language?: string; max_results?: number; + page?: number; }; type: "phaseo:web_search" | "gateway:web_search"; } @@ -2147,13 +2167,33 @@ export type CreateChatCompletionParams = { type: "phaseo:datetime" | "gateway:datetime"; } | { + engine?: + | "auto" + | "native" + | "exa" + | "firecrawl" + | "parallel" + | "perplexity" + | "tinyfish"; include_highlights?: boolean; include_text?: boolean; + language?: string; max_results?: number; + page?: number; parameters?: { + engine?: + | "auto" + | "native" + | "exa" + | "firecrawl" + | "parallel" + | "perplexity" + | "tinyfish"; include_highlights?: boolean; include_text?: boolean; + language?: string; max_results?: number; + page?: number; }; type: "phaseo:web_search" | "gateway:web_search"; } @@ -4869,13 +4909,33 @@ export type CreateResponseParams = { type: "phaseo:datetime" | "gateway:datetime"; } | { + engine?: + | "auto" + | "native" + | "exa" + | "firecrawl" + | "parallel" + | "perplexity" + | "tinyfish"; include_highlights?: boolean; include_text?: boolean; + language?: string; max_results?: number; + page?: number; parameters?: { + engine?: + | "auto" + | "native" + | "exa" + | "firecrawl" + | "parallel" + | "perplexity" + | "tinyfish"; include_highlights?: boolean; include_text?: boolean; + language?: string; max_results?: number; + page?: number; }; type: "phaseo:web_search" | "gateway:web_search"; } diff --git a/packages/sdk/sdk-ts/src/oapi-gen/models/AnthropicMessagesRequest.ts b/packages/sdk/sdk-ts/src/oapi-gen/models/AnthropicMessagesRequest.ts index 7c86980692..6a27ad7438 100644 --- a/packages/sdk/sdk-ts/src/oapi-gen/models/AnthropicMessagesRequest.ts +++ b/packages/sdk/sdk-ts/src/oapi-gen/models/AnthropicMessagesRequest.ts @@ -141,13 +141,33 @@ export interface AnthropicMessagesRequest { type: "phaseo:datetime" | "gateway:datetime"; } | { + engine?: + | "auto" + | "native" + | "exa" + | "firecrawl" + | "parallel" + | "perplexity" + | "tinyfish"; include_highlights?: boolean; include_text?: boolean; + language?: string; max_results?: number; + page?: number; parameters?: { + engine?: + | "auto" + | "native" + | "exa" + | "firecrawl" + | "parallel" + | "perplexity" + | "tinyfish"; include_highlights?: boolean; include_text?: boolean; + language?: string; max_results?: number; + page?: number; }; type: "phaseo:web_search" | "gateway:web_search"; } diff --git a/packages/sdk/sdk-ts/src/oapi-gen/models/ChatCompletionsRequest.ts b/packages/sdk/sdk-ts/src/oapi-gen/models/ChatCompletionsRequest.ts index c2894fe9fa..3221c83c77 100644 --- a/packages/sdk/sdk-ts/src/oapi-gen/models/ChatCompletionsRequest.ts +++ b/packages/sdk/sdk-ts/src/oapi-gen/models/ChatCompletionsRequest.ts @@ -232,13 +232,33 @@ export interface ChatCompletionsRequest { type: "phaseo:datetime" | "gateway:datetime"; } | { + engine?: + | "auto" + | "native" + | "exa" + | "firecrawl" + | "parallel" + | "perplexity" + | "tinyfish"; include_highlights?: boolean; include_text?: boolean; + language?: string; max_results?: number; + page?: number; parameters?: { + engine?: + | "auto" + | "native" + | "exa" + | "firecrawl" + | "parallel" + | "perplexity" + | "tinyfish"; include_highlights?: boolean; include_text?: boolean; + language?: string; max_results?: number; + page?: number; }; type: "phaseo:web_search" | "gateway:web_search"; } diff --git a/packages/sdk/sdk-ts/src/oapi-gen/models/GatewayWebSearchToolDefinition.ts b/packages/sdk/sdk-ts/src/oapi-gen/models/GatewayWebSearchToolDefinition.ts index 5d5663d714..29fa409dd4 100644 --- a/packages/sdk/sdk-ts/src/oapi-gen/models/GatewayWebSearchToolDefinition.ts +++ b/packages/sdk/sdk-ts/src/oapi-gen/models/GatewayWebSearchToolDefinition.ts @@ -3,13 +3,33 @@ * */ export interface GatewayWebSearchToolDefinition { + engine?: + | "auto" + | "native" + | "exa" + | "firecrawl" + | "parallel" + | "perplexity" + | "tinyfish"; include_highlights?: boolean; include_text?: boolean; + language?: string; max_results?: number; + page?: number; parameters?: { + engine?: + | "auto" + | "native" + | "exa" + | "firecrawl" + | "parallel" + | "perplexity" + | "tinyfish"; include_highlights?: boolean; include_text?: boolean; + language?: string; max_results?: number; + page?: number; }; type: "phaseo:web_search" | "gateway:web_search"; } diff --git a/packages/sdk/sdk-ts/src/oapi-gen/models/ResponsesRequest.ts b/packages/sdk/sdk-ts/src/oapi-gen/models/ResponsesRequest.ts index 869c105cb9..00c62dda92 100644 --- a/packages/sdk/sdk-ts/src/oapi-gen/models/ResponsesRequest.ts +++ b/packages/sdk/sdk-ts/src/oapi-gen/models/ResponsesRequest.ts @@ -152,13 +152,33 @@ export interface ResponsesRequest { type: "phaseo:datetime" | "gateway:datetime"; } | { + engine?: + | "auto" + | "native" + | "exa" + | "firecrawl" + | "parallel" + | "perplexity" + | "tinyfish"; include_highlights?: boolean; include_text?: boolean; + language?: string; max_results?: number; + page?: number; parameters?: { + engine?: + | "auto" + | "native" + | "exa" + | "firecrawl" + | "parallel" + | "perplexity" + | "tinyfish"; include_highlights?: boolean; include_text?: boolean; + language?: string; max_results?: number; + page?: number; }; type: "phaseo:web_search" | "gateway:web_search"; } diff --git a/packages/sdk/sdk-ts/src/oapi-gen/models/TextGenerateTool.ts b/packages/sdk/sdk-ts/src/oapi-gen/models/TextGenerateTool.ts index ed7e9e1bb0..447501372e 100644 --- a/packages/sdk/sdk-ts/src/oapi-gen/models/TextGenerateTool.ts +++ b/packages/sdk/sdk-ts/src/oapi-gen/models/TextGenerateTool.ts @@ -16,13 +16,33 @@ export type TextGenerateTool = type: "phaseo:datetime" | "gateway:datetime"; } | { + engine?: + | "auto" + | "native" + | "exa" + | "firecrawl" + | "parallel" + | "perplexity" + | "tinyfish"; include_highlights?: boolean; include_text?: boolean; + language?: string; max_results?: number; + page?: number; parameters?: { + engine?: + | "auto" + | "native" + | "exa" + | "firecrawl" + | "parallel" + | "perplexity" + | "tinyfish"; include_highlights?: boolean; include_text?: boolean; + language?: string; max_results?: number; + page?: number; }; type: "phaseo:web_search" | "gateway:web_search"; } From f6538b37ff5b680a630025f9f2e4faa80a00741e Mon Sep 17 00:00:00 2001 From: Daniel Butler <72151886+DanielButler1@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:52:54 +0100 Subject: [PATCH 3/6] fix(api): harden TinyFish search configuration --- .../pipeline/surfaces/server-tools.test.ts | 47 +++++++++++++++++++ .../api/src/pipeline/surfaces/server-tools.ts | 4 +- apps/docs/openapi/v1/openapi.public.yaml | 4 +- apps/docs/openapi/v1/openapi.yaml | 4 +- 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/api/src/pipeline/surfaces/server-tools.test.ts b/apps/api/src/pipeline/surfaces/server-tools.test.ts index 1c8c81de65..8cba159717 100644 --- a/apps/api/src/pipeline/surfaces/server-tools.test.ts +++ b/apps/api/src/pipeline/surfaces/server-tools.test.ts @@ -1530,6 +1530,53 @@ describe("buildServerToolContinuation", () => { } }); + it("rejects insecure TinyFish Search base URLs", async () => { + getBindingsMock.mockReturnValue({ + TINYFISH_API_KEY: "tinyfish_test_key", + TINYFISH_SEARCH_BASE_URL: "http://search.example.com", + }); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + try { + const continuation = await buildServerToolContinuation( + { + choices: [{ + message: { + role: "assistant", + content: [], + toolCalls: [{ + id: "call_tinyfish_insecure", + name: "phaseo_web_search", + arguments: JSON.stringify({ query: "latest AI news", engine: "tinyfish" }), + }], + }, + finishReason: "tool_calls", + }], + } as any, + { + enabled: true, + datetimeDefaultTimezones: ["UTC"], + webSearchEnabled: true, + webSearchMaxResults: 5, + webSearchIncludeText: false, + webSearchIncludeHighlights: true, + webFetchEnabled: false, + webFetchMaxChars: 12000, + }, + ); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(continuation?.toolResults[0]).toMatchObject({ isError: true }); + expect(JSON.parse(String(continuation?.toolResults[0]?.content))).toMatchObject({ + error: "search_not_configured", + engine: "tinyfish", + }); + } finally { + vi.unstubAllGlobals(); + } + }); + it("executes configured Parallel web search calls", async () => { getBindingsMock.mockReturnValue({ PARALLEL_API_KEY: "parallel_test_key", diff --git a/apps/api/src/pipeline/surfaces/server-tools.ts b/apps/api/src/pipeline/surfaces/server-tools.ts index 85d2a7bbba..9b800e958d 100644 --- a/apps/api/src/pipeline/surfaces/server-tools.ts +++ b/apps/api/src/pipeline/surfaces/server-tools.ts @@ -1764,7 +1764,9 @@ function resolveTinyfishSearchConfig(): { apiKey: string; baseUrl: string } | nu const apiKey = String(bindings.TINYFISH_API_KEY ?? "").trim(); if (!apiKey) return null; const baseUrl = String(bindings.TINYFISH_SEARCH_BASE_URL ?? DEFAULT_TINYFISH_SEARCH_BASE_URL).trim() || DEFAULT_TINYFISH_SEARCH_BASE_URL; - return { apiKey, baseUrl: baseUrl.replace(/\/+$/, "") }; + const parsedBaseUrl = new URL(baseUrl); + if (parsedBaseUrl.protocol !== "https:") return null; + return { apiKey, baseUrl: parsedBaseUrl.toString().replace(/\/+$/, "") }; } catch { return null; } diff --git a/apps/docs/openapi/v1/openapi.public.yaml b/apps/docs/openapi/v1/openapi.public.yaml index 1266472b70..18c253c24f 100644 --- a/apps/docs/openapi/v1/openapi.public.yaml +++ b/apps/docs/openapi/v1/openapi.public.yaml @@ -11968,7 +11968,7 @@ components: max_results: type: integer minimum: 1 - maximum: 10 + maximum: 25 language: type: string page: @@ -11983,7 +11983,7 @@ components: max_results: type: integer minimum: 1 - maximum: 10 + maximum: 25 engine: type: string enum: diff --git a/apps/docs/openapi/v1/openapi.yaml b/apps/docs/openapi/v1/openapi.yaml index 1c16ab2e87..0af12b84ef 100644 --- a/apps/docs/openapi/v1/openapi.yaml +++ b/apps/docs/openapi/v1/openapi.yaml @@ -9320,7 +9320,7 @@ components: max_results: type: integer minimum: 1 - maximum: 10 + maximum: 25 language: type: string page: @@ -9335,7 +9335,7 @@ components: max_results: type: integer minimum: 1 - maximum: 10 + maximum: 25 engine: type: string enum: From d9491e754643fda08cd8927c4992498645eccb02 Mon Sep 17 00:00:00 2001 From: Daniel Butler <72151886+DanielButler1@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:58:18 +0100 Subject: [PATCH 4/6] fix(docs): sync OpenAPI model enum --- apps/docs/openapi/v1/openapi.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/docs/openapi/v1/openapi.yaml b/apps/docs/openapi/v1/openapi.yaml index 0af12b84ef..0ef1bcad1d 100644 --- a/apps/docs/openapi/v1/openapi.yaml +++ b/apps/docs/openapi/v1/openapi.yaml @@ -19741,7 +19741,6 @@ components: - anthropic/claude-fable-5 - anthropic/claude-fable-5.1 - anthropic/claude-haiku-4.5 - - anthropic/claude-mythos-5.1 - anthropic/claude-opus-4 - anthropic/claude-opus-4.1 - anthropic/claude-opus-4.5 From e927494d56b119162b850c582ef43cae5b8ea526 Mon Sep 17 00:00:00 2001 From: Daniel Butler <72151886+DanielButler1@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:14:05 +0100 Subject: [PATCH 5/6] feat(web): add TinyFish logo --- apps/web/public/logos/tinyfish.svg | 33 ++++++++++++++++++++++++++++++ apps/web/src/lib/logos.test.ts | 1 + apps/web/src/lib/logos/index.ts | 1 + apps/web/src/lib/logos/manifest.ts | 3 +++ 4 files changed, 38 insertions(+) create mode 100644 apps/web/public/logos/tinyfish.svg diff --git a/apps/web/public/logos/tinyfish.svg b/apps/web/public/logos/tinyfish.svg new file mode 100644 index 0000000000..7fcb17681a --- /dev/null +++ b/apps/web/public/logos/tinyfish.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/lib/logos.test.ts b/apps/web/src/lib/logos.test.ts index 1f011529ee..ae43037dc4 100644 --- a/apps/web/src/lib/logos.test.ts +++ b/apps/web/src/lib/logos.test.ts @@ -133,6 +133,7 @@ describe("logos", () => { ["io-net", "/logos/ionet.svg"], ["ovhcloud", "/logos/ovhcloud.svg"], ["sap-ai-core", "/logos/sap.svg"], + ["tinyfish", "/logos/tinyfish.svg"], ["zhipuai-coding-plan", "/logos/zhipu.svg"], ] as const) { expect(resolveLogo(id)).toMatchObject({ src }); diff --git a/apps/web/src/lib/logos/index.ts b/apps/web/src/lib/logos/index.ts index b3685b418f..f6fc2ae260 100644 --- a/apps/web/src/lib/logos/index.ts +++ b/apps/web/src/lib/logos/index.ts @@ -72,6 +72,7 @@ const labelOverrides: Partial> = { stepfun: "StepFun", submodel: "SubModel", togetherai: "Together AI", + tinyfish: "TinyFish", wandb: "Weights & Biases", "spacex-ai": "SpaceXAI", "zai": "Z.ai", diff --git a/apps/web/src/lib/logos/manifest.ts b/apps/web/src/lib/logos/manifest.ts index abc0136dec..75c7aa397f 100644 --- a/apps/web/src/lib/logos/manifest.ts +++ b/apps/web/src/lib/logos/manifest.ts @@ -500,6 +500,9 @@ export const logoManifest = { tinker: { color: "/logos/tinker.svg", }, + tinyfish: { + color: "/logos/tinyfish.svg", + }, "thinking-machines": { color: "/logos/thinking-machines.svg", }, From 708650eecb652837463c339da90b07a4cddfd8d1 Mon Sep 17 00:00:00 2001 From: Daniel Butler <72151886+DanielButler1@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:23:29 +0100 Subject: [PATCH 6/6] docs: clarify TinyFish search setup --- .../v1/guides/server-tools/web-search.mdx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/docs/v1/guides/server-tools/web-search.mdx b/apps/docs/v1/guides/server-tools/web-search.mdx index c811a4ba71..e5bf8161e2 100644 --- a/apps/docs/v1/guides/server-tools/web-search.mdx +++ b/apps/docs/v1/guides/server-tools/web-search.mdx @@ -86,6 +86,26 @@ curl https://api.phaseo.app/v1/chat/completions \ Use `engine: "native"` only in the tool declaration. If a model tries to pass `engine: "native"` inside an already-emitted gateway search call, Phaseo returns a tool error because native tools must be selected before the upstream request is sent. +## TinyFish Search + +Enable the engine by configuring `TINYFISH_API_KEY` in your Phaseo gateway runtime secrets, then select it in the tool parameters: + +```json +{ + "type": "phaseo:web_search", + "parameters": { + "engine": "tinyfish", + "language": "en", + "page": 0, + "max_results": 5, + "allowed_domains": ["phaseo.app", "github.com"], + "excluded_domains": ["reddit.com"] + } +} +``` + +`language` provides a locale hint and `page` selects a result page from `0` through `10`. TinyFish supports both domain-filter lists; Phaseo converts them into search operators. Results include URLs, titles, and snippets/highlights. Use `phaseo:web_fetch` when the model needs fuller text from a result. + ## Domain filtering Use `allowed_domains` when the answer must come from a controlled source set: