feat(coding-agent): add kimi web search provider
This commit is contained in:
@@ -26,6 +26,7 @@ const PROVIDERS: Array<SearchProviderId | "auto"> = [
|
||||
"exa",
|
||||
"brave",
|
||||
"jina",
|
||||
"kimi",
|
||||
"zai",
|
||||
"gemini",
|
||||
"codex",
|
||||
|
||||
@@ -653,7 +653,7 @@ export const SETTINGS_SCHEMA = {
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
"providers.webSearch": {
|
||||
type: "enum",
|
||||
values: ["auto", "exa", "brave", "jina", "zai", "perplexity", "anthropic"] as const,
|
||||
values: ["auto", "exa", "brave", "jina", "kimi", "zai", "perplexity", "anthropic"] as const,
|
||||
default: "auto",
|
||||
ui: { tab: "services", label: "Web search provider", description: "Provider for web search tool", submenu: true },
|
||||
},
|
||||
|
||||
@@ -164,11 +164,12 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
|
||||
{
|
||||
value: "auto",
|
||||
label: "Auto",
|
||||
description: "Priority: Exa > Brave > Jina > Perplexity > Anthropic > Gemini > Codex > Z.AI > Synthetic",
|
||||
description: "Priority: Exa > Brave > Jina > Kimi > Perplexity > Anthropic > Gemini > Codex > Z.AI > Synthetic",
|
||||
},
|
||||
{ value: "exa", label: "Exa", description: "Requires EXA_API_KEY" },
|
||||
{ value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" },
|
||||
{ value: "jina", label: "Jina", description: "Requires JINA_API_KEY" },
|
||||
{ value: "kimi", label: "Kimi", description: "Requires MOONSHOT_SEARCH_API_KEY or MOONSHOT_API_KEY" },
|
||||
{ value: "perplexity", label: "Perplexity", description: "Requires PERPLEXITY_API_KEY" },
|
||||
{ value: "anthropic", label: "Anthropic", description: "Uses Anthropic web search" },
|
||||
{ value: "zai", label: "Z.AI", description: "Calls Z.AI webSearchPrime MCP" },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Unified Web Search Tool
|
||||
*
|
||||
* Single tool supporting Anthropic, Perplexity, Exa, Brave, Jina, Gemini, Codex, Z.AI, and Synthetic
|
||||
* Single tool supporting Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Z.AI, and Synthetic
|
||||
* providers with provider-specific parameters exposed conditionally.
|
||||
*
|
||||
* When EXA_API_KEY is available, additional specialized tools are exposed:
|
||||
@@ -33,7 +33,7 @@ import { SearchProviderError } from "./types";
|
||||
export const webSearchSchema = Type.Object({
|
||||
query: Type.String({ description: "Search query" }),
|
||||
provider: Type.Optional(
|
||||
StringEnum(["auto", "exa", "brave", "jina", "zai", "anthropic", "perplexity", "gemini", "codex", "synthetic"], {
|
||||
StringEnum(["auto", "exa", "brave", "jina", "kimi", "zai", "anthropic", "perplexity", "gemini", "codex", "synthetic"], {
|
||||
description: "Search provider (default: auto)",
|
||||
}),
|
||||
),
|
||||
@@ -47,7 +47,7 @@ export const webSearchSchema = Type.Object({
|
||||
|
||||
export type SearchParams = {
|
||||
query: string;
|
||||
provider?: "auto" | "exa" | "brave" | "jina" | "zai" | "anthropic" | "perplexity" | "gemini" | "codex" | "synthetic";
|
||||
provider?: "auto" | "exa" | "brave" | "jina" | "kimi" | "zai" | "anthropic" | "perplexity" | "gemini" | "codex" | "synthetic";
|
||||
recency?: "day" | "week" | "month" | "year";
|
||||
limit?: number;
|
||||
/** Maximum output tokens. Defaults to 4096. */
|
||||
@@ -236,7 +236,7 @@ export async function runSearchQuery(
|
||||
/**
|
||||
* Web search tool implementation.
|
||||
*
|
||||
* Supports Anthropic, Perplexity, Exa, Brave, Jina, Gemini, Codex, Z.AI, and Synthetic providers with automatic fallback.
|
||||
* Supports Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Z.AI, and Synthetic providers with automatic fallback.
|
||||
* Session is accepted for interface consistency but not used.
|
||||
*/
|
||||
export class SearchTool implements AgentTool<typeof webSearchSchema, SearchRenderDetails> {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CodexProvider } from "./providers/codex";
|
||||
import { ExaProvider } from "./providers/exa";
|
||||
import { GeminiProvider } from "./providers/gemini";
|
||||
import { JinaProvider } from "./providers/jina";
|
||||
import { KimiProvider } from "./providers/kimi";
|
||||
import { PerplexityProvider } from "./providers/perplexity";
|
||||
import { SyntheticProvider } from "./providers/synthetic";
|
||||
import { ZaiProvider } from "./providers/zai";
|
||||
@@ -18,6 +19,7 @@ const SEARCH_PROVIDERS: Record<SearchProviderId, SearchProvider> = {
|
||||
brave: new BraveProvider(),
|
||||
jina: new JinaProvider(),
|
||||
perplexity: new PerplexityProvider(),
|
||||
kimi: new KimiProvider(),
|
||||
zai: new ZaiProvider(),
|
||||
anthropic: new AnthropicProvider(),
|
||||
gemini: new GeminiProvider(),
|
||||
@@ -33,6 +35,7 @@ const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"codex",
|
||||
"kimi",
|
||||
"zai",
|
||||
"synthetic",
|
||||
];
|
||||
@@ -49,7 +52,7 @@ export function setPreferredSearchProvider(provider: SearchProviderId | "auto"):
|
||||
preferredProvId = provider;
|
||||
}
|
||||
|
||||
/** Determine which providers are configured (priority: Exa → Brave → Jina → Perplexity → Anthropic → Gemini → Codex → Z.AI → Synthetic) */
|
||||
/** Determine which providers are configured (priority: Exa → Brave → Jina → Kimi → Perplexity → Anthropic → Gemini → Codex → Z.AI → Synthetic) */
|
||||
export async function resolveProviderChain(
|
||||
preferredProvider: SearchProviderId | "auto" = preferredProvId,
|
||||
): Promise<SearchProvider[]> {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Kimi Web Search Provider
|
||||
*
|
||||
* Uses Moonshot Kimi Code search API to retrieve web results.
|
||||
* Endpoint: POST https://api.kimi.com/coding/v1/search
|
||||
*/
|
||||
import { getEnvApiKey } from "@oh-my-pi/pi-ai";
|
||||
import { $env } from "@oh-my-pi/pi-utils";
|
||||
import { getAgentDbPath } from "@oh-my-pi/pi-utils/dirs";
|
||||
import { AgentStorage } from "../../../session/agent-storage";
|
||||
import type { SearchResponse, SearchSource } from "../../../web/search/types";
|
||||
import { SearchProviderError } from "../../../web/search/types";
|
||||
import type { SearchParams } from "./base";
|
||||
import { SearchProvider } from "./base";
|
||||
const KIMI_SEARCH_URL = "https://api.kimi.com/coding/v1/search";
|
||||
|
||||
const DEFAULT_NUM_RESULTS = 10;
|
||||
const MAX_NUM_RESULTS = 20;
|
||||
const DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
|
||||
export interface KimiSearchParams {
|
||||
query: string;
|
||||
num_results?: number;
|
||||
include_content?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
interface KimiSearchResult {
|
||||
site_name?: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
snippet?: string;
|
||||
content?: string;
|
||||
date?: string;
|
||||
icon?: string;
|
||||
mime?: string;
|
||||
}
|
||||
|
||||
interface KimiSearchResponse {
|
||||
search_results?: KimiSearchResult[];
|
||||
}
|
||||
|
||||
function asTrimmed(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function resolveBaseUrl(): string {
|
||||
return asTrimmed($env.MOONSHOT_SEARCH_BASE_URL) ?? asTrimmed($env.KIMI_SEARCH_BASE_URL) ?? KIMI_SEARCH_URL;
|
||||
}
|
||||
|
||||
|
||||
function clampNumResults(value: number | undefined): number {
|
||||
if (!value || Number.isNaN(value)) return DEFAULT_NUM_RESULTS;
|
||||
return Math.min(MAX_NUM_RESULTS, Math.max(1, value));
|
||||
}
|
||||
|
||||
function dateToAgeSeconds(dateStr: string | undefined): number | undefined {
|
||||
if (!dateStr) return undefined;
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
return Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Kimi search credentials from environment or agent.db credentials.
|
||||
* Priority: MOONSHOT_SEARCH_API_KEY / KIMI_SEARCH_API_KEY / MOONSHOT_API_KEY, then agent.db providers "moonshot" or "kimi-code".
|
||||
*/
|
||||
async function findApiKey(): Promise<string | null> {
|
||||
const envKey =
|
||||
asTrimmed($env.MOONSHOT_SEARCH_API_KEY) ??
|
||||
asTrimmed($env.KIMI_SEARCH_API_KEY) ??
|
||||
getEnvApiKey("moonshot") ??
|
||||
null;
|
||||
if (envKey) return envKey;
|
||||
|
||||
try {
|
||||
const storage = await AgentStorage.open(getAgentDbPath());
|
||||
for (const provider of ["moonshot", "kimi-code"] as const) {
|
||||
const records = storage.listAuthCredentials(provider);
|
||||
for (const record of records) {
|
||||
const credential = record.credential;
|
||||
if (credential.type === "api_key" && credential.key.trim().length > 0) {
|
||||
return credential.key;
|
||||
}
|
||||
if (credential.type === "oauth" && credential.access.trim().length > 0) {
|
||||
return credential.access;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
async function callKimiSearch(
|
||||
apiKey: string,
|
||||
params: { query: string; limit: number; includeContent: boolean; signal?: AbortSignal },
|
||||
): Promise<{ response: KimiSearchResponse; requestId?: string }> {
|
||||
const response = await fetch(resolveBaseUrl(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text_query: params.query,
|
||||
limit: params.limit,
|
||||
enable_page_crawling: params.includeContent,
|
||||
timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
|
||||
}),
|
||||
signal: params.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new SearchProviderError(
|
||||
"kimi",
|
||||
`Kimi search API error (${response.status}): ${errorText}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as KimiSearchResponse;
|
||||
const requestId = response.headers.get("x-request-id") ?? response.headers.get("x-msh-request-id") ?? undefined;
|
||||
return { response: data, requestId };
|
||||
}
|
||||
|
||||
/** Execute Kimi web search. */
|
||||
export async function searchKimi(params: KimiSearchParams): Promise<SearchResponse> {
|
||||
const apiKey = await findApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"Kimi search credentials not found. Set MOONSHOT_SEARCH_API_KEY, KIMI_SEARCH_API_KEY, MOONSHOT_API_KEY, or login with 'omp /login moonshot'.",
|
||||
);
|
||||
}
|
||||
|
||||
const limit = clampNumResults(params.num_results);
|
||||
const { response, requestId } = await callKimiSearch(apiKey, {
|
||||
query: params.query,
|
||||
limit,
|
||||
includeContent: params.include_content ?? false,
|
||||
signal: params.signal,
|
||||
});
|
||||
const sources: SearchSource[] = [];
|
||||
|
||||
for (const result of response.search_results ?? []) {
|
||||
if (!result.url) continue;
|
||||
const publishedDate = asTrimmed(result.date);
|
||||
const snippet = asTrimmed(result.snippet) ?? asTrimmed(result.content);
|
||||
sources.push({
|
||||
title: asTrimmed(result.title) ?? result.url,
|
||||
url: result.url,
|
||||
snippet,
|
||||
publishedDate,
|
||||
ageSeconds: dateToAgeSeconds(publishedDate),
|
||||
author: asTrimmed(result.site_name),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "kimi",
|
||||
sources: sources.slice(0, limit),
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
/** Search provider for Kimi web search. */
|
||||
export class KimiProvider extends SearchProvider {
|
||||
readonly id = "kimi";
|
||||
readonly label = "Kimi";
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
return !!(await findApiKey());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
search(params: SearchParams): Promise<SearchResponse> {
|
||||
return searchKimi({
|
||||
query: params.query,
|
||||
num_results: params.numSearchResults ?? params.limit,
|
||||
signal: params.signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export type SearchProviderId =
|
||||
| "exa"
|
||||
| "brave"
|
||||
| "jina"
|
||||
| "kimi"
|
||||
| "zai"
|
||||
| "anthropic"
|
||||
| "perplexity"
|
||||
|
||||
Reference in New Issue
Block a user