Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-tinyfish-search.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions apps/api/src/core/__tests__/schemas-text-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions apps/api/src/core/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,7 @@
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(),
Expand All @@ -613,8 +613,10 @@
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(),
Expand All @@ -627,6 +629,8 @@
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({
Expand Down Expand Up @@ -1107,7 +1111,7 @@
function validateMiniMaxImageRequest(
request: MiniMaxImageRequestShape,
ctx: z.RefinementCtx,
options: { allowSubjectReference: boolean },

Check warning on line 1114 in apps/api/src/core/schemas.ts

View workflow job for this annotation

GitHub Actions / Apps api

File has too many lines (1928). Maximum allowed is 1000
): void {
if (!isMiniMaxImageModel(request.model)) return;

Expand Down
176 changes: 175 additions & 1 deletion apps/api/src/pipeline/surfaces/server-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -1433,6 +1437,146 @@ 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,
billableWebSearchRequests: 0,
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("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",
Expand Down Expand Up @@ -2020,3 +2164,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,
});
});
});
Loading
Loading