Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/desktop/src/bun/remote/remote-runtime-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ describe("RemoteRuntimeClient", () => {
exaApiKey: "",
anysearchApiKey: "",
zhihuAccessSecret: "",
serplyApiKey: "",
});
}
);
Expand Down
6 changes: 5 additions & 1 deletion apps/desktop/src/components/settings/search-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const PROVIDER_ORDER: readonly SearchProviderId[] = [
"exa",
"anysearch",
"zhihu",
"serply",
];

/** Where each provider's key is issued, for the "Get API key" link. */
Expand All @@ -35,6 +36,7 @@ const FAVICON_DOMAINS: Record<SearchProviderId, string> = {
exa: "exa.ai",
anysearch: "anysearch.com",
zhihu: "zhihu.com",
serply: "serply.io",
};

const GET_KEY_URLS: Record<SearchProviderId, string> = {
Expand All @@ -44,6 +46,7 @@ const GET_KEY_URLS: Record<SearchProviderId, string> = {
exa: "https://dashboard.exa.ai/api-keys",
anysearch: "https://www.anysearch.com/console/api-keys",
zhihu: "https://developer.zhihu.com/",
serply: "https://serply.io",
};

export function SearchPage({ runtimeId }: { runtimeId: RuntimeId }) {
Expand Down Expand Up @@ -237,7 +240,8 @@ function _settingsKeyFor(
| "tavilyApiKey"
| "exaApiKey"
| "anysearchApiKey"
| "zhihuAccessSecret" {
| "zhihuAccessSecret"
| "serplyApiKey" {
return provider === "zhihu" ? "zhihuAccessSecret" : `${provider}ApiKey`;
}

Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,7 @@ const APP_MESSAGES = {
exa: "Exa",
anysearch: "AnySearch",
zhihu: "Zhihu",
serply: "Serply",
},
keys: {
brave: "Brave Search API key",
Expand All @@ -707,6 +708,7 @@ const APP_MESSAGES = {
exa: "Exa API key (optional)",
anysearch: "AnySearch API key (optional)",
zhihu: "Zhihu Access Secret",
serply: "Serply API key",
},
envPrefix: "Values starting with ",
envMiddle: " are read from the environment (e.g. ",
Expand Down Expand Up @@ -1359,6 +1361,7 @@ const APP_MESSAGES = {
exa: "Exa",
anysearch: "AnySearch",
zhihu: "知乎",
serply: "Serply",
},
keys: {
brave: "Brave Search API Key",
Expand All @@ -1367,6 +1370,7 @@ const APP_MESSAGES = {
exa: "Exa API Key(可选)",
anysearch: "AnySearch API Key(可选)",
zhihu: "知乎 Access Secret",
serply: "Serply API Key",
},
envPrefix: "以 ",
envMiddle: " 开头的值会从环境变量读取(例如 ",
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/rpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function createRuntime(): RuntimeClient {
exaApiKey: "",
anysearchApiKey: "",
zhihuAccessSecret: "",
serplyApiKey: "",
}),
getNetworkSettings: () => ({
enabled: false,
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/generator/langgraph/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,12 @@ function _hasLiteralSecret(
if (!search) {
return false;
}
return [search.firecrawlApiKey, search.tavilyApiKey, search.braveApiKey].some(
(v) => v && !v.startsWith("$")
);
return [
search.firecrawlApiKey,
search.tavilyApiKey,
search.braveApiKey,
search.serplyApiKey,
].some((v) => v && !v.startsWith("$"));
}

/**
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/generator/langgraph/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,14 +289,16 @@ function _searchEnvBlock(search: SearchSettings, withValues: boolean): string {
withValues ? _searchKeyLiteral(value) : "";
return `
# Web-search backend for the built-in web_search / web_fetch tools:
# one of firecrawl, tavily, or brave.
# one of firecrawl, tavily, brave, or serply.
SEARCH_PROVIDER=${search.provider}
# Optional — Firecrawl's free tier works without a key.
FIRECRAWL_API_KEY=${key(search.firecrawlApiKey)}
# Required only when SEARCH_PROVIDER=tavily.
TAVILY_API_KEY=${key(search.tavilyApiKey)}
# Required only when SEARCH_PROVIDER=brave.
BRAVE_API_KEY=${key(search.braveApiKey)}
# Required only when SEARCH_PROVIDER=serply.
SERPLY_API_KEY=${key(search.serplyApiKey)}
`;
}

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
FIRECRAWL_BASE_URL = "https://api.firecrawl.dev"
TAVILY_BASE_URL = "https://api.tavily.com"
BRAVE_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search"
SERPLY_SEARCH_URL = "https://api.serply.io/v1/search/"


def _truncate_text(text: str, max_chars: int) -> str:
Expand Down Expand Up @@ -133,14 +134,66 @@ def _brave_search(query: str, limit: int, include_content: bool) -> list[dict]:
return results


def _serply_search(query: str, limit: int, include_content: bool) -> list[dict]:
"""Serply web search, returning Google SERP results. Requires ``SERPLY_API_KEY``."""
api_key = os.environ.get("SERPLY_API_KEY")
if not api_key:
raise RuntimeError("Serply API key is not configured. Set SERPLY_API_KEY.")

# One request reads a single result page and a page carries at most ten
# organic results, so num is clamped rather than silently truncated by the
# API. A page crowded with non-organic blocks can return fewer, so the
# count is a ceiling, not a guarantee.
count = max(1, min(10, limit))
res = requests.get(
SERPLY_SEARCH_URL,
headers={"Accept": "application/json", "X-Api-Key": api_key},
params={"q": query, "num": str(count)},
)

# Serply reports errors as JSON, but it sits behind a CDN that can answer
# with an HTML page instead; parsing blind would bury the status code under
# a decode error.
try:
json_body = res.json()
except ValueError:
json_body = None

if not res.ok:
detail = (json_body or {}).get("detail") or (json_body or {}).get("message")
raise RuntimeError(detail or f"web_search failed: {res.status_code}")
if json_body is None:
raise RuntimeError(
f"web_search failed: Serply returned a non-JSON response ({res.status_code})."
)

results = []
# num is a request hint, so hold the response to the caller's limit too.
for item in (json_body.get("results") or [])[:count]:
description = item.get("description")
results.append(
{
"title": item.get("title") or "Untitled",
"url": item.get("link") or "",
"snippet": description,
# A SERP row carries one snippet and no page body, so
# include_content has no longer text to offer here.
"content": _truncate_text(description, 2_000)
if include_content and description
else None,
}
)
return results


@tool
def web_search(query: str, limit: int = 5, includeContent: bool = False) -> list[dict]:
"""Search the web and return LLM-friendly results.

Search the web and return LLM-friendly results.

The backend is chosen by the ``SEARCH_PROVIDER`` environment variable
(``firecrawl`` by default, or ``tavily``/``brave``).
(``firecrawl`` by default, or ``tavily``/``brave``/``serply``).

Args:
query: The search query string to look up on the web.
Expand All @@ -153,4 +206,6 @@ def web_search(query: str, limit: int = 5, includeContent: bool = False) -> list
return _tavily_search(query, limit, includeContent)
if provider == "brave":
return _brave_search(query, limit, includeContent)
if provider == "serply":
return _serply_search(query, limit, includeContent)
return _firecrawl_search(query, limit, includeContent)
8 changes: 6 additions & 2 deletions packages/core/src/types/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ export type SearchProviderId =
| "tavily"
| "exa"
| "anysearch"
| "zhihu";
| "zhihu"
| "serply";

/**
* User-configured search settings, persisted to `settings/search.json`. API keys
Expand All @@ -15,7 +16,8 @@ export type SearchProviderId =
*
* `exa` and `anysearch` are MCP-backed providers whose keys are optional (both
* work anonymously with lower rate limits); `zhihu` is Zhihu's official MCP
* search and requires an access secret from the Zhihu developer console.
* search and requires an access secret from the Zhihu developer console;
* `serply` returns Google SERP results and requires a key.
*/
export interface SearchSettings {
provider: SearchProviderId;
Expand All @@ -25,6 +27,7 @@ export interface SearchSettings {
exaApiKey: string;
anysearchApiKey: string;
zhihuAccessSecret: string;
serplyApiKey: string;
}

export const DEFAULT_SEARCH_SETTINGS: SearchSettings = {
Expand All @@ -35,4 +38,5 @@ export const DEFAULT_SEARCH_SETTINGS: SearchSettings = {
exaApiKey: "$EXA_API_KEY",
anysearchApiKey: "$ANYSEARCH_API_KEY",
zhihuAccessSecret: "$ZHIHU_ACCESS_SECRET",
serplyApiKey: "$SERPLY_API_KEY",
};
49 changes: 48 additions & 1 deletion packages/core/tests/generator/langgraph/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,20 @@ import path from "node:path";
import {
agentPy,
applyTemplatePy,
envExample,
envFile,
langgraphJson,
makefile,
mcpEnvEntries,
mcpModule,
metaPromptMiddlewarePy,
} from "../../../src/generator/langgraph/templates";
import type { GeneratorMcpServer } from "../../../src/generator/types";
import type {
GeneratorMcpServer,
GeneratorModelInfo,
} from "../../../src/generator/types";
import type { ModelConfig } from "../../../src/types";
import { DEFAULT_SEARCH_SETTINGS } from "../../../src/types/search";

const pythonTmp = mkdtempSync(
path.join(os.tmpdir(), "llm-space-working-directory-python-")
Expand Down Expand Up @@ -139,6 +146,46 @@ describe("mcpEnvEntries", () => {
});
});

describe("envFile / envExample search block", () => {
const model: ModelConfig = { provider: "openai", id: "gpt-4o" };
const info: GeneratorModelInfo = {
name: "gpt-4o",
apiKey: "$OPENAI_API_KEY",
anthropic: false,
deepseekThinking: false,
supportsReasoning: false,
};

test("selects serply and fills in its literal key", () => {
const env = envFile(model, info, {
...DEFAULT_SEARCH_SETTINGS,
provider: "serply",
serplyApiKey: "serply-literal-key",
});
expect(env).toContain("SEARCH_PROVIDER=serply");
expect(env).toContain("SERPLY_API_KEY=serply-literal-key");
});

test("a $VAR serply key is left for the environment to supply", () => {
const env = envFile(model, info, {
...DEFAULT_SEARCH_SETTINGS,
provider: "serply",
});
expect(env).toContain("SERPLY_API_KEY=\n");
});

test("the example file names the var without leaking the key", () => {
const example = envExample(model, info, {
...DEFAULT_SEARCH_SETTINGS,
provider: "serply",
serplyApiKey: "serply-literal-key",
});
expect(example).toContain("# Required only when SEARCH_PROVIDER=serply.");
expect(example).toContain("SERPLY_API_KEY=\n");
expect(example).not.toContain("serply-literal-key");
});
});

describe("agentPy / langgraphJson MCP wiring", () => {
test("with MCP: async make_graph factory awaiting get_mcp_tools", () => {
const py = agentPy([{ module: "read", symbol: "read" }], true, false);
Expand Down
6 changes: 6 additions & 0 deletions packages/runtime/src/search/search-settings-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const VALID_PROVIDERS: readonly SearchProviderId[] = [
"exa",
"anysearch",
"zhihu",
"serply",
];

const SearchSettingsFileSchema = z.object({
Expand All @@ -29,6 +30,7 @@ const SearchSettingsFileSchema = z.object({
exaApiKey: z.string().optional(),
anysearchApiKey: z.string().optional(),
zhihuAccessSecret: z.string().optional(),
serplyApiKey: z.string().optional(),
});

/**
Expand Down Expand Up @@ -106,6 +108,10 @@ export class SearchSettingsManager {
typeof input.zhihuAccessSecret === "string"
? input.zhihuAccessSecret
: DEFAULT_SEARCH_SETTINGS.zhihuAccessSecret,
serplyApiKey:
typeof input.serplyApiKey === "string"
? input.serplyApiKey
: DEFAULT_SEARCH_SETTINGS.serplyApiKey,
};
}
}
Loading