feat(coding-agent): add kimi web search provider

This commit is contained in:
oglassdev
2026-02-19 05:53:14 +01:00
committed by can1357
parent 13b0a2a0f6
commit 487eb9166c
7 changed files with 210 additions and 7 deletions
@@ -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"