diff --git a/.changeset/add-langsearch-web-search.md b/.changeset/add-langsearch-web-search.md new file mode 100644 index 0000000000..307f0eebf6 --- /dev/null +++ b/.changeset/add-langsearch-web-search.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add configurable LangSearch web search and optional semantic reranking. Configure it under Settings → Web Search or with `kimi search`. diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 960c707a6c..f29b59eb1f 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -8,6 +8,7 @@ import { registerDoctorCommand } from './sub/doctor'; import { registerExportCommand } from './sub/export'; import { registerLoginCommand } from './sub/login'; import { registerProviderCommand } from './sub/provider'; +import { registerSearchCommand } from './sub/search'; import { registerServerCommand } from './sub/server'; import { registerVisCommand } from './sub/vis'; @@ -88,6 +89,7 @@ export function createProgram( registerExportCommand(program); registerProviderCommand(program); + registerSearchCommand(program); registerAcpCommand(program); registerServerCommand(program); registerLoginCommand(program); diff --git a/apps/kimi-code/src/cli/sub/search.ts b/apps/kimi-code/src/cli/sub/search.ts new file mode 100644 index 0000000000..e5c1182823 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/search.ts @@ -0,0 +1,367 @@ +/** + * `kimi search` sub-command — non-interactive web search backend management. + * + * Mirrors the TUI `/settings` → Web Search flow + * (apps/kimi-code/src/tui/commands/web-search.ts) for users who want to + * inspect or change the LangSearch / rerank configuration without launching + * the TUI. + * + * - `status` Show the active web search backend and rerank status. + * - `set langsearch` Write a `[services.langsearch]` section. + * - `clear langsearch` Remove the `[services.langsearch]` section. + * - `set rerank` Write a `[services.rerank]` section. + * - `clear rerank` Remove the `[services.rerank]` section. + * - `limits` Print the LangSearch tier rate-limit table. + */ + +import { + createKimiHarness, + type KimiConfig, + type KimiHarness, +} from '@moonshot-ai/kimi-code-sdk'; +import type { Command } from 'commander'; + +import { createKimiCodeHostIdentity } from '#/cli/version'; + +interface WritableLike { + write(chunk: string): boolean; +} + +export interface SearchDeps { + readonly getHarness: () => KimiHarness; + readonly stdout: WritableLike; + readonly stderr: WritableLike; + readonly exit: (code: number) => never; +} + +interface SetLangSearchOptions { + readonly apiKey?: string; + readonly tier?: string; + readonly count?: string; +} + +interface SetRerankOptions { + readonly provider?: string; + readonly apiKey?: string; + readonly enabled?: string; +} + +const LANGSEARCH_TIERS = ['free', 'tier1', 'tier2', 'tier3'] as const; +type LangSearchTier = (typeof LANGSEARCH_TIERS)[number]; + +const RERANK_PROVIDERS = ['langsearch'] as const; +type RerankProvider = (typeof RERANK_PROVIDERS)[number]; + +const LANGSEARCH_EXPERIMENTAL_FLAG = 'langsearch-web-search'; +const LANGSEARCH_EXPERIMENTAL_MESSAGE = + 'LangSearch web search is experimental. Enable it in Settings → Experiments or set [experimental].langsearch-web-search = true.\n'; + +interface TierLimit { + readonly qps: number; + readonly qpm: number; + readonly qpd: number; +} + +// Rate limits reflect LangSearch's published per-tier quotas. +const TIER_LIMITS: Record = { + free: { qps: 1, qpm: 60, qpd: 1_000 }, + tier1: { qps: 5, qpm: 200, qpd: 2_000 }, + tier2: { qps: 10, qpm: 500, qpd: 10_000 }, + tier3: { qps: 30, qpm: 2_000, qpd: 100_000 }, +}; + +function isLangSearchTier(value: string): value is LangSearchTier { + return (LANGSEARCH_TIERS as readonly string[]).includes(value); +} + +function isRerankProvider(value: string): value is RerankProvider { + return (RERANK_PROVIDERS as readonly string[]).includes(value); +} + +export async function handleSearchStatus(deps: SearchDeps): Promise { + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + const [config, features] = await Promise.all([ + harness.getConfig(), + harness.getExperimentalFeatures(), + ]); + const services = config.services ?? {}; + const langSearchEnabled = isExperimentalEnabled(features); + const backend = activeBackend(services, langSearchEnabled); + deps.stdout.write(`Web search backend: ${backend}\n`); + const langsearch = services.langsearch; + if (hasValue(langsearch?.apiKey)) { + deps.stdout.write( + `LangSearch: tier=${langsearch?.tier ?? 'free'} count=${String(langsearch?.count ?? 10)}${langSearchEnabled ? '' : ' status=experimental feature disabled'}\n`, + ); + } + const rerank = services.rerank; + if (rerank?.provider !== undefined) { + const hasApiKey = hasValue(rerank.apiKey) || hasValue(services.langsearch?.apiKey); + const status = !langSearchEnabled + ? 'experimental feature disabled' + : rerank.enabled === false + ? 'disabled' + : hasApiKey + ? 'enabled' + : 'missing API key'; + deps.stdout.write(`Rerank: ${status} (provider: ${rerank.provider})\n`); + } else { + deps.stdout.write('Rerank: not configured\n'); + } +} + +export async function handleSearchSetLangSearch( + deps: SearchDeps, + opts: SetLangSearchOptions, +): Promise { + const apiKey = opts.apiKey; + if (apiKey === undefined || apiKey.length === 0) { + deps.stderr.write('Missing API key. Pass --api-key .\n'); + deps.exit(1); + } + + const tier = opts.tier ?? 'free'; + if (!isLangSearchTier(tier)) { + deps.stderr.write( + `Invalid tier "${opts.tier}". Expected one of: ${LANGSEARCH_TIERS.join(', ')}.\n`, + ); + deps.exit(1); + } + + const count = opts.count === undefined ? 10 : parseCount(opts.count, deps); + if (count === undefined) return; + + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + await requireLangSearchExperimental(harness, deps); + + await harness.replaceService('langsearch', { apiKey, tier, count }); + + deps.stdout.write( + `LangSearch configured: tier=${tier} count=${String(count)}\n`, + ); +} + +export async function handleSearchSetRerank( + deps: SearchDeps, + opts: SetRerankOptions, +): Promise { + const provider = opts.provider ?? 'langsearch'; + if (!isRerankProvider(provider)) { + deps.stderr.write( + `Unknown rerank provider "${opts.provider}". Only "langsearch" is supported.\n`, + ); + deps.exit(1); + } + + const enabled = opts.enabled === undefined ? true : parseBool(opts.enabled, deps, '--enabled'); + if (enabled === undefined) return; + + const apiKey = opts.apiKey; + + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + await requireLangSearchExperimental(harness, deps); + const config = await harness.getConfig(); + if (enabled && !hasValue(apiKey) && !hasValue(config.services?.langsearch?.apiKey)) { + deps.stderr.write( + 'Missing API key. Pass --api-key or configure LangSearch web search first.\n', + ); + deps.exit(1); + } + + await harness.replaceService('rerank', { + enabled, + provider, + apiKey: hasValue(apiKey) ? apiKey : undefined, + }); + + deps.stdout.write( + `Rerank configured: provider=${provider} enabled=${String(enabled)}${apiKey && apiKey.length > 0 ? ' api_key=set' : ' api_key=reuse-langsearch'}\n`, + ); +} + +export async function handleSearchClear( + deps: SearchDeps, + provider: string, +): Promise { + const harness = deps.getHarness(); + await harness.ensureConfigFile(); + const config = await harness.getConfig(); + const services = config.services ?? {}; + + if (provider === 'langsearch') { + if (services.langsearch === undefined) { + deps.stdout.write('LangSearch is not configured.\n'); + return; + } + await harness.removeService('langsearch'); + deps.stdout.write('LangSearch web search cleared.\n'); + return; + } + + if (provider === 'rerank') { + if (services.rerank === undefined) { + deps.stdout.write('Rerank is not configured.\n'); + return; + } + await harness.removeService('rerank'); + deps.stdout.write('Rerank configuration cleared.\n'); + return; + } + + deps.stderr.write( + `Unknown provider "${provider}". Use "langsearch" or "rerank".\n`, + ); + deps.exit(1); +} + +export function handleSearchLimits(deps: SearchDeps): void { + deps.stdout.write('LangSearch tier rate limits:\n\n'); + deps.stdout.write(' tier qps qpm qpd\n'); + for (const tier of LANGSEARCH_TIERS) { + const limit = TIER_LIMITS[tier]; + deps.stdout.write( + ` ${tier.padEnd(7)} ${String(limit.qps).padStart(3)} ${String(limit.qpm).padStart(5)} ${String(limit.qpd).padStart(6)}\n`, + ); + } +} + +function activeBackend( + services: NonNullable, + langSearchEnabled: boolean, +): string { + if (langSearchEnabled && hasValue(services.langsearch?.apiKey)) return 'LangSearch'; + if (hasValue(services.moonshotSearch?.baseUrl)) return 'Moonshot'; + if (hasValue(services.langsearch?.apiKey)) { + return 'not configured (LangSearch experimental feature disabled)'; + } + return 'not configured'; +} + +function isExperimentalEnabled( + features: Awaited>, +): boolean { + return features.some( + (feature) => feature.id === LANGSEARCH_EXPERIMENTAL_FLAG && feature.enabled, + ); +} + +async function requireLangSearchExperimental( + harness: KimiHarness, + deps: SearchDeps, +): Promise { + if (isExperimentalEnabled(await harness.getExperimentalFeatures())) return; + deps.stderr.write(LANGSEARCH_EXPERIMENTAL_MESSAGE); + deps.exit(1); +} + +function hasValue(value: string | undefined): boolean { + return value !== undefined && value.trim().length > 0; +} + +function parseCount(value: string, deps: SearchDeps): number | undefined { + const n = Number(value); + if (!Number.isInteger(n) || n < 1 || n > 10) { + deps.stderr.write(`Invalid --count "${value}". Expected an integer between 1 and 10.\n`); + deps.exit(1); + } + return n; +} + +function parseBool( + value: string, + deps: SearchDeps, + flag: string, +): boolean | undefined { + if (value === 'true') return true; + if (value === 'false') return false; + deps.stderr.write(`Invalid ${flag} "${value}". Expected "true" or "false".\n`); + deps.exit(1); +} + +export function registerSearchCommand(parent: Command, deps?: Partial): void { + const search = parent + .command('search') + .description('Manage the web search backend and rerank (LangSearch) non-interactively.'); + + const runAction = async (resolved: SearchDeps, run: () => Promise): Promise => { + try { + await run(); + } catch (error) { + resolved.stderr.write(`${errorMessage(error)}\n`); + resolved.exit(1); + } + }; + + search + .command('status') + .description('Show the active web search backend and rerank status.') + .action(async () => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => handleSearchStatus(resolved)); + }); + + const setCmd = search + .command('set') + .description('Configure a web search provider or rerank.'); + + setCmd + .command('langsearch') + .description('Configure the LangSearch web search backend.') + .requiredOption('--api-key ', 'API key for the provider.') + .option('--tier ', 'LangSearch tier: free | tier1 | tier2 | tier3.', 'free') + .option('--count ', 'Number of results to request (1–10).', '10') + .action(async (options: SetLangSearchOptions) => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => handleSearchSetLangSearch(resolved, options)); + }); + + setCmd + .command('rerank') + .description('Configure the rerank provider.') + .option('--provider ', 'Rerank provider: langsearch.', 'langsearch') + .option('--api-key ', 'API key for rerank. Omit to reuse the LangSearch search key.') + .option('--enabled ', 'Enable rerank: true | false.', 'true') + .action(async (options: SetRerankOptions) => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => handleSearchSetRerank(resolved, options)); + }); + + search + .command('clear ') + .description('Remove a web search provider or rerank config. Use "langsearch" or "rerank".') + .action(async (provider: string) => { + const resolved = resolveDeps(deps); + await runAction(resolved, () => handleSearchClear(resolved, provider)); + }); + + search + .command('limits') + .description('Show the LangSearch tier rate-limit table.') + .action(() => { + const resolved = resolveDeps(deps); + handleSearchLimits(resolved); + }); +} + +function resolveDeps(overrides: Partial = {}): SearchDeps { + let harness: KimiHarness | undefined; + const identity = createKimiCodeHostIdentity(); + return { + getHarness: + overrides.getHarness ?? + (() => { + harness ??= createKimiHarness({ identity }); + return harness; + }), + stdout: overrides.stdout ?? process.stdout, + stderr: overrides.stderr ?? process.stderr, + exit: overrides.exit ?? ((code: number) => process.exit(code)), + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} \ No newline at end of file diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 5cab011ea3..749b2b8d72 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -27,6 +27,7 @@ import { formatErrorMessage } from '../utils/event-payload'; import { thinkingEffortToConfig } from '../utils/thinking-config'; import { showUsage } from './info'; import { setExperimentalFeatures } from './experimental-flags'; +import { showWebSearchConfig } from './web-search'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- @@ -773,5 +774,6 @@ function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelectio case 'experiments': void showExperimentsPanel(host); return; case 'upgrade': showUpdatePreferencePicker(host); return; case 'usage': void showUsage(host); return; + case 'webSearch': void showWebSearchConfig(host); return; } } diff --git a/apps/kimi-code/src/tui/commands/prompts.ts b/apps/kimi-code/src/tui/commands/prompts.ts index 5b0fd55339..041e4224da 100644 --- a/apps/kimi-code/src/tui/commands/prompts.ts +++ b/apps/kimi-code/src/tui/commands/prompts.ts @@ -114,6 +114,7 @@ export function promptApiKey( host: SlashCommandHost, platformName: string, subtitleLines: readonly string[] = ['Your key will be saved to ~/.kimi-code/config.toml'], + options: { readonly allowEmpty?: boolean } = {}, ): Promise { return new Promise((resolve) => { const dialog = new ApiKeyInputDialogComponent( @@ -123,6 +124,7 @@ export function promptApiKey( host.restoreEditor(); resolve(result.kind === 'ok' ? result.value : undefined); }, + options, ); host.mountEditorReplacement(dialog); }); diff --git a/apps/kimi-code/src/tui/commands/web-search.ts b/apps/kimi-code/src/tui/commands/web-search.ts new file mode 100644 index 0000000000..efe023222e --- /dev/null +++ b/apps/kimi-code/src/tui/commands/web-search.ts @@ -0,0 +1,631 @@ +import { + KIMI_CODE_PROVIDER_NAME, + OPEN_PLATFORMS, +} from '@moonshot-ai/kimi-code-oauth'; +import type { + KimiConfig, + MoonshotServiceConfig, + RerankServiceConfig, + ServicesConfig, +} from '@moonshot-ai/kimi-code-sdk'; + +import { + ChoicePickerComponent, + type ChoiceOption, +} from '../components/dialogs/choice-picker'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; +import { isExperimentalFlagEnabled } from './experimental-flags'; +import { promptApiKey } from './prompts'; + +// --------------------------------------------------------------------------- +// /settings → Web Search — search and rerank provider configuration +// --------------------------------------------------------------------------- + +const LANGSEARCH_EXPERIMENTAL_FLAG = 'langsearch-web-search'; +const ROOT_SEARCH_PROVIDER = 'search-provider'; +const ROOT_RERANK_PROVIDER = 'rerank-provider'; + +const SEARCH_PROVIDER_VALUES = ['moonshot', 'langsearch'] as const; +type SearchProviderChoice = (typeof SEARCH_PROVIDER_VALUES)[number]; + +const TIER_VALUES = ['free', 'tier1', 'tier2', 'tier3'] as const; +type LangSearchTier = (typeof TIER_VALUES)[number]; + +const RERANK_PROVIDER_VALUES = ['langsearch'] as const; +type RerankProviderChoice = (typeof RERANK_PROVIDER_VALUES)[number]; + +const RERANK_TOGGLE_VALUES = ['enabled', 'disabled'] as const; +type RerankToggle = (typeof RERANK_TOGGLE_VALUES)[number]; + +interface PickerOptions { + readonly title: string; + readonly options: readonly ChoiceOption[]; + readonly currentValue?: string; + readonly notice?: string; + readonly noticeTone?: 'success' | 'warning'; +} + +interface MoonshotOAuthSource { + readonly baseUrl: string; + readonly oauth: NonNullable; +} + +/** Settings → Web Search entry with current provider state shown at the top. */ +export async function showWebSearchConfig(host: SlashCommandHost): Promise { + const config = await host.harness.getConfig(); + const services = config.services ?? {}; + const summary = currentProviderSummary(services); + const action = await pickChoice(host, { + title: 'Web Search', + notice: `${summary.search}\n${summary.rerank}`, + noticeTone: summary.hasWarning ? 'warning' : 'success', + options: [ + { + value: ROOT_SEARCH_PROVIDER, + label: 'Web search provider', + description: 'Configure Moonshot or LangSearch for web search.', + }, + { + value: ROOT_RERANK_PROVIDER, + label: 'Rerank provider', + description: 'Configure and manage semantic reranking.', + }, + ], + }); + if (action === ROOT_SEARCH_PROVIDER) { + await showSearchProviderMenu(host); + } else if (action === ROOT_RERANK_PROVIDER) { + await showRerankProviderMenu(host); + } +} + +async function showSearchProviderMenu(host: SlashCommandHost): Promise { + const config = await host.harness.getConfig(); + const services = config.services ?? {}; + const current = currentSearchProvider(services); + const selected = await pickChoice(host, { + title: 'Web search provider', + currentValue: current, + options: [ + { + value: 'moonshot', + label: 'Moonshot', + description: 'Configure a Moonshot API key.', + }, + { + value: 'langsearch', + label: 'LangSearch', + description: isExperimentalFlagEnabled(LANGSEARCH_EXPERIMENTAL_FLAG) + ? 'Use the LangSearch Web Search API.' + : 'Enable LangSearch web search under Settings → Experiments first.', + }, + ], + }); + if (!isSearchProviderChoice(selected)) return; + if ( + selected === 'langsearch' && + !isExperimentalFlagEnabled(LANGSEARCH_EXPERIMENTAL_FLAG) + ) { + showLangSearchExperimentalNotice(host); + return; + } + + if (selected === current) { + await manageSearchProvider(host, selected); + } else { + await configureSearchProvider(host, selected); + } +} + +async function manageSearchProvider( + host: SlashCommandHost, + provider: SearchProviderChoice, +): Promise { + const label = searchProviderLabel(provider); + const action = await pickChoice(host, { + title: `${label} web search`, + options: [ + { + value: 'edit', + label: 'Edit configuration', + description: `Replace the current ${label} search settings.`, + }, + { + value: 'remove', + label: 'Remove provider', + description: 'Remove this web search provider configuration.', + tone: 'danger', + }, + ], + }); + if (action === 'edit') { + await configureSearchProvider(host, provider); + } else if (action === 'remove') { + await removeSearchProvider(host, provider); + } +} + +async function configureSearchProvider( + host: SlashCommandHost, + provider: SearchProviderChoice, +): Promise { + if (provider === 'langsearch') { + await configureLangSearch(host); + } else { + await configureMoonshot(host); + } +} + +async function configureLangSearch(host: SlashCommandHost): Promise { + const apiKey = await promptApiKey(host, 'LangSearch', [ + 'Your key will be saved to ~/.kimi-code/config.toml under [services.langsearch].', + ]); + if (apiKey === undefined) return; + + const tier = await pickTier(host); + if (tier === undefined) return; + + try { + await host.harness.replaceService('langsearch', { + apiKey, + tier, + }); + await reloadSessionAfterWebSearchChange(host, 'LangSearch web search configured.'); + } catch (error) { + host.showError(`Failed to save LangSearch config: ${formatErrorMessage(error)}`); + } +} + +async function configureMoonshot(host: SlashCommandHost): Promise { + const config = await host.harness.getConfig(); + const oauthSource = findMoonshotOAuthSource(config); + const options: ChoiceOption[] = []; + if (oauthSource !== undefined) { + options.push({ + value: 'oauth', + label: 'Kimi Code OAuth', + description: 'Reuse the credentials from your existing Kimi Code login.', + }); + } + options.push({ + value: 'api-key', + label: 'Moonshot API key', + description: 'Configure Moonshot Search using an API key.', + }); + + const authMethod = await pickChoice(host, { + title: 'Moonshot authentication', + currentValue: + config.services?.moonshotSearch?.oauth !== undefined ? 'oauth' : undefined, + options, + }); + if (authMethod === 'oauth' && oauthSource !== undefined) { + await saveMoonshotConfig(host, { + baseUrl: oauthSource.baseUrl, + apiKey: '', + oauth: oauthSource.oauth, + }); + } else if (authMethod === 'api-key') { + await configureMoonshotApiKey(host); + } +} + +async function configureMoonshotApiKey(host: SlashCommandHost): Promise { + const platformId = await pickChoice(host, { + title: 'Moonshot API region', + options: OPEN_PLATFORMS.map((platform) => ({ + value: platform.id, + label: platform.name, + description: platform.baseUrl, + })), + }); + const platform = OPEN_PLATFORMS.find((candidate) => candidate.id === platformId); + if (platform === undefined) return; + + const apiKey = await promptApiKey(host, platform.name, [ + `${'search URL'.padEnd(12)}${searchUrlFromBaseUrl(platform.baseUrl)}`, + `${'saved to'.padEnd(12)}~/.kimi-code/config.toml`, + ]); + if (apiKey === undefined) return; + + await saveMoonshotConfig(host, { + baseUrl: searchUrlFromBaseUrl(platform.baseUrl), + apiKey, + }); +} + +async function saveMoonshotConfig( + host: SlashCommandHost, + service: MoonshotServiceConfig, +): Promise { + try { + const config = await host.harness.getConfig(); + const langsearch = config.services?.langsearch; + const rerank = config.services?.rerank; + await host.harness.replaceService('moonshotSearch', service); + if ( + rerank?.provider === 'langsearch' && + !isNonEmpty(rerank.apiKey) && + isNonEmpty(langsearch?.apiKey) + ) { + await host.harness.replaceService('rerank', { + ...rerank, + apiKey: langsearch.apiKey, + }); + } + if (langsearch !== undefined) { + await host.harness.removeService('langsearch'); + } + await reloadSessionAfterWebSearchChange(host, 'Moonshot web search configured.'); + } catch (error) { + host.showError(`Failed to save Moonshot config: ${formatErrorMessage(error)}`); + } +} + +async function removeSearchProvider( + host: SlashCommandHost, + provider: SearchProviderChoice, +): Promise { + try { + await host.harness.removeService( + provider === 'moonshot' ? 'moonshotSearch' : 'langsearch', + ); + await reloadSessionAfterWebSearchChange( + host, + `${searchProviderLabel(provider)} web search removed.`, + ); + } catch (error) { + host.showError( + `Failed to remove ${searchProviderLabel(provider)}: ${formatErrorMessage(error)}`, + ); + } +} + +async function showRerankProviderMenu(host: SlashCommandHost): Promise { + if (!isExperimentalFlagEnabled(LANGSEARCH_EXPERIMENTAL_FLAG)) { + showLangSearchExperimentalNotice(host); + return; + } + const config = await host.harness.getConfig(); + const rerank = config.services?.rerank; + const selected = await pickChoice(host, { + title: 'Rerank provider', + currentValue: rerank?.provider, + options: [ + { + value: 'langsearch', + label: 'LangSearch', + description: 'Reorder web search results using semantic relevance.', + }, + ], + }); + if (!isRerankProviderChoice(selected)) return; + + if (rerank?.provider === selected) { + await editRerankProvider(host, rerank); + } else { + await setupRerankProvider(host, selected); + } +} + +async function setupRerankProvider( + host: SlashCommandHost, + provider: RerankProviderChoice, +): Promise { + const apiKey = await promptRerankApiKey(host); + if (apiKey === undefined) return; + const config = await host.harness.getConfig(); + if (!isNonEmpty(apiKey) && !isNonEmpty(config.services?.langsearch?.apiKey)) { + host.showError( + 'A LangSearch API key is required when the search provider is not LangSearch.', + ); + return; + } + + const toggle = await pickRerankToggle(host); + if (toggle === undefined) return; + + try { + await host.harness.replaceService('rerank', { + enabled: toggle === 'enabled', + provider, + apiKey: isNonEmpty(apiKey) ? apiKey : undefined, + }); + await reloadSessionAfterWebSearchChange(host, 'Rerank configured.'); + } catch (error) { + host.showError(`Failed to save rerank config: ${formatErrorMessage(error)}`); + } +} + +async function editRerankProvider( + host: SlashCommandHost, + rerank: RerankServiceConfig, +): Promise { + const action = await pickChoice(host, { + title: 'LangSearch rerank', + notice: `Current status: ${rerank.enabled === false ? 'disabled' : 'enabled'}`, + options: [ + { + value: 'status', + label: 'Status', + description: rerank.enabled === false ? 'Disabled' : 'Enabled', + }, + { + value: 'api-key', + label: 'API key', + description: + isNonEmpty(rerank.apiKey) + ? 'A dedicated rerank API key is configured.' + : 'Reuses the LangSearch web search API key.', + }, + { + value: 'remove', + label: 'Remove provider', + description: 'Delete the rerank provider configuration.', + tone: 'danger', + }, + ], + }); + + if (action === 'status') { + await editRerankStatus(host, rerank); + } else if (action === 'api-key') { + await editRerankApiKey(host, rerank); + } else if (action === 'remove') { + await removeRerankProvider(host); + } +} + +async function editRerankStatus( + host: SlashCommandHost, + rerank: RerankServiceConfig, +): Promise { + const current: RerankToggle = rerank.enabled === false ? 'disabled' : 'enabled'; + const toggle = await pickRerankToggle(host, current); + if (toggle === undefined || toggle === current) return; + + try { + await host.harness.replaceService('rerank', { + ...rerank, + enabled: toggle === 'enabled', + }); + await reloadSessionAfterWebSearchChange(host, `Rerank ${toggle}.`); + } catch (error) { + host.showError(`Failed to update rerank status: ${formatErrorMessage(error)}`); + } +} + +async function editRerankApiKey( + host: SlashCommandHost, + rerank: RerankServiceConfig, +): Promise { + const apiKey = await promptRerankApiKey(host); + if (apiKey === undefined) return; + const config = await host.harness.getConfig(); + if (!isNonEmpty(apiKey) && !isNonEmpty(config.services?.langsearch?.apiKey)) { + host.showError( + 'A LangSearch API key is required when the search provider is not LangSearch.', + ); + return; + } + + try { + await host.harness.replaceService('rerank', { + ...rerank, + apiKey: isNonEmpty(apiKey) ? apiKey : undefined, + }); + await reloadSessionAfterWebSearchChange(host, 'Rerank API key updated.'); + } catch (error) { + host.showError(`Failed to update rerank API key: ${formatErrorMessage(error)}`); + } +} + +async function removeRerankProvider(host: SlashCommandHost): Promise { + try { + await host.harness.removeService('rerank'); + await reloadSessionAfterWebSearchChange(host, 'Rerank provider removed.'); + } catch (error) { + host.showError(`Failed to remove rerank provider: ${formatErrorMessage(error)}`); + } +} + +function promptRerankApiKey(host: SlashCommandHost): Promise { + return promptApiKey( + host, + 'LangSearch Rerank', + ['API key for rerank. Leave empty to reuse the LangSearch search key.'], + { allowEmpty: true }, + ); +} + +function pickTier(host: SlashCommandHost): Promise { + return pickChoice(host, { + title: 'LangSearch tier', + options: TIER_VALUES.map((value) => ({ + value, + label: value, + description: + value === 'free' + ? 'Free tier — lowest rate limits.' + : `${value} — higher rate limits.`, + })), + }).then((value) => (isLangSearchTier(value) ? value : undefined)); +} + +function pickRerankToggle( + host: SlashCommandHost, + currentValue?: RerankToggle, +): Promise { + return pickChoice(host, { + title: 'Rerank status', + currentValue, + options: [ + { + value: 'enabled', + label: 'Enabled', + description: 'Rerank search results by relevance.', + }, + { + value: 'disabled', + label: 'Disabled', + description: 'Keep rerank configured but turned off.', + }, + ], + }).then((value) => (isRerankToggle(value) ? value : undefined)); +} + +function pickChoice( + host: SlashCommandHost, + options: PickerOptions, +): Promise { + return new Promise((resolve) => { + const picker = new ChoicePickerComponent({ + title: options.title, + options: options.options, + currentValue: options.currentValue, + notice: options.notice, + noticeTone: options.noticeTone, + onSelect: (value) => { + host.restoreEditor(); + resolve(value); + }, + onCancel: () => { + host.restoreEditor(); + resolve(undefined); + }, + }); + host.mountEditorReplacement(picker); + }); +} + +async function reloadSessionAfterWebSearchChange( + host: SlashCommandHost, + statusMessage: string, +): Promise { + if (host.session === undefined) { + host.showStatus(statusMessage); + return; + } + await host.session.reloadSession(); + await host.reloadCurrentSessionView(host.session, `${statusMessage} Session reloaded.`); +} + +function findMoonshotOAuthSource(config: KimiConfig): MoonshotOAuthSource | undefined { + const managed = config.providers[KIMI_CODE_PROVIDER_NAME]; + if (managed?.oauth !== undefined && isNonEmpty(managed.baseUrl)) { + return { + baseUrl: searchUrlFromBaseUrl(managed.baseUrl), + oauth: managed.oauth, + }; + } + + const service = config.services?.moonshotSearch; + if (service?.oauth !== undefined && isNonEmpty(service.baseUrl)) { + return { + baseUrl: service.baseUrl, + oauth: service.oauth, + }; + } + return undefined; +} + +function currentSearchProvider( + services: ServicesConfig, +): SearchProviderChoice | undefined { + if ( + isExperimentalFlagEnabled(LANGSEARCH_EXPERIMENTAL_FLAG) && + isNonEmpty(services.langsearch?.apiKey) + ) { + return 'langsearch'; + } + if (isNonEmpty(services.moonshotSearch?.baseUrl)) return 'moonshot'; + return undefined; +} + +function currentProviderSummary(services: ServicesConfig): { + readonly search: string; + readonly rerank: string; + readonly hasWarning: boolean; +} { + const langSearchEnabled = isExperimentalFlagEnabled(LANGSEARCH_EXPERIMENTAL_FLAG); + const current = currentSearchProvider(services); + const langSearchDisabled = !langSearchEnabled && isNonEmpty(services.langsearch?.apiKey); + const search = + current === 'langsearch' + ? `Current web search: LangSearch (tier: ${services.langsearch?.tier ?? 'free'})` + : current === 'moonshot' + ? `Current web search: Moonshot (${services.moonshotSearch?.oauth !== undefined ? 'OAuth' : 'API key'})` + : langSearchDisabled + ? 'Current web search: LangSearch configured, experimental feature disabled' + : 'Current web search: not configured'; + + const rerank = services.rerank; + if (rerank?.provider === undefined) { + return { + search, + rerank: 'Current rerank: not configured', + hasWarning: current === undefined || langSearchDisabled, + }; + } + const rerankLabel = rerankProviderLabel(rerank.provider); + if (!langSearchEnabled) { + return { + search, + rerank: `Current rerank: ${rerankLabel} configured, experimental feature disabled`, + hasWarning: true, + }; + } + if (rerank.enabled === false) { + return { + search, + rerank: `Current rerank: ${rerankLabel} disabled`, + hasWarning: current === undefined, + }; + } + + const hasKey = isNonEmpty(rerank.apiKey) || isNonEmpty(services.langsearch?.apiKey); + return { + search, + rerank: `Current rerank: ${rerankLabel} ${hasKey ? 'enabled' : 'missing API key'}`, + hasWarning: current === undefined || !hasKey, + }; +} + +function searchProviderLabel(provider: SearchProviderChoice): string { + return provider === 'moonshot' ? 'Moonshot' : 'LangSearch'; +} + +function rerankProviderLabel(provider: RerankProviderChoice): string { + return provider === 'langsearch' ? 'LangSearch' : provider; +} + +function showLangSearchExperimentalNotice(host: SlashCommandHost): void { + host.showNotice( + 'Enable “LangSearch web search” under Settings → Experiments before configuring LangSearch or rerank.', + ); +} + +function searchUrlFromBaseUrl(baseUrl: string): string { + return `${baseUrl.replace(/\/+$/, '')}/search`; +} + +function isSearchProviderChoice(value: string | undefined): value is SearchProviderChoice { + return value !== undefined && (SEARCH_PROVIDER_VALUES as readonly string[]).includes(value); +} + +function isLangSearchTier(value: string | undefined): value is LangSearchTier { + return value !== undefined && (TIER_VALUES as readonly string[]).includes(value); +} + +function isRerankProviderChoice(value: string | undefined): value is RerankProviderChoice { + return value !== undefined && (RERANK_PROVIDER_VALUES as readonly string[]).includes(value); +} + +function isRerankToggle(value: string | undefined): value is RerankToggle { + return value !== undefined && (RERANK_TOGGLE_VALUES as readonly string[]).includes(value); +} + +function isNonEmpty(value: string | undefined): value is string { + return value !== undefined && value.trim().length > 0; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts index d95d9ff932..720f791bf3 100644 --- a/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/api-key-input-dialog.ts @@ -48,6 +48,7 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { private readonly onDone: (result: ApiKeyInputResult) => void; private readonly title: string; private readonly subtitleLines: readonly string[]; + private readonly allowEmpty: boolean; private done = false; private emptyHinted = false; @@ -55,11 +56,13 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { platformName: string, subtitleLines: readonly string[], onDone: (result: ApiKeyInputResult) => void, + options: { readonly allowEmpty?: boolean } = {}, ) { super(); this.onDone = onDone; this.title = `Enter API key for ${platformName}`; this.subtitleLines = subtitleLines; + this.allowEmpty = options.allowEmpty ?? false; this.input.onSubmit = (value) => { this.submit(value); }; @@ -143,7 +146,7 @@ export class ApiKeyInputDialogComponent extends Container implements Focusable { private submit(value: string): void { if (this.done) return; const trimmed = value.trim(); - if (trimmed.length === 0) { + if (trimmed.length === 0 && !this.allowEmpty) { this.emptyHinted = true; return; } diff --git a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts index 81e4b8d125..b1788d20ad 100644 --- a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts @@ -7,7 +7,8 @@ export type SettingsSelection = | 'permission' | 'experiments' | 'upgrade' - | 'usage'; + | 'usage' + | 'webSearch'; const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ { @@ -45,6 +46,11 @@ const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ label: 'Usage', description: 'Show session tokens, context window, and plan quotas.', }, + { + value: 'webSearch', + label: 'Web Search', + description: 'Configure web search and rerank providers.', + }, ]; function isSettingsSelection(value: string): value is SettingsSelection { @@ -55,7 +61,8 @@ function isSettingsSelection(value: string): value is SettingsSelection { value === 'permission' || value === 'experiments' || value === 'upgrade' || - value === 'usage' + value === 'usage' || + value === 'webSearch' ); } diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index b15ed315fe..519ed23148 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -461,6 +461,7 @@ describe('CLI options parsing', () => { expect(commandNames).toEqual([ 'export', 'provider', + 'search', 'acp', 'server', 'web', diff --git a/apps/kimi-code/test/cli/search.test.ts b/apps/kimi-code/test/cli/search.test.ts new file mode 100644 index 0000000000..ef1a7a3072 --- /dev/null +++ b/apps/kimi-code/test/cli/search.test.ts @@ -0,0 +1,144 @@ +import type { KimiConfig, KimiHarness } from '@moonshot-ai/kimi-code-sdk'; +import { Command } from 'commander'; +import { describe, expect, it, vi } from 'vitest'; + +import { + handleSearchClear, + handleSearchSetLangSearch, + handleSearchStatus, + registerSearchCommand, + type SearchDeps, +} from '#/cli/sub/search'; + +interface TestContext { + readonly deps: SearchDeps; + readonly ensureConfigFile: ReturnType; + readonly getConfig: ReturnType; + readonly getExperimentalFeatures: ReturnType; + readonly replaceService: ReturnType; + readonly setConfig: ReturnType; + readonly removeService: ReturnType; + readonly stdout: () => string; + readonly stderr: () => string; +} + +function makeContext(config?: KimiConfig): TestContext { + let stdout = ''; + let stderr = ''; + const resolvedConfig = config ?? { providers: {} }; + const ensureConfigFile = vi.fn(async () => {}); + const getConfig = vi.fn(async () => resolvedConfig); + const getExperimentalFeatures = vi.fn(async () => [ + { id: 'langsearch-web-search', enabled: true }, + ]); + const replaceService = vi.fn(async () => resolvedConfig); + const setConfig = vi.fn(async () => resolvedConfig); + const removeService = vi.fn(async () => resolvedConfig); + const harness = { + ensureConfigFile, + getConfig, + getExperimentalFeatures, + replaceService, + setConfig, + removeService, + } as unknown as KimiHarness; + + return { + deps: { + getHarness: () => harness, + stdout: { + write: (chunk: string) => { + stdout += chunk; + return true; + }, + }, + stderr: { + write: (chunk: string) => { + stderr += chunk; + return true; + }, + }, + exit: (code: number): never => { + throw new Error(`exit:${String(code)}`); + }, + }, + ensureConfigFile, + getConfig, + getExperimentalFeatures, + replaceService, + setConfig, + removeService, + stdout: () => stdout, + stderr: () => stderr, + }; +} + +describe('kimi search', () => { + it('parses the nested set langsearch command and writes its config', async () => { + const context = makeContext(); + const program = new Command(); + program.exitOverride(); + program.configureOutput({ writeOut: () => {}, writeErr: () => {} }); + registerSearchCommand(program, context.deps); + + await program.parseAsync([ + 'node', + 'kimi', + 'search', + 'set', + 'langsearch', + '--api-key', + 'sk-test', + '--tier', + 'tier2', + '--count', + '10', + ]); + + expect(context.replaceService).toHaveBeenCalledWith('langsearch', { + apiKey: 'sk-test', + tier: 'tier2', + count: 10, + }); + expect(context.stdout()).toContain('LangSearch configured: tier=tier2 count=10'); + }); + + it('rejects result counts above the Web Search API maximum', async () => { + const context = makeContext(); + + await expect( + handleSearchSetLangSearch(context.deps, { + apiKey: 'sk-test', + count: '11', + }), + ).rejects.toThrow('exit:1'); + + expect(context.stderr()).toContain('Expected an integer between 1 and 10'); + expect(context.setConfig).not.toHaveBeenCalled(); + }); + + it('clears LangSearch through the explicit removal API', async () => { + const context = makeContext({ + providers: {}, + services: { + langsearch: { apiKey: 'sk-test' }, + rerank: { enabled: true, provider: 'langsearch', apiKey: 'sk-rerank-test' }, + }, + }); + + await handleSearchClear(context.deps, 'langsearch'); + + expect(context.removeService).toHaveBeenCalledWith('langsearch'); + expect(context.stdout()).toContain('LangSearch web search cleared.'); + }); + + it('reports an actionable status when no backend is configured', async () => { + const context = makeContext(); + + await handleSearchStatus(context.deps); + + expect(context.stdout()).toBe( + 'Web search backend: not configured\nRerank: not configured\n', + ); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/web-search.test.ts b/apps/kimi-code/test/tui/commands/web-search.test.ts new file mode 100644 index 0000000000..45fce89fa4 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/web-search.test.ts @@ -0,0 +1,421 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { showWebSearchConfig } from '#/tui/commands/web-search'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { setExperimentalFeatures } from '#/tui/commands/experimental-flags'; + +type MountedPanel = { + render: (width: number) => string[]; + handleInput: (data: string) => void; +}; + +interface TestConfig { + readonly providers?: Record; + readonly services?: Record; +} + +interface HostExtras { + harness: { + getConfig: ReturnType; + setConfig: ReturnType; + replaceService: ReturnType; + removeService: ReturnType; + }; + showStatus: ReturnType; + showNotice: ReturnType; + showError: ReturnType; + mountEditorReplacement: ReturnType; + restoreEditor: ReturnType; + reloadCurrentSessionView: ReturnType; +} + +const ENTER = '\r'; +const ESC = '\u001B'; +const UP = '\u001B[A'; +const DOWN = '\u001B[B'; + +function makeHost( + config: TestConfig = {}, + session?: { reloadSession: ReturnType }, +): { host: SlashCommandHost & HostExtras; getMounted: () => MountedPanel | null } { + let mounted: MountedPanel | null = null; + const normalized = { + providers: config.providers ?? {}, + services: config.services, + }; + const host = { + harness: { + getConfig: vi.fn(async () => normalized), + setConfig: vi.fn(async () => normalized), + replaceService: vi.fn(async () => normalized), + removeService: vi.fn(async () => normalized), + }, + session, + showStatus: vi.fn(), + showNotice: vi.fn(), + showError: vi.fn(), + mountEditorReplacement: vi.fn((panel: MountedPanel) => { + mounted = panel; + }), + restoreEditor: vi.fn(), + reloadCurrentSessionView: vi.fn(async () => {}), + } as unknown as SlashCommandHost & HostExtras; + return { host, getMounted: () => mounted }; +} + +function renderedText(panel: MountedPanel): string { + return panel.render(100).join('\n'); +} + +async function settle(): Promise { + for (let index = 0; index < 8; index++) await Promise.resolve(); +} + +async function input(panel: MountedPanel, value: string): Promise { + panel.handleInput(value); + await settle(); +} + +async function type(panel: MountedPanel, value: string): Promise { + for (const character of value) panel.handleInput(character); + await settle(); +} + +describe('showWebSearchConfig', () => { + beforeEach(() => { + setExperimentalFeatures([{ id: 'langsearch-web-search', enabled: true }]); + }); + + it('shows current provider state at the top and only the two provider menus', async () => { + const { host, getMounted } = makeHost({ + services: { + langsearch: { apiKey: 'sk-test', tier: 'tier2' }, + rerank: { provider: 'langsearch', enabled: true }, + }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + const panel = getMounted(); + expect(panel).not.toBeNull(); + const text = renderedText(panel!); + expect(text).toContain('Current web search: LangSearch (tier: tier2)'); + expect(text).toContain('Current rerank: LangSearch enabled'); + expect(text).toContain('Web search provider'); + expect(text).toContain('Rerank provider'); + expect(text).not.toContain('Show current backend'); + expect(text).not.toContain('Activate LangSearch'); + + await input(panel!, ESC); + await pending; + }); + + it('shows a missing-key warning when rerank depended on removed LangSearch config', async () => { + const { host, getMounted } = makeHost({ + services: { + moonshotSearch: { + baseUrl: 'https://api.example.test/v1/search', + apiKey: 'sk-search', + }, + rerank: { provider: 'langsearch', enabled: true }, + }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + expect(renderedText(getMounted()!)).toContain( + 'Current rerank: LangSearch missing API key', + ); + await input(getMounted()!, ESC); + await pending; + }); + + it('marks the active search provider as current', async () => { + const { host, getMounted } = makeHost({ + services: { langsearch: { apiKey: 'sk-test' } }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, ENTER); + const text = renderedText(getMounted()!); + expect(text).toContain('LangSearch ← current'); + expect(text).not.toContain('Moonshot ← current'); + + await input(getMounted()!, ESC); + await pending; + }); + + it('preserves the LangSearch key used by rerank when switching to Moonshot', async () => { + const session = { reloadSession: vi.fn(async () => {}) }; + const { host, getMounted } = makeHost( + { + providers: { + 'managed:kimi-code': { + type: 'kimi', + baseUrl: 'https://api.kimi.com/coding/v1', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + }, + services: { + langsearch: { apiKey: 'sk-langsearch' }, + rerank: { + provider: 'langsearch', + enabled: true, + baseUrl: 'https://rerank.example.test/v1', + customHeaders: { 'X-Test': 'test' }, + }, + }, + }, + session, + ); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, ENTER); // Web search provider + await input(getMounted()!, UP); // Moonshot (LangSearch starts selected) + await input(getMounted()!, ENTER); + expect(renderedText(getMounted()!)).toContain('Kimi Code OAuth'); + await input(getMounted()!, ENTER); + await pending; + + expect(host.harness.replaceService).toHaveBeenCalledWith('moonshotSearch', { + baseUrl: 'https://api.kimi.com/coding/v1/search', + apiKey: '', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }); + expect(host.harness.replaceService).toHaveBeenCalledWith('rerank', { + provider: 'langsearch', + enabled: true, + apiKey: 'sk-langsearch', + baseUrl: 'https://rerank.example.test/v1', + customHeaders: { 'X-Test': 'test' }, + }); + expect(host.harness.replaceService.mock.invocationCallOrder[1]).toBeLessThan( + host.harness.removeService.mock.invocationCallOrder[0]!, + ); + expect(host.harness.removeService).toHaveBeenCalledWith('langsearch'); + expect(session.reloadSession).toHaveBeenCalledTimes(1); + expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( + session, + 'Moonshot web search configured. Session reloaded.', + ); + }); + + it('keeps a dedicated rerank key unchanged when switching to Moonshot', async () => { + const { host, getMounted } = makeHost({ + providers: { + 'managed:kimi-code': { + type: 'kimi', + baseUrl: 'https://api.kimi.com/coding/v1', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }, + }, + services: { + langsearch: { apiKey: 'sk-langsearch' }, + rerank: { + provider: 'langsearch', + enabled: true, + apiKey: 'sk-rerank', + }, + }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, ENTER); // Web search provider + await input(getMounted()!, UP); // Moonshot + await input(getMounted()!, ENTER); + await input(getMounted()!, ENTER); // Kimi Code OAuth + await pending; + + expect(host.harness.replaceService).not.toHaveBeenCalledWith( + 'rerank', + expect.anything(), + ); + expect(host.harness.removeService).toHaveBeenCalledWith('langsearch'); + }); + + it('configures Moonshot manually with an automatically derived search URL', async () => { + const { host, getMounted } = makeHost(); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, ENTER); // Web search provider + await input(getMounted()!, ENTER); // Moonshot + await input(getMounted()!, ENTER); // Moonshot API key (only auth option) + expect(renderedText(getMounted()!)).toContain('Moonshot API region'); + await input(getMounted()!, ENTER); // China + expect(renderedText(getMounted()!)).toContain( + 'https://api.moonshot.cn/v1/search', + ); + await type(getMounted()!, 'sk-test'); + await input(getMounted()!, ENTER); + await pending; + + expect(host.harness.replaceService).toHaveBeenCalledWith('moonshotSearch', { + baseUrl: 'https://api.moonshot.cn/v1/search', + apiKey: 'sk-test', + }); + expect(host.showStatus).toHaveBeenCalledWith('Moonshot web search configured.'); + }); + + it('keeps Moonshot as fallback when configuring LangSearch', async () => { + const { host, getMounted } = makeHost({ + services: { + moonshotSearch: { + baseUrl: 'https://api.example.test/v1/search', + apiKey: 'sk-search', + }, + }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, ENTER); // Web search provider + await input(getMounted()!, DOWN); // LangSearch + await input(getMounted()!, ENTER); + await type(getMounted()!, 'sk-langsearch'); + await input(getMounted()!, ENTER); + await input(getMounted()!, ENTER); // free tier + await pending; + + expect(host.harness.replaceService).toHaveBeenCalledWith('langsearch', { + apiKey: 'sk-langsearch', + tier: 'free', + }); + expect(host.harness.removeService).not.toHaveBeenCalledWith('moonshotSearch'); + }); + + it('opens management for the current provider and removes it', async () => { + const { host, getMounted } = makeHost({ + services: { langsearch: { apiKey: 'sk-test' } }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, ENTER); // Web search provider + await input(getMounted()!, ENTER); // current LangSearch + expect(renderedText(getMounted()!)).toContain('Edit configuration'); + expect(renderedText(getMounted()!)).toContain('Remove provider'); + await input(getMounted()!, DOWN); + await input(getMounted()!, ENTER); + await pending; + + expect(host.harness.removeService).toHaveBeenCalledWith('langsearch'); + expect(host.showStatus).toHaveBeenCalledWith('LangSearch web search removed.'); + }); + + it('configures rerank independently while Moonshot is the search provider', async () => { + const { host, getMounted } = makeHost({ + services: { + moonshotSearch: { + baseUrl: 'https://api.example.test/v1/search', + apiKey: 'sk-search', + }, + }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, DOWN); + await input(getMounted()!, ENTER); // Rerank provider + await input(getMounted()!, ENTER); // LangSearch + await type(getMounted()!, 'sk-rerank'); + await input(getMounted()!, ENTER); + await input(getMounted()!, ENTER); // Enabled + await pending; + + expect(host.harness.replaceService).toHaveBeenCalledWith('rerank', { + provider: 'langsearch', + enabled: true, + apiKey: 'sk-rerank', + }); + expect(host.harness.removeService).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('Rerank configured.'); + }); + + it('edits the current rerank provider status', async () => { + const { host, getMounted } = makeHost({ + services: { + langsearch: { apiKey: 'sk-search' }, + rerank: { provider: 'langsearch', enabled: true }, + }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, DOWN); // Rerank provider + await input(getMounted()!, ENTER); + expect(renderedText(getMounted()!)).toContain('LangSearch ← current'); + await input(getMounted()!, ENTER); + expect(renderedText(getMounted()!)).toContain('Current status: enabled'); + await input(getMounted()!, ENTER); // Status + expect(renderedText(getMounted()!)).toContain('Enabled ← current'); + await input(getMounted()!, DOWN); + await input(getMounted()!, ENTER); // Disabled + await pending; + + expect(host.harness.replaceService).toHaveBeenCalledWith('rerank', { + provider: 'langsearch', + enabled: false, + }); + expect(host.showStatus).toHaveBeenCalledWith('Rerank disabled.'); + }); + + it('clears the dedicated rerank key so it reuses the search key', async () => { + const { host, getMounted } = makeHost({ + services: { + langsearch: { apiKey: 'sk-search' }, + rerank: { + provider: 'langsearch', + enabled: true, + apiKey: 'sk-rerank', + }, + }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, DOWN); + await input(getMounted()!, ENTER); // Rerank provider + await input(getMounted()!, ENTER); // Current LangSearch + await input(getMounted()!, DOWN); // API key + await input(getMounted()!, ENTER); + await input(getMounted()!, ENTER); // Empty means reuse search key + await pending; + + expect(host.harness.removeService).not.toHaveBeenCalledWith('rerank'); + expect(host.harness.replaceService).toHaveBeenCalledWith('rerank', { + provider: 'langsearch', + enabled: true, + apiKey: undefined, + }); + expect(host.showStatus).toHaveBeenCalledWith('Rerank API key updated.'); + }); + + it('removes the current rerank provider from its editor', async () => { + const { host, getMounted } = makeHost({ + services: { + rerank: { + provider: 'langsearch', + enabled: false, + apiKey: 'sk-rerank', + }, + }, + }); + const pending = showWebSearchConfig(host); + await settle(); + + await input(getMounted()!, DOWN); + await input(getMounted()!, ENTER); // Rerank provider + await input(getMounted()!, ENTER); // Current LangSearch + await input(getMounted()!, DOWN); + await input(getMounted()!, DOWN); // Remove provider + await input(getMounted()!, ENTER); + await pending; + + expect(host.harness.removeService).toHaveBeenCalledWith('rerank'); + expect(host.showStatus).toHaveBeenCalledWith('Rerank provider removed.'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts index 610ea23e26..376e60d841 100644 --- a/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/api-key-input-dialog.test.ts @@ -1,5 +1,5 @@ import { visibleWidth } from '@moonshot-ai/pi-tui'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { ApiKeyInputDialogComponent } from '#/tui/components/dialogs/api-key-input-dialog'; @@ -18,4 +18,32 @@ describe('ApiKeyInputDialogComponent', () => { } } }); + + it('submits an empty value when the caller allows key reuse', () => { + const onDone = vi.fn(); + const dialog = new ApiKeyInputDialogComponent( + 'LangSearch Rerank', + ['Leave empty to reuse the search key.'], + onDone, + { allowEmpty: true }, + ); + + dialog.handleInput('\r'); + + expect(onDone).toHaveBeenCalledWith({ kind: 'ok', value: '' }); + }); + + it('rejects an empty value by default', () => { + const onDone = vi.fn(); + const dialog = new ApiKeyInputDialogComponent( + 'LangSearch', + ['Paste your API key below.'], + onDone, + ); + + dialog.handleInput('\r'); + + expect(onDone).not.toHaveBeenCalled(); + expect(dialog.render(80).join('\n')).toContain('API key cannot be empty.'); + }); }); diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 141d021fa0..4632bf6059 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -260,7 +260,22 @@ In print mode (`kimi -p ""`), Kimi Code stays alive after the main agent ## `services` -`services` configures two built-in services: web search (`moonshot_search`) and web fetch (`moonshot_fetch`). Only these two fixed keys are recognized; other keys are ignored. Both entries share the same fields: +`services` configures built-in web search, web fetch, and optional result reranking. The recognized tables are: + +- **`moonshot_search`**: Moonshot web search backend. +- **`moonshot_fetch`**: Moonshot web fetch backend. +- **`langsearch`**: LangSearch web search backend. +- **`rerank`**: Optional semantic reranker that can reorder results from either search backend. + +LangSearch search and rerank are experimental and disabled by default. Enable **LangSearch web search** under **Settings → Experiments**, set `KIMI_CODE_EXPERIMENTAL_LANGSEARCH_WEB_SEARCH=1`, or add `langsearch-web-search = true` under `[experimental]`. Moonshot search remains available when the flag is off. + +When both search backends are configured and the experimental flag is enabled, `langsearch` takes precedence over `moonshot_search`. Disabling the flag or removing `langsearch` makes the runtime fall back to Moonshot when Moonshot search credentials are available. + +In the TUI, **Settings → Web Search** shows the current search and rerank providers at the top. Use **Web search provider** to configure or edit Moonshot or LangSearch, and **Rerank provider** to configure, enable, disable, edit, or remove semantic reranking independently. Selecting Moonshot can reuse the current Kimi Code OAuth login or configure an API key for the China or Global API region. + +### Moonshot services + +`moonshot_search` and `moonshot_fetch` accept the same fields: | Field | Type | Required | Description | | --- | --- | --- | --- | @@ -269,16 +284,51 @@ In print mode (`kimi -p ""`), Kimi Code stays alive after the main agent | `oauth` | `table` | No | OAuth credential reference, same structure as `providers.*.oauth` | | `custom_headers` | `table` | No | Custom HTTP headers attached to each request | +### LangSearch web search + +`langsearch` calls the [LangSearch Web Search API](https://docs.langsearch.com/api/web-search-api). Configure it from **Settings → Web Search** in the TUI, with `kimi search set langsearch`, or by editing `config.toml`. + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `api_key` | `string` | — | LangSearch API key; required to activate this backend | +| `base_url` | `string` | `https://api.langsearch.com` | API base URL | +| `tier` | `string` | `free` | Rate-limit tier: `free`, `tier1`, `tier2`, or `tier3` | +| `freshness` | `string` | `noLimit` | Result age filter sent to LangSearch: `oneDay`, `oneWeek`, `oneMonth`, `oneYear`, or `noLimit` | +| `summary` | `boolean` | `true` | Request generated summaries and use them as result snippets when available | +| `count` | `integer` | `10` | Number of results per request, from `1` to `10` | +| `custom_headers` | `table` | — | Custom HTTP headers attached to each request | + +### Semantic rerank + +`rerank` is independent of the selected search backend. When enabled, it sends search results to the configured semantic reranker after either LangSearch or Moonshot returns them. Reranking is best-effort: if the rerank request fails, the original search result order is preserved. + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `enabled` | `boolean` | `true` | Whether to rerank search results | +| `provider` | `string` | — | Rerank provider; currently only `langsearch` is supported | +| `api_key` | `string` | — | Rerank API key; when omitted, reuses `services.langsearch.api_key` | +| `base_url` | `string` | `https://api.langsearch.com` | Rerank API base URL | +| `custom_headers` | `table` | — | Custom HTTP headers attached to each request | + +The following example activates LangSearch search and the [LangSearch Semantic Rerank API](https://docs.langsearch.com/api/semantic-rerank-api): + ```toml -[services.moonshot_search] -base_url = "https://api.moonshot.cn/v1/search" -api_key = "sk-xxx" +[experimental] +langsearch-web-search = true -[services.moonshot_fetch] -base_url = "https://api.moonshot.cn/v1/fetch" -api_key = "sk-xxx" +[services.langsearch] +api_key = "YOUR_API_KEY" +tier = "free" +count = 10 + +[services.rerank] +enabled = true +provider = "langsearch" +# api_key = "YOUR_RERANK_API_KEY" # Omit to reuse services.langsearch.api_key ``` +Use `kimi search status` to inspect the current configuration, `kimi search clear langsearch` to remove LangSearch, and `kimi search clear rerank` to remove rerank settings. These commands update `config.toml` directly. + ## `permission` `permission` sets permission rules that are automatically loaded when a session starts, controlling whether the Agent needs user confirmation before calling a tool. Rules are written as a `[[permission.rules]]` array of tables, matched in order — the first matching rule takes effect. diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index dce8bd813d..6e1062a919 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -120,7 +120,7 @@ In `stream-json` mode, regular replies produce an Assistant message; when the mo ## Subcommands -`kimi` provides the following subcommands: `login` (non-interactive login), `acp` (ACP IDE mode), `server` (run and manage the local REST/WebSocket/web service), `web` (open the web UI; runs the server in the foreground by default), `doctor` (validate configuration files), `export` (export a session), `migrate` (migrate legacy data), `upgrade` (check for updates), and `provider` (manage providers). +`kimi` provides the following subcommands: `login` (non-interactive login), `acp` (ACP IDE mode), `server` (run and manage the local REST/WebSocket/web service), `web` (open the web UI; runs the server in the foreground by default), `doctor` (validate configuration files), `export` (export a session), `migrate` (migrate legacy data), `upgrade` (check for updates), `search` (manage web search and rerank), and `provider` (manage providers). ### `kimi login` @@ -316,6 +316,31 @@ kimi vis 01HZ...XYZ kimi vis --host 0.0.0.0 --port 8123 --no-open ``` +### `kimi search` + +Manage the web search backend and semantic rerank without opening the TUI. In **Settings → Web Search**, the current providers appear at the top: **Web search provider** configures Moonshot or LangSearch, while **Rerank provider** independently manages the reranker status and API key. Moonshot setup can reuse the current Kimi Code OAuth login or accept an API key for the China or Global API region. Both the CLI and TUI persist changes to `[services]` in `config.toml`. LangSearch search and rerank are experimental; enable **LangSearch web search** under **Settings → Experiments** or set `KIMI_CODE_EXPERIMENTAL_LANGSEARCH_WEB_SEARCH=1` before configuring them. + +| Command | Description | +| --- | --- | +| `kimi search status` | Show the active search backend and rerank status | +| `kimi search set langsearch --api-key ` | Configure LangSearch web search | +| `kimi search set rerank` | Configure LangSearch semantic rerank; reuses the search API key by default | +| `kimi search clear langsearch` | Remove `[services.langsearch]` and fall back to Moonshot when available | +| `kimi search clear rerank` | Remove `[services.rerank]` | +| `kimi search limits` | Show the published LangSearch quotas for each tier | + +`kimi search set langsearch` accepts `--tier ` and `--count <1-10>`. `kimi search set rerank` accepts `--provider langsearch`, an optional `--api-key `, and `--enabled `. + +```sh +kimi search set langsearch --api-key YOUR_API_KEY --tier free --count 10 +kimi search set rerank +kimi search status +kimi search clear rerank +kimi search clear langsearch +``` + +See [`services`](../configuration/config-files.md#services) for the complete configuration field reference and backend precedence. + ### `kimi provider` Manage providers in the shell — the non-interactive equivalent of `/provider` in the TUI. Suitable for scripted deployments, CI initialization, and one-line setup on a new machine. diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 496c9c571d..4015d9ba7f 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -260,7 +260,22 @@ display_name = "Kimi for Coding (custom)" ## `services` -`services` 配置网页搜索(`moonshot_search`)和网页抓取(`moonshot_fetch`)两项内置服务。只识别这两个固定 key,其他 key 会被忽略。两项字段相同: +`services` 配置内置网页搜索、网页抓取和可选的结果重排。可识别以下配置表: + +- **`moonshot_search`**:Moonshot 网页搜索后端。 +- **`moonshot_fetch`**:Moonshot 网页抓取后端。 +- **`langsearch`**:LangSearch 网页搜索后端。 +- **`rerank`**:可选的语义重排服务,可对任一搜索后端返回的结果重新排序。 + +LangSearch 搜索和重排属于实验功能,默认关闭。可在 **Settings → Experiments** 中启用 **LangSearch web search**,设置 `KIMI_CODE_EXPERIMENTAL_LANGSEARCH_WEB_SEARCH=1`,或在 `[experimental]` 下添加 `langsearch-web-search = true`。关闭该 flag 时,Moonshot 搜索仍可正常使用。 + +同时配置两个搜索后端且实验 flag 已启用时,`langsearch` 的优先级高于 `moonshot_search`。关闭该 flag 或移除 `langsearch` 后,如果 Moonshot 搜索凭据可用,运行时会回退到 Moonshot。 + +在 TUI 的 **Settings → Web Search** 中,顶部会显示当前搜索和重排供应商。使用 **Web search provider** 可配置或编辑 Moonshot 或 LangSearch;使用 **Rerank provider** 可独立配置、启用、禁用、编辑或移除语义重排。选择 Moonshot 时,可以复用当前 Kimi Code OAuth 登录,也可以为中国区或全球区 API 配置密钥。 + +### Moonshot 服务 + +`moonshot_search` 和 `moonshot_fetch` 接受相同字段: | 字段 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | @@ -269,16 +284,51 @@ display_name = "Kimi for Coding (custom)" | `oauth` | `table` | 否 | OAuth 凭据引用,结构同 `providers.*.oauth` | | `custom_headers` | `table` | 否 | 请求时附加的自定义 HTTP 头 | +### LangSearch 网页搜索 + +`langsearch` 调用 [LangSearch Web Search API](https://docs.langsearch.com/api/web-search-api)。可以在 TUI 的 **Settings → Web Search** 中配置,也可以运行 `kimi search set langsearch` 或直接编辑 `config.toml`。 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `api_key` | `string` | — | LangSearch API 密钥;激活该后端时必填 | +| `base_url` | `string` | `https://api.langsearch.com` | API 基础 URL | +| `tier` | `string` | `free` | 限流档位:`free`、`tier1`、`tier2` 或 `tier3` | +| `freshness` | `string` | `noLimit` | 发送给 LangSearch 的结果时效筛选:`oneDay`、`oneWeek`、`oneMonth`、`oneYear` 或 `noLimit` | +| `summary` | `boolean` | `true` | 请求生成摘要,并在有摘要时将其用作结果片段 | +| `count` | `integer` | `10` | 每次请求的结果数量,范围为 `1` 到 `10` | +| `custom_headers` | `table` | — | 请求时附加的自定义 HTTP 头 | + +### 语义重排 + +`rerank` 与所选搜索后端相互独立。启用后,无论结果来自 LangSearch 还是 Moonshot,都会在搜索完成后发送给配置的语义重排服务。重排采用尽力而为策略:如果重排请求失败,会保留原始搜索结果顺序。 + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `enabled` | `boolean` | `true` | 是否对搜索结果进行重排 | +| `provider` | `string` | — | 重排供应商;目前仅支持 `langsearch` | +| `api_key` | `string` | — | 重排 API 密钥;省略时复用 `services.langsearch.api_key` | +| `base_url` | `string` | `https://api.langsearch.com` | 重排 API 基础 URL | +| `custom_headers` | `table` | — | 请求时附加的自定义 HTTP 头 | + +以下示例会启用 LangSearch 搜索和 [LangSearch Semantic Rerank API](https://docs.langsearch.com/api/semantic-rerank-api): + ```toml -[services.moonshot_search] -base_url = "https://api.moonshot.cn/v1/search" -api_key = "sk-xxx" +[experimental] +langsearch-web-search = true -[services.moonshot_fetch] -base_url = "https://api.moonshot.cn/v1/fetch" -api_key = "sk-xxx" +[services.langsearch] +api_key = "YOUR_API_KEY" +tier = "free" +count = 10 + +[services.rerank] +enabled = true +provider = "langsearch" +# api_key = "YOUR_RERANK_API_KEY" # 省略时复用 services.langsearch.api_key ``` +运行 `kimi search status` 可查看当前配置;`kimi search clear langsearch` 会移除 LangSearch,`kimi search clear rerank` 会移除重排配置。这些命令会直接更新 `config.toml`。 + ## `permission` `permission` 设置会话启动时自动加载的权限规则,控制 Agent 调用工具时是否需要用户确认。规则用 `[[permission.rules]]` 数组表写出,按顺序匹配,第一条命中即生效。 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index 3470eac198..74c4811ab2 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -120,7 +120,7 @@ kimi -p "List changed files" --output-format stream-json ## 子命令 -`kimi` 提供以下子命令:`login`(非交互式登录)、`acp`(ACP IDE 模式)、`server`(运行并管理本地 REST/WebSocket/web 服务)、`web`(打开 web UI,默认前台运行服务)、`doctor`(校验配置文件)、`export`(导出会话)、`migrate`(迁移旧版数据)、`upgrade`(检查更新)、`provider`(管理供应商)。 +`kimi` 提供以下子命令:`login`(非交互式登录)、`acp`(ACP IDE 模式)、`server`(运行并管理本地 REST/WebSocket/web 服务)、`web`(打开 web UI,默认前台运行服务)、`doctor`(校验配置文件)、`export`(导出会话)、`migrate`(迁移旧版数据)、`upgrade`(检查更新)、`search`(管理网页搜索和重排)、`provider`(管理供应商)。 ### `kimi login` @@ -316,6 +316,31 @@ kimi vis 01HZ...XYZ kimi vis --host 0.0.0.0 --port 8123 --no-open ``` +### `kimi search` + +无需打开 TUI 即可管理网页搜索后端和语义重排。在 **Settings → Web Search** 中,顶部会显示当前供应商:**Web search provider** 用于配置 Moonshot 或 LangSearch,**Rerank provider** 则独立管理重排状态和 API 密钥。Moonshot 配置可以复用当前 Kimi Code OAuth 登录,也可以使用中国区或全球区 API 密钥。CLI 和 TUI 都会把更改持久化到 `config.toml` 的 `[services]` 中。LangSearch 搜索和重排属于实验功能;配置前请在 **Settings → Experiments** 中启用 **LangSearch web search**,或设置 `KIMI_CODE_EXPERIMENTAL_LANGSEARCH_WEB_SEARCH=1`。 + +| 命令 | 说明 | +| --- | --- | +| `kimi search status` | 显示当前搜索后端和重排状态 | +| `kimi search set langsearch --api-key ` | 配置 LangSearch 网页搜索 | +| `kimi search set rerank` | 配置 LangSearch 语义重排;默认复用搜索 API 密钥 | +| `kimi search clear langsearch` | 移除 `[services.langsearch]`,并在 Moonshot 可用时回退到 Moonshot | +| `kimi search clear rerank` | 移除 `[services.rerank]` | +| `kimi search limits` | 显示 LangSearch 各档位公布的配额 | + +`kimi search set langsearch` 接受 `--tier ` 和 `--count <1-10>`。`kimi search set rerank` 接受 `--provider langsearch`、可选的 `--api-key ` 以及 `--enabled `。 + +```sh +kimi search set langsearch --api-key YOUR_API_KEY --tier free --count 10 +kimi search set rerank +kimi search status +kimi search clear rerank +kimi search clear langsearch +``` + +完整配置字段和后端优先级见 [`services`](../configuration/config-files.md#services)。 + ### `kimi provider` 在 shell 中管理供应商,相当于 TUI 中 `/provider` 的非交互版本。适合脚本化部署、CI 初始化,以及在新机器上一行完成配置。 diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 2f4a57693a..b413fecd42 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -307,6 +307,10 @@ const ALLOWED_EXCEPTIONS = new Set([ // auth-independent `web` domain. 'auth>tool', 'auth>toolRegistry', + // `auth` owns the credential-backed LangSearch providers. The feature is + // unreleased, so the provider resolver reads the App-scoped flag service and + // contributes its flag definition from the same owning domain. + 'auth>flag', 'permissionGate>approval', // `permissionRules` (L3) persists the approval broker's `ApprovalResponse` // (Session, L7) verbatim in its wire-logged `PermissionApprovalResultRecord` diff --git a/packages/agent-core-v2/src/app/auth/configSection.ts b/packages/agent-core-v2/src/app/auth/configSection.ts index abfd7326c2..e2eea470be 100644 --- a/packages/agent-core-v2/src/app/auth/configSection.ts +++ b/packages/agent-core-v2/src/app/auth/configSection.ts @@ -2,16 +2,20 @@ * `auth` domain (L2) — `services` config-section schema and TOML transforms. * * Owns the `[services]` configuration section (`moonshot_search` / - * `moonshot_fetch`), mirroring v1's `ServicesConfigSchema`: the schema, and the - * snake_case ↔ camelCase TOML transforms (including the nested `oauth` and - * `custom_headers` normalization, with `custom_headers` record keys preserved - * verbatim). Self-registered at module load via `registerConfigSection`, so the - * `config` domain never imports this domain's types. + * `moonshot_fetch` / `langsearch`), mirroring v1's `ServicesConfigSchema`: the + * schema, and the snake_case ↔ camelCase TOML transforms (including the nested + * `oauth` and `custom_headers` normalization, with `custom_headers` record keys + * preserved verbatim). Self-registered at module load via `registerConfigSection`, + * so the `config` domain never imports this domain's types. * * The `auth` domain owns this section because its OAuth login/logout flows * provision and clear it (see `authService`) and its `WebSearchProviderService` - * consumes `moonshot_search`; the `web` domain reads `moonshot_fetch` from the - * same section. Bound at App scope. + * consumes `moonshot_search` and `langsearch`; the `web` domain reads + * `moonshot_fetch` from the same section. Bound at App scope. + * + * `langsearch` is an alternative web-search backend (LangSearch API) with + * optional rerank and per-tier rate limiting. It takes precedence over + * `moonshot_search` when configured. */ import { z } from 'zod'; @@ -41,10 +45,43 @@ export const MoonshotServiceConfigSchema = z.object({ export type MoonshotServiceConfig = z.infer; +export const LangSearchTierSchema = z.enum(['free', 'tier1', 'tier2', 'tier3']); +export const LangSearchFreshnessSchema = z.enum([ + 'oneDay', + 'oneWeek', + 'oneMonth', + 'oneYear', + 'noLimit', +]); + +export const LangSearchServiceConfigSchema = z.object({ + apiKey: z.string().optional(), + baseUrl: z.string().optional(), + tier: LangSearchTierSchema.optional(), + freshness: LangSearchFreshnessSchema.optional(), + summary: z.boolean().optional(), + count: z.number().int().min(1).max(10).optional(), + customHeaders: StringRecordSchema.optional(), +}); + +export type LangSearchServiceConfig = z.infer; + +export const RerankServiceConfigSchema = z.object({ + enabled: z.boolean().optional(), + provider: z.enum(['langsearch']).optional(), + apiKey: z.string().optional(), + baseUrl: z.string().optional(), + customHeaders: StringRecordSchema.optional(), +}); + +export type RerankServiceConfig = z.infer; + export const ServicesConfigSchema = z .object({ moonshotSearch: MoonshotServiceConfigSchema.optional(), moonshotFetch: MoonshotServiceConfigSchema.optional(), + langsearch: LangSearchServiceConfigSchema.optional(), + rerank: RerankServiceConfigSchema.optional(), }) .passthrough(); @@ -79,6 +116,8 @@ export const servicesToToml = (value: unknown, rawSnake: unknown): unknown => { const out = cloneRecord(rawSnake); writeService(out, 'moonshot_search', value['moonshotSearch']); writeService(out, 'moonshot_fetch', value['moonshotFetch']); + writeService(out, 'langsearch', value['langsearch']); + writeService(out, 'rerank', value['rerank']); return out; }; diff --git a/packages/agent-core-v2/src/app/auth/webSearch/flag.ts b/packages/agent-core-v2/src/app/auth/webSearch/flag.ts new file mode 100644 index 0000000000..b2cafc3411 --- /dev/null +++ b/packages/agent-core-v2/src/app/auth/webSearch/flag.ts @@ -0,0 +1,25 @@ +/** + * `auth/webSearch` experimental flag contribution. + * + * Gates the LangSearch search and semantic-rerank providers. Off by default; + * enable via `KIMI_CODE_EXPERIMENTAL_LANGSEARCH_WEB_SEARCH`, the master + * `KIMI_CODE_EXPERIMENTAL_FLAG`, or the `[experimental]` config section. + */ + +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const LANGSEARCH_WEB_SEARCH_FLAG_ID = 'langsearch-web-search'; +export const LANGSEARCH_WEB_SEARCH_FLAG_ENV = + 'KIMI_CODE_EXPERIMENTAL_LANGSEARCH_WEB_SEARCH'; + +export const langSearchWebSearchFlag: FlagDefinitionInput = { + id: LANGSEARCH_WEB_SEARCH_FLAG_ID, + title: 'LangSearch web search', + description: + 'Use LangSearch as a configurable WebSearch backend and optionally rerank search results with its semantic reranker.', + env: LANGSEARCH_WEB_SEARCH_FLAG_ENV, + default: false, + surface: 'both', +}; + +registerFlagDefinition(langSearchWebSearchFlag); diff --git a/packages/agent-core-v2/src/app/auth/webSearch/providers/langsearch-rerank.ts b/packages/agent-core-v2/src/app/auth/webSearch/providers/langsearch-rerank.ts new file mode 100644 index 0000000000..58fc2aa009 --- /dev/null +++ b/packages/agent-core-v2/src/app/auth/webSearch/providers/langsearch-rerank.ts @@ -0,0 +1,140 @@ +/** + * `auth` domain — LangSearch rerank provider. + * + * Implements {@link RerankProvider} against the LangSearch rerank API + * (https://api.langsearch.com/v1/rerank). Reorders a set of search results by + * semantic relevance to a query. The search wrapper treats rerank failures as + * non-fatal and preserves the original order. + * + * Rate limiting is enforced client-side per the configured `tier` (see + * https://docs.langsearch.com/limits/api-limits) via the shared `RateLimiter`. + */ + +import type { RerankProvider } from '../rerank'; +import type { WebSearchResult } from '../tools/web-search'; +import { RateLimiter, TIER_LIMITS, type LangSearchTier } from './rateLimiter'; + +export interface LangSearchRerankProviderOptions { + apiKey: string; + baseUrl?: string; + tier?: LangSearchTier; + customHeaders?: Record; + fetchImpl?: typeof fetch; + limiter?: RateLimiter; +} + +interface LangSearchRerankResult { + index: number; + relevance_score?: number; +} + +interface LangSearchRerankResponse { + code?: number; + msg?: string | null; + results?: LangSearchRerankResult[]; +} + +const DEFAULT_BASE_URL = 'https://api.langsearch.com'; +const RERANK_MODEL = 'langsearch-reranker-v1'; + +export class LangSearchRerankProvider implements RerankProvider { + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly tier: LangSearchTier; + private readonly customHeaders: Record; + private readonly fetchImpl: typeof fetch; + private readonly limiter: RateLimiter; + + constructor(options: LangSearchRerankProviderOptions) { + this.apiKey = options.apiKey; + this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); + this.tier = options.tier ?? 'free'; + this.customHeaders = options.customHeaders ?? {}; + this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + this.limiter = options.limiter ?? new RateLimiter(TIER_LIMITS[this.tier], this.tier); + } + + async rerank( + query: string, + results: WebSearchResult[], + signal?: AbortSignal, + ): Promise { + if (results.length === 0) return results; + + const documents = results.map((r) => r.snippet || r.title); + const body = JSON.stringify({ + model: RERANK_MODEL, + query, + documents, + top_n: results.length, + return_documents: false, + }); + + const response = await this.post('/v1/rerank', body, signal); + + if (response.status !== 200) { + throw new Error( + `LangSearch rerank request failed: HTTP ${String(response.status)}. ${await safeReadText(response)}`.trim(), + ); + } + + const json = (await response.json()) as LangSearchRerankResponse; + assertLangSearchSuccess('rerank request', json.code, json.msg); + const ranked = json.results; + if (!Array.isArray(ranked)) return results; + + const reordered: WebSearchResult[] = []; + const used = new Set(); + const sorted = ranked.toSorted( + (a, b) => (b.relevance_score ?? 0) - (a.relevance_score ?? 0), + ); + for (const entry of sorted) { + if (Number.isInteger(entry.index) && entry.index >= 0 && entry.index < results.length) { + if (!used.has(entry.index)) { + reordered.push(results[entry.index]!); + used.add(entry.index); + } + } + } + for (let i = 0; i < results.length; i++) { + if (!used.has(i)) reordered.push(results[i]!); + } + return reordered; + } + + private async post( + path: string, + bodyJson: string, + signal: AbortSignal | undefined, + ): Promise { + await this.limiter.acquire(signal); + return this.fetchImpl(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + ...this.customHeaders, + }, + body: bodyJson, + signal, + }); + } +} + +function assertLangSearchSuccess( + operation: string, + code: number | undefined, + message: string | null | undefined, +): void { + if (code === undefined || code === 200) return; + const detail = typeof message === 'string' && message.length > 0 ? ` ${message}` : ''; + throw new Error(`LangSearch ${operation} failed: API code ${String(code)}.${detail}`); +} + +async function safeReadText(response: Response): Promise { + try { + return await response.text(); + } catch { + return ''; + } +} diff --git a/packages/agent-core-v2/src/app/auth/webSearch/providers/langsearch-web-search.ts b/packages/agent-core-v2/src/app/auth/webSearch/providers/langsearch-web-search.ts new file mode 100644 index 0000000000..d6e0c34a88 --- /dev/null +++ b/packages/agent-core-v2/src/app/auth/webSearch/providers/langsearch-web-search.ts @@ -0,0 +1,172 @@ +/** + * `auth` domain — LangSearch web-search provider. + * + * Implements {@link WebSearchProvider} against the LangSearch API + * (https://api.langsearch.com/v1/web-search). Rerank is handled by the separate + * `RerankProvider` / `RerankingWebSearchProvider` seam, not inline here. + * + * Rate limiting is enforced client-side per the configured `tier` (see + * https://docs.langsearch.com/limits/api-limits). The limiter is in-memory and + * best-effort — it prevents common 429s in normal single-session use but does + * not coordinate across processes. A 429 from the API is still handled with a + * clear error message suggesting a tier upgrade. + */ + +import { RateLimiter, TIER_LIMITS, type LangSearchTier } from './rateLimiter'; +import type { WebSearchProvider, WebSearchResult } from '../tools/web-search'; + +export type { LangSearchTier } from './rateLimiter'; + +export interface LangSearchWebSearchProviderOptions { + apiKey: string; + baseUrl?: string; + tier?: LangSearchTier; + freshness?: string; + summary?: boolean; + count?: number; + customHeaders?: Record; + fetchImpl?: typeof fetch; + limiter?: RateLimiter; +} + +interface LangSearchWebPageValue { + name?: string; + url?: string; + snippet?: string; + summary?: string; + siteName?: string; + datePublished?: string; +} + +interface LangSearchSearchResponse { + code?: number; + msg?: string | null; + data?: { + webPages?: { + value?: LangSearchWebPageValue[]; + }; + }; +} + +const DEFAULT_BASE_URL = 'https://api.langsearch.com'; + +export class LangSearchWebSearchProvider implements WebSearchProvider { + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly tier: LangSearchTier; + private readonly freshness: string; + private readonly summary: boolean; + private readonly count: number; + private readonly customHeaders: Record; + private readonly fetchImpl: typeof fetch; + private readonly limiter: RateLimiter; + + constructor(options: LangSearchWebSearchProviderOptions) { + this.apiKey = options.apiKey; + this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); + this.tier = options.tier ?? 'free'; + this.freshness = options.freshness ?? 'noLimit'; + this.summary = options.summary ?? true; + this.count = options.count ?? 10; + this.customHeaders = options.customHeaders ?? {}; + this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + this.limiter = options.limiter ?? new RateLimiter(TIER_LIMITS[this.tier], this.tier); + } + + async search( + query: string, + options?: { toolCallId?: string; signal?: AbortSignal }, + ): Promise { + return this.doSearch(query, options?.signal); + } + + private async doSearch( + query: string, + signal: AbortSignal | undefined, + ): Promise { + const body = JSON.stringify({ + query, + freshness: this.freshness, + summary: this.summary, + count: this.count, + }); + + const response = await this.post('/v1/web-search', body, signal); + + if (response.status === 401) { + const detail = await safeReadText(response); + throw new Error( + `LangSearch request failed: HTTP 401 (auth/unauthorized). ${detail}`.trim(), + ); + } + if (response.status === 429) { + const detail = await safeReadText(response); + throw new Error( + `LangSearch rate limit exceeded (tier: ${this.tier}). ${detail}`.trim(), + ); + } + if (response.status !== 200) { + const detail = await safeReadText(response); + throw new Error( + `LangSearch request failed: HTTP ${String(response.status)}. ${detail}`.trim(), + ); + } + + const json = (await response.json()) as LangSearchSearchResponse; + assertLangSearchSuccess('web search request', json.code, json.msg); + const raw = json.data?.webPages?.value; + if (!Array.isArray(raw)) return []; + + return raw.map((r): WebSearchResult => { + const snippet = this.summary && r.summary ? r.summary : (r.snippet ?? ''); + const out: WebSearchResult = { + title: r.name ?? '', + url: r.url ?? '', + snippet, + }; + if (typeof r.siteName === 'string' && r.siteName.length > 0) { + out.siteName = r.siteName; + } + if (typeof r.datePublished === 'string' && r.datePublished.length > 0) { + out.date = r.datePublished; + } + return out; + }); + } + + private async post( + path: string, + bodyJson: string, + signal: AbortSignal | undefined, + ): Promise { + await this.limiter.acquire(signal); + return this.fetchImpl(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + ...this.customHeaders, + }, + body: bodyJson, + signal, + }); + } +} + +function assertLangSearchSuccess( + operation: string, + code: number | undefined, + message: string | null | undefined, +): void { + if (code === undefined || code === 200) return; + const detail = typeof message === 'string' && message.length > 0 ? ` ${message}` : ''; + throw new Error(`LangSearch ${operation} failed: API code ${String(code)}.${detail}`); +} + +async function safeReadText(response: Response): Promise { + try { + return await response.text(); + } catch { + return ''; + } +} diff --git a/packages/agent-core-v2/src/app/auth/webSearch/providers/rateLimiter.ts b/packages/agent-core-v2/src/app/auth/webSearch/providers/rateLimiter.ts new file mode 100644 index 0000000000..caad33bd4a --- /dev/null +++ b/packages/agent-core-v2/src/app/auth/webSearch/providers/rateLimiter.ts @@ -0,0 +1,96 @@ +/** + * `auth` domain — shared rate limiter for LangSearch API providers. + * + * Simple in-memory sliding-window limiter enforcing QPS / QPM / QPD. Best-effort: + * does not coordinate across processes. Used by both the search and rerank + * providers to avoid 429s within a single session. + */ + +export type LangSearchTier = 'free' | 'tier1' | 'tier2' | 'tier3'; + +export interface TierLimit { + readonly qps: number; + readonly qpm: number; + readonly qpd: number; +} + +export const TIER_LIMITS: Record = { + free: { qps: 1, qpm: 60, qpd: 1000 }, + tier1: { qps: 5, qpm: 200, qpd: 2000 }, + tier2: { qps: 10, qpm: 500, qpd: 10000 }, + tier3: { qps: 30, qpm: 2000, qpd: 100000 }, +}; + +export class RateLimiter { + private readonly limit: TierLimit; + private readonly tierLabel: string; + private readonly secondWindow: number[] = []; + private readonly minuteWindow: number[] = []; + private readonly dayWindow: number[] = []; + + constructor(limit: TierLimit, tierLabel: string) { + this.limit = limit; + this.tierLabel = tierLabel; + } + + async acquire(signal?: AbortSignal): Promise { + for (;;) { + if (signal?.aborted) { + throw new DOMException('The operation was aborted.', 'AbortError'); + } + const now = Date.now(); + this.prune(now); + + if (this.dayWindow.length >= this.limit.qpd) { + throw new Error( + `LangSearch daily quota exhausted (tier: ${this.tierLabel}). Try again tomorrow or upgrade your tier.`, + ); + } + if (this.minuteWindow.length >= this.limit.qpm) { + await this.sleepUntil(this.minuteWindow[0]! + 60_000, signal); + continue; + } + if (this.secondWindow.length >= this.limit.qps) { + await this.sleepUntil(this.secondWindow[0]! + 1_000, signal); + continue; + } + + this.secondWindow.push(now); + this.minuteWindow.push(now); + this.dayWindow.push(now); + return; + } + } + + private prune(now: number): void { + const oneSecondAgo = now - 1_000; + const oneMinuteAgo = now - 60_000; + const oneDayAgo = now - 86_400_000; + while (this.secondWindow.length > 0 && this.secondWindow[0]! <= oneSecondAgo) { + this.secondWindow.shift(); + } + while (this.minuteWindow.length > 0 && this.minuteWindow[0]! <= oneMinuteAgo) { + this.minuteWindow.shift(); + } + while (this.dayWindow.length > 0 && this.dayWindow[0]! <= oneDayAgo) { + this.dayWindow.shift(); + } + } + + private async sleepUntil(target: number, signal?: AbortSignal): Promise { + const delay = Math.max(0, target - Date.now()); + if (delay <= 0) return; + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, delay); + const onAbort = () => { + clearTimeout(timer); + reject(new DOMException('The operation was aborted.', 'AbortError')); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) onAbort(); + }); + } +} diff --git a/packages/agent-core-v2/src/app/auth/webSearch/providers/reranking-web-search.ts b/packages/agent-core-v2/src/app/auth/webSearch/providers/reranking-web-search.ts new file mode 100644 index 0000000000..f9aafe3df2 --- /dev/null +++ b/packages/agent-core-v2/src/app/auth/webSearch/providers/reranking-web-search.ts @@ -0,0 +1,32 @@ +/** + * `auth` domain — `WebSearchProvider` wrapper that applies a rerank pass. + * + * Wraps any `WebSearchProvider` and reorders its results through a + * `RerankProvider` after each `search()`. Rerank failures are non-fatal: the + * original search-order results are returned unless the caller's abort signal + * fired. + */ + +import type { RerankProvider } from '../rerank'; +import type { WebSearchProvider, WebSearchResult } from '../tools/web-search'; + +export class RerankingWebSearchProvider implements WebSearchProvider { + constructor( + private readonly delegate: WebSearchProvider, + private readonly reranker: RerankProvider, + ) {} + + async search( + query: string, + options?: { toolCallId?: string; signal?: AbortSignal }, + ): Promise { + const results = await this.delegate.search(query, options); + if (results.length === 0) return results; + try { + return await this.reranker.rerank(query, results, options?.signal); + } catch (error) { + if (options?.signal?.aborted) throw error; + return results; + } + } +} diff --git a/packages/agent-core-v2/src/app/auth/webSearch/rerank.ts b/packages/agent-core-v2/src/app/auth/webSearch/rerank.ts new file mode 100644 index 0000000000..9debaf0839 --- /dev/null +++ b/packages/agent-core-v2/src/app/auth/webSearch/rerank.ts @@ -0,0 +1,25 @@ +/** + * `auth` domain (L2) — rerank provider contract and DI service identifier. + * + * Defines the `RerankProvider` interface (reorder search results by semantic + * relevance to a query) and the `IRerankService` DI token that resolves the + * configured rerank backend (or `undefined` when rerank is not configured). + * Bound at App scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { RateLimiter } from './providers/rateLimiter'; +import type { WebSearchResult } from './tools/web-search'; + +export interface RerankProvider { + rerank(query: string, results: WebSearchResult[], signal?: AbortSignal): Promise; +} + +export interface IRerankService { + readonly _serviceBrand: undefined; + getRerankProvider(limiter?: RateLimiter): RerankProvider | undefined; +} + +export const IRerankService: ServiceIdentifier = + createDecorator('rerankService'); diff --git a/packages/agent-core-v2/src/app/auth/webSearch/rerankService.ts b/packages/agent-core-v2/src/app/auth/webSearch/rerankService.ts new file mode 100644 index 0000000000..a7cf72f283 --- /dev/null +++ b/packages/agent-core-v2/src/app/auth/webSearch/rerankService.ts @@ -0,0 +1,55 @@ +/** + * `auth` domain (L2) — `IRerankService` implementation. + * + * Resolves the configured rerank backend from the `[services.rerank]` config + * section (read through `config`). When the section is absent, disabled, or its + * provider has no usable credential, yields `undefined` so search results are + * returned in their original order. The `langsearch` provider reuses the `langsearch` + * search section's `apiKey` when the rerank section does not define its own, + * and inherits its `tier` for rate limiting. Bound at App scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; + +import { SERVICES_SECTION, type ServicesConfig } from '../configSection'; +import { LANGSEARCH_WEB_SEARCH_FLAG_ID } from './flag'; +import { IRerankService } from './rerank'; +import { LangSearchRerankProvider } from './providers/langsearch-rerank'; +import type { RateLimiter } from './providers/rateLimiter'; + +export class RerankService implements IRerankService { + declare readonly _serviceBrand: undefined; + + constructor( + @IConfigService private readonly config: IConfigService, + @IFlagService private readonly flags: IFlagService, + ) {} + + getRerankProvider(limiter?: RateLimiter) { + if (!this.flags.enabled(LANGSEARCH_WEB_SEARCH_FLAG_ID)) return undefined; + const cfg = this.config.get(SERVICES_SECTION); + if (cfg?.rerank?.enabled === false || cfg?.rerank?.provider !== 'langsearch') { + return undefined; + } + + const apiKey = nonEmptyString(cfg.rerank.apiKey) ?? nonEmptyString(cfg.langsearch?.apiKey); + if (apiKey === undefined) return undefined; + return new LangSearchRerankProvider({ + apiKey, + baseUrl: cfg.rerank.baseUrl, + customHeaders: cfg.rerank.customHeaders, + tier: cfg.langsearch?.tier, + limiter, + }); + } +} + +function nonEmptyString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +} + +registerScopedService(LifecycleScope.App, IRerankService, RerankService, InstantiationType.Eager, 'auth'); diff --git a/packages/agent-core-v2/src/app/auth/webSearch/tools/web-search.ts b/packages/agent-core-v2/src/app/auth/webSearch/tools/web-search.ts index f5ce5da372..2e4006440b 100644 --- a/packages/agent-core-v2/src/app/auth/webSearch/tools/web-search.ts +++ b/packages/agent-core-v2/src/app/auth/webSearch/tools/web-search.ts @@ -3,16 +3,16 @@ * `WebSearchProvider` contract. * * Defines the `WebSearch` tool and the host-injected `WebSearchProvider` - * interface (plus `WebSearchResult`). Web search needs an authenticated - * Moonshot backend, so the tool lives in the KimiOAuth `auth` domain: it reads - * its provider from the App-scope `IWebSearchProviderService` at - * registry-construction time and self-registers via `registerTool(...)` at - * module load, but only when a provider is configured (there is no local - * search backend). + * interface (plus `WebSearchResult`). The tool lives in the cross-cutting + * `auth` domain because its Moonshot and LangSearch backends use credentials + * from the App-scope `IWebSearchProviderService`. It self-registers through + * `toolRegistry` only when a provider is configured; no local search backend + * is exposed. Bound at Agent scope through the tool registry. */ import { z } from 'zod'; +import { registerTool } from '#/agent/toolRegistry/toolContribution'; import { toInputJsonSchema } from '#/tool/input-schema'; import { literalRulePattern, matchesGlobRuleSubject } from '#/tool/rule-match'; import { @@ -23,7 +23,6 @@ import { type ToolExecution, } from '#/tool/toolContract'; import { ToolResultBuilder } from '#/tool/result-builder'; -import { registerTool } from '#/agent/toolRegistry/toolContribution'; import { IWebSearchProviderService } from '../webSearch'; import DESCRIPTION from './web-search.md?raw'; diff --git a/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts b/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts index 1fffd46e6a..9bc33f40c0 100644 --- a/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts +++ b/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts @@ -1,22 +1,28 @@ /** * `auth` domain (cross-cutting) — `IWebSearchProviderService` implementation. * - * Resolves the `WebSearch` backend from two sources, in precedence order: - * (1) an explicit `[services.moonshot_search]` config section (read through - * `config`, mirroring v1 where that section is the single authoritative - * web-search source) — built with its `apiKey` and/or an `oauth` ref resolved - * through `IOAuthService.resolveTokenProvider(...)`; and (2) the managed Kimi - * OAuth provider (`managed:kimi-code`) when it carries an `oauth` ref (the - * state after a successful Kimi login), whose bearer token comes from + * Resolves the `WebSearch` backend from three sources, in precedence order: + * (1) an explicit `[services.langsearch]` config section (read through `config`) + * — built with its `apiKey`; takes precedence over Moonshot when configured; + * (2) an explicit + * `[services.moonshot_search]` config section (read through `config`, mirroring + * v1 where that section is the single authoritative web-search source) — built + * with its `apiKey` and/or an `oauth` ref resolved through + * `IOAuthService.resolveTokenProvider(...)`; and (3) the managed Kimi OAuth + * provider (`managed:kimi-code`) when it carries an `oauth` ref (the state + * after a successful Kimi login), whose bearer token comes from * `IOAuthService.resolveTokenProvider(...)` and whose base URL is derived from - * the provider's `baseUrl`. The explicit config wins over the managed - * derivation. Both use the host's Kimi identity headers (`IHostRequestHeaders`, - * mirroring v1's `kimiRequestHeaders`) as default headers. When neither source - * is configured it yields `undefined` so the self-registering `WebSearch` tool - * stays hidden. Owns no tool registration — the `WebSearch` tool self-registers - * via `registerTool(...)` and reads this service from the Agent-scope accessor. - * Tests and hosts that need a custom backend bind `IWebSearchProviderService` - * directly. Bound at App scope. + * the provider's `baseUrl`. The explicit configs win over the managed + * derivation. Both Moonshot sources use the host's Kimi identity headers + * (`IHostRequestHeaders`, mirroring v1's `kimiRequestHeaders`) as default + * headers. When a rerank backend is configured via `IRerankService`, the + * resolved provider is wrapped in a `RerankingWebSearchProvider` so its + * results are reordered by semantic relevance to the query. When none of the + * sources is configured it yields `undefined` so the self-registering + * `WebSearch` tool stays hidden. Owns no tool registration — + * the `WebSearch` tool self-registers via `registerTool(...)` and reads this + * service from the Agent-scope accessor. Tests and hosts that need a custom + * backend bind `IWebSearchProviderService` directly. Bound at App scope. */ import { @@ -28,26 +34,83 @@ import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IOAuthService } from '#/app/auth/auth'; import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; import { IHostRequestHeaders } from '#/app/model/hostRequestHeaders'; import { IProviderService } from '#/app/provider/provider'; import { SERVICES_SECTION, type ServicesConfig } from '../configSection'; +import { LANGSEARCH_WEB_SEARCH_FLAG_ID } from './flag'; +import { IRerankService } from './rerank'; +import { LangSearchWebSearchProvider } from './providers/langsearch-web-search'; import { MoonshotWebSearchProvider } from './providers/moonshot-web-search'; +import { RateLimiter, TIER_LIMITS, type LangSearchTier } from './providers/rateLimiter'; +import { RerankingWebSearchProvider } from './providers/reranking-web-search'; import type { WebSearchProvider } from './tools/web-search'; import { IWebSearchProviderService } from './webSearch'; export class WebSearchProviderService implements IWebSearchProviderService { declare readonly _serviceBrand: undefined; + private langSearchLimiter: { readonly tier: LangSearchTier; readonly value: RateLimiter } | undefined; constructor( @IProviderService private readonly providers: IProviderService, @IOAuthService private readonly oauth: IOAuthService, @IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders, @IConfigService private readonly config: IConfigService, + @IFlagService private readonly flags: IFlagService, + @IRerankService private readonly rerankService: IRerankService, ) {} getWebSearchProvider(): WebSearchProvider | undefined { - return this.fromServicesConfig() ?? this.fromManagedOAuth(); + const services = this.config.get(SERVICES_SECTION); + const limiter = this.resolveLangSearchLimiter(services); + const search = + this.fromLangSearchConfig(services, limiter) ?? + this.fromServicesConfig() ?? + this.fromManagedOAuth(); + if (search === undefined) return undefined; + const reranker = this.rerankService.getRerankProvider(limiter); + return reranker !== undefined ? new RerankingWebSearchProvider(search, reranker) : search; + } + + private fromLangSearchConfig( + services: ServicesConfig | undefined, + limiter: RateLimiter | undefined, + ): WebSearchProvider | undefined { + if (!this.flags.enabled(LANGSEARCH_WEB_SEARCH_FLAG_ID)) return undefined; + const cfg = services?.langsearch; + const apiKey = nonEmptyString(cfg?.apiKey); + if (apiKey === undefined) return undefined; + return new LangSearchWebSearchProvider({ + apiKey, + baseUrl: cfg?.baseUrl, + tier: cfg?.tier, + freshness: cfg?.freshness, + summary: cfg?.summary, + count: cfg?.count, + customHeaders: cfg?.customHeaders, + limiter, + }); + } + + private resolveLangSearchLimiter(services: ServicesConfig | undefined): RateLimiter | undefined { + if (!this.flags.enabled(LANGSEARCH_WEB_SEARCH_FLAG_ID)) return undefined; + const searchConfigured = nonEmptyString(services?.langsearch?.apiKey) !== undefined; + const rerankConfigured = + services?.rerank?.enabled !== false && + services?.rerank?.provider === 'langsearch' && + (nonEmptyString(services.rerank.apiKey) ?? nonEmptyString(services.langsearch?.apiKey)) !== + undefined; + if (!searchConfigured && !rerankConfigured) return undefined; + + const tier = services?.langsearch?.tier ?? 'free'; + if (this.langSearchLimiter?.tier !== tier) { + this.langSearchLimiter = { + tier, + value: new RateLimiter(TIER_LIMITS[tier], tier), + }; + } + return this.langSearchLimiter.value; } private fromServicesConfig(): WebSearchProvider | undefined { diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 8ecf108c84..1c3f51ad52 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -300,13 +300,20 @@ export * from '#/persistence/backends/node-fs/workspaceLocalConfigService'; import '#/persistence/backends/minidb/flag'; export * from '#/persistence/backends/minidb/miniDbQueryStore'; export * from '#/persistence/backends/memory/inMemoryStorageService'; +import '#/app/auth/webSearch/flag'; import '#/app/auth/webSearch/tools/web-search'; export * from '#/app/auth/auth'; export * from '#/app/auth/authService'; export * from '#/app/auth/configSection'; export * from '#/app/auth/webSearch/webSearch'; export * from '#/app/auth/webSearch/webSearchService'; +export * from '#/app/auth/webSearch/rerank'; +export * from '#/app/auth/webSearch/rerankService'; export * from '#/app/auth/webSearch/providers/moonshot-web-search'; +export * from '#/app/auth/webSearch/providers/langsearch-web-search'; +export * from '#/app/auth/webSearch/providers/langsearch-rerank'; +export * from '#/app/auth/webSearch/providers/reranking-web-search'; +export * from '#/app/auth/webSearch/providers/rateLimiter'; export * from '#/app/authLegacy/authLegacy'; export * from '#/app/authLegacy/authLegacyService'; export * from '#/app/file/fileService'; diff --git a/packages/agent-core-v2/test/app/auth/auth.test.ts b/packages/agent-core-v2/test/app/auth/auth.test.ts index 6081ef9243..fd6e40f672 100644 --- a/packages/agent-core-v2/test/app/auth/auth.test.ts +++ b/packages/agent-core-v2/test/app/auth/auth.test.ts @@ -26,11 +26,14 @@ import { } from '#/app/auth/configSection'; import { IWebSearchProviderService } from '#/app/auth/webSearch/webSearch'; import { WebSearchProviderService } from '#/app/auth/webSearch/webSearchService'; +import { IRerankService } from '#/app/auth/webSearch/rerank'; +import { RerankService } from '#/app/auth/webSearch/rerankService'; import { IAuthLegacyService } from '#/app/authLegacy/authLegacy'; import { AuthLegacyService } from '#/app/authLegacy/authLegacyService'; import { IConfigService } from '#/app/config/config'; import { ConfigRegistry } from '#/app/config/configService'; import { type DomainEvent, IEventService } from '#/app/event/event'; +import { IFlagService } from '#/app/flag/flag'; import { ILogService } from '#/_base/log/log'; import { IHostRequestHeaders } from '#/app/model/hostRequestHeaders'; import { MODELS_SECTION, type ModelAlias } from '#/app/model/model'; @@ -38,6 +41,7 @@ import { IPlatformService, type PlatformConfig } from '#/app/platform/platform'; import { IProviderService, type ProviderConfig, type ProvidersChangedEvent } from '#/app/provider/provider'; import { registerBootstrapServices } from '../bootstrap/stubs'; +import { stubFlag } from '../flag/stubs'; import { registerTelemetryServices } from '../telemetry/stubs'; const OAUTH_PROVIDER = 'managed:kimi-code'; @@ -766,11 +770,13 @@ describe('WebSearchProviderService', () => { let providers: Record; let servicesConfig: ServicesConfig | undefined; let resolveTokenProvider: ReturnType; + let langSearchFlagEnabled: boolean; beforeEach(() => { disposables = new DisposableStore(); providers = {}; servicesConfig = undefined; + langSearchFlagEnabled = true; resolveTokenProvider = vi .fn() .mockReturnValue({ getAccessToken: async () => 'access-token' }); @@ -793,7 +799,9 @@ describe('WebSearchProviderService', () => { get: ((domain: string) => domain === SERVICES_SECTION ? servicesConfig : undefined) as IConfigService['get'], }); + reg.defineInstance(IFlagService, stubFlag(() => langSearchFlagEnabled)); reg.define(IWebSearchProviderService, WebSearchProviderService); + reg.define(IRerankService, RerankService); }, }); }); @@ -971,6 +979,185 @@ describe('WebSearchProviderService', () => { expect(createService().getWebSearchProvider()).toBeUndefined(); expect(resolveTokenProvider).not.toHaveBeenCalled(); }); + + // --- LangSearch provider tests --- + + function langsearchFetchMock( + searchResults: Array<{ name?: string; url?: string; snippet?: string; summary?: string; datePublished?: string }>, + rerankResults?: Array<{ index: number; relevance_score?: number }>, + ): ReturnType { + return vi.fn().mockImplementation((url: string) => { + if (url.endsWith('/v1/web-search')) { + return Promise.resolve({ + status: 200, + json: async () => ({ + code: 200, + data: { webPages: { value: searchResults } }, + }), + }); + } + if (url.endsWith('/v1/rerank')) { + if (rerankResults === undefined) { + return Promise.resolve({ status: 500, text: async () => 'rerank error' }); + } + return Promise.resolve({ + status: 200, + json: async () => ({ code: 200, results: rerankResults }), + }); + } + return Promise.resolve({ status: 404, text: async () => 'not found' }); + }); + } + + it('returns undefined when langsearch has no apiKey', () => { + servicesConfig = { langsearch: { baseUrl: 'https://api.langsearch.com' } }; + expect(createService().getWebSearchProvider()).toBeUndefined(); + }); + + it('does not activate LangSearch while its experimental flag is disabled', () => { + langSearchFlagEnabled = false; + servicesConfig = { langsearch: { apiKey: 'ls-key' } }; + expect(createService().getWebSearchProvider()).toBeUndefined(); + }); + + it('rejects unsuccessful LangSearch API codes returned with HTTP 200', async () => { + servicesConfig = { langsearch: { apiKey: 'ls-key' } }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + status: 200, + json: async () => ({ code: 500, msg: 'backend unavailable' }), + }), + ); + + await expect(createService().getWebSearchProvider()!.search('query')).rejects.toThrow( + 'LangSearch web search request failed: API code 500. backend unavailable', + ); + }); + + it('builds a LangSearch provider from services.langsearch apiKey with rerank', async () => { + servicesConfig = { + langsearch: { apiKey: 'ls-key', tier: 'tier2' }, + rerank: { enabled: true, provider: 'langsearch' }, + }; + const fetchMock = langsearchFetchMock( + [ + { name: 'Result A', url: 'https://a.example.com', snippet: 'Snippet A' }, + { name: 'Result B', url: 'https://b.example.com', snippet: 'Snippet B' }, + ], + [ + { index: 1, relevance_score: 0.9 }, + { index: 0, relevance_score: 0.5 }, + ], + ); + vi.stubGlobal('fetch', fetchMock); + + const provider = createService().getWebSearchProvider(); + expect(provider).not.toBeUndefined(); + const results = await provider!.search('query'); + + // Reranked: index 1 first, then index 0. + expect(results).toEqual([ + { title: 'Result B', url: 'https://b.example.com', snippet: 'Snippet B' }, + { title: 'Result A', url: 'https://a.example.com', snippet: 'Snippet A' }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + const [searchUrl, searchInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(searchUrl).toBe('https://api.langsearch.com/v1/web-search'); + expect((searchInit.headers as Record)['Authorization']).toBe('Bearer ls-key'); + const searchBody = JSON.parse(searchInit.body as string); + expect(searchBody.query).toBe('query'); + // Rerank call reuses the langsearch apiKey when rerank has none of its own. + const [, rerankInit] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect((rerankInit.headers as Record)['Authorization']).toBe('Bearer ls-key'); + }); + + it('ignores fractional LangSearch rerank indexes', async () => { + servicesConfig = { + langsearch: { apiKey: 'ls-key' }, + rerank: { enabled: true, provider: 'langsearch' }, + }; + const fetchMock = langsearchFetchMock( + [ + { name: 'Result A', url: 'https://a.example.com', snippet: 'Snippet A' }, + { name: 'Result B', url: 'https://b.example.com', snippet: 'Snippet B' }, + ], + [ + { index: 0.5, relevance_score: 1 }, + { index: 1, relevance_score: 0.9 }, + ], + ); + vi.stubGlobal('fetch', fetchMock); + + await expect(createService().getWebSearchProvider()!.search('query')).resolves.toEqual([ + { title: 'Result B', url: 'https://b.example.com', snippet: 'Snippet B' }, + { title: 'Result A', url: 'https://a.example.com', snippet: 'Snippet A' }, + ]); + }); + + it('LangSearch provider skips rerank when services.rerank is disabled', async () => { + servicesConfig = { + langsearch: { apiKey: 'ls-key' }, + rerank: { enabled: false, provider: 'langsearch' }, + }; + const fetchMock = langsearchFetchMock( + [{ name: 'Result A', url: 'https://a.example.com', snippet: 'Snippet A' }], + ); + vi.stubGlobal('fetch', fetchMock); + + const provider = createService().getWebSearchProvider(); + const results = await provider!.search('query'); + + expect(results).toEqual([ + { title: 'Result A', url: 'https://a.example.com', snippet: 'Snippet A' }, + ]); + // Only one fetch — the search. No rerank call. + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]![0]).toBe('https://api.langsearch.com/v1/web-search'); + }); + + it('LangSearch rerank failure falls back to original search order', async () => { + servicesConfig = { + langsearch: { apiKey: 'ls-key' }, + rerank: { enabled: true, provider: 'langsearch' }, + }; + // rerankResults undefined → mock returns 500 for /v1/rerank + const fetchMock = langsearchFetchMock( + [ + { name: 'Result A', url: 'https://a.example.com', snippet: 'Snippet A' }, + { name: 'Result B', url: 'https://b.example.com', snippet: 'Snippet B' }, + ], + undefined, + ); + vi.stubGlobal('fetch', fetchMock); + + const provider = createService().getWebSearchProvider(); + const results = await provider!.search('query'); + + // Original order preserved when rerank fails. + expect(results).toEqual([ + { title: 'Result A', url: 'https://a.example.com', snippet: 'Snippet A' }, + { title: 'Result B', url: 'https://b.example.com', snippet: 'Snippet B' }, + ]); + }); + + it('langsearch takes precedence over moonshot_search', async () => { + servicesConfig = { + langsearch: { apiKey: 'ls-key' }, + moonshotSearch: { baseUrl: 'https://moonshot.example.com/search', apiKey: 'ms-key' }, + }; + const fetchMock = langsearchFetchMock([ + { name: 'LS Result', url: 'https://ls.example.com', snippet: 'LS snippet' }, + ]); + vi.stubGlobal('fetch', fetchMock); + + const provider = createService().getWebSearchProvider(); + expect(provider).not.toBeUndefined(); + await provider!.search('query'); + + // First call should be to LangSearch, not Moonshot. + expect(fetchMock.mock.calls[0]![0]).toBe('https://api.langsearch.com/v1/web-search'); + }); }); describe('services config section', () => { @@ -982,15 +1169,22 @@ describe('services config section', () => { registry.validate(SERVICES_SECTION, { moonshotSearch: { baseUrl: 'https://api.example.com/search', apiKey: 'search-key' }, moonshotFetch: { baseUrl: 'https://api.example.com/fetch' }, + langsearch: { apiKey: 'ls-key', tier: 'tier1', count: 5 }, + rerank: { enabled: true, provider: 'langsearch' }, customService: { baseUrl: 'https://service.example.com', retries: 3 }, }), ).toEqual({ moonshotSearch: { baseUrl: 'https://api.example.com/search', apiKey: 'search-key' }, moonshotFetch: { baseUrl: 'https://api.example.com/fetch' }, + langsearch: { apiKey: 'ls-key', tier: 'tier1', count: 5 }, + rerank: { enabled: true, provider: 'langsearch' }, customService: { baseUrl: 'https://service.example.com', retries: 3 }, }); expect(() => - registry.validate(SERVICES_SECTION, { moonshotSearch: { baseUrl: 42 } }), + registry.validate(SERVICES_SECTION, { langsearch: { tier: 'invalid' } }), + ).toThrow(); + expect(() => + registry.validate(SERVICES_SECTION, { langsearch: { count: 11 } }), ).toThrow(); }); @@ -1004,6 +1198,8 @@ describe('services config section', () => { oauth: { storage: 'file', key: 'oauth/kimi-code', oauth_host: 'https://auth.example.com' }, }, moonshot_fetch: { base_url: 'https://api.example.com/fetch', api_key: 'fetch-key' }, + langsearch: { api_key: 'ls-key', tier: 'tier1', count: 5 }, + rerank: { enabled: true, provider: 'langsearch' }, }), ).toEqual({ moonshotSearch: { @@ -1013,6 +1209,8 @@ describe('services config section', () => { oauth: { storage: 'file', key: 'oauth/kimi-code', oauthHost: 'https://auth.example.com' }, }, moonshotFetch: { baseUrl: 'https://api.example.com/fetch', apiKey: 'fetch-key' }, + langsearch: { apiKey: 'ls-key', tier: 'tier1', count: 5 }, + rerank: { enabled: true, provider: 'langsearch' }, }); }); diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 06e7c9d307..771b1a5839 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -210,9 +210,42 @@ export const MoonshotServiceConfigSchema = z.object({ export type MoonshotServiceConfig = z.infer; +export const LangSearchTierSchema = z.enum(['free', 'tier1', 'tier2', 'tier3']); +export const LangSearchFreshnessSchema = z.enum([ + 'oneDay', + 'oneWeek', + 'oneMonth', + 'oneYear', + 'noLimit', +]); + +export const LangSearchServiceConfigSchema = z.object({ + apiKey: z.string().optional(), + baseUrl: z.string().optional(), + tier: LangSearchTierSchema.optional(), + freshness: LangSearchFreshnessSchema.optional(), + summary: z.boolean().optional(), + count: z.number().int().min(1).max(10).optional(), + customHeaders: StringRecordSchema.optional(), +}); + +export type LangSearchServiceConfig = z.infer; + +export const RerankServiceConfigSchema = z.object({ + enabled: z.boolean().optional(), + provider: z.enum(['langsearch']).optional(), + apiKey: z.string().optional(), + baseUrl: z.string().optional(), + customHeaders: StringRecordSchema.optional(), +}); + +export type RerankServiceConfig = z.infer; + export const ServicesConfigSchema = z.object({ moonshotSearch: MoonshotServiceConfigSchema.optional(), moonshotFetch: MoonshotServiceConfigSchema.optional(), + langsearch: LangSearchServiceConfigSchema.optional(), + rerank: RerankServiceConfigSchema.optional(), }); export type ServicesConfig = z.infer; @@ -325,9 +358,13 @@ const ImageConfigPatchSchema = ImageConfigSchema.partial(); const ModelCatalogConfigPatchSchema = ModelCatalogConfigSchema.partial(); const ExperimentalConfigPatchSchema = ExperimentalConfigSchema; const MoonshotServiceConfigPatchSchema = MoonshotServiceConfigSchema.partial(); +const LangSearchServiceConfigPatchSchema = LangSearchServiceConfigSchema.partial(); +const RerankServiceConfigPatchSchema = RerankServiceConfigSchema.partial(); const ServicesConfigPatchSchema = z.object({ moonshotSearch: MoonshotServiceConfigPatchSchema.optional(), moonshotFetch: MoonshotServiceConfigPatchSchema.optional(), + langsearch: LangSearchServiceConfigPatchSchema.optional(), + rerank: RerankServiceConfigPatchSchema.optional(), }); export const KimiConfigPatchSchema = z diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index edee21a041..105f504f08 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -13,6 +13,7 @@ import { type HookDefConfig, type ImageConfig, type KimiConfig, + type LangSearchServiceConfig, type LoopControl, type ModelAlias, type MoonshotServiceConfig, @@ -639,10 +640,22 @@ function servicesToToml(services: ServicesConfig, rawServices: unknown): Record< } else { delete out['moonshot_fetch']; } + if (services.langsearch !== undefined) { + out['langsearch'] = serviceToToml(services.langsearch); + } else { + delete out['langsearch']; + } + if (services.rerank !== undefined) { + out['rerank'] = serviceToToml(services.rerank); + } else { + delete out['rerank']; + } return out; } -function serviceToToml(service: MoonshotServiceConfig): Record { +function serviceToToml( + service: MoonshotServiceConfig | LangSearchServiceConfig | NonNullable, +): Record { const out: Record = {}; for (const [key, value] of Object.entries(service)) { if (key === 'oauth' && value !== undefined) { diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index dbec75b80b..2c4af513bf 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -32,6 +32,15 @@ export const FLAG_DEFINITIONS = [ default: false, surface: 'core', }, + { + id: 'langsearch-web-search', + title: 'LangSearch web search', + description: + 'Use LangSearch as a configurable WebSearch backend and optionally rerank search results with its semantic reranker.', + env: 'KIMI_CODE_EXPERIMENTAL_LANGSEARCH_WEB_SEARCH', + default: false, + surface: 'both', + }, ] as const satisfies readonly FlagDefinitionInput[]; /** Literal union of registered flag ids. */ diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index ec6d825f54..122e98fd33 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -15,7 +15,14 @@ import type { PermissionData, PermissionMode } from '#/agent/permission'; import type { PlanData } from '#/agent/plan'; import type { SwarmModeTrigger } from '#/agent/swarm'; import type { ToolInfo } from '#/agent/tool'; -import type { KimiConfig, KimiConfigPatch, McpServerConfig } from '#/config'; +import type { + KimiConfig, + KimiConfigPatch, + LangSearchServiceConfig, + McpServerConfig, + MoonshotServiceConfig, + RerankServiceConfig, +} from '#/config'; import type { ExperimentalFeatureState } from '#/flags'; import type { ResumeSessionResult } from '#/rpc/resumed'; import type { SessionMeta } from '#/session'; @@ -436,6 +443,26 @@ export interface RemoveKimiProviderPayload { readonly providerId: string; } +export interface ReplaceableKimiServices { + readonly moonshotSearch: MoonshotServiceConfig; + readonly langsearch: LangSearchServiceConfig; + readonly rerank: RerankServiceConfig; +} + +export type ReplaceableKimiService = keyof ReplaceableKimiServices; +export type RemovableKimiService = ReplaceableKimiService; + +export type ReplaceKimiServicePayload = { + readonly [Service in ReplaceableKimiService]: { + readonly service: Service; + readonly config: ReplaceableKimiServices[Service]; + }; +}[ReplaceableKimiService]; + +export interface RemoveKimiServicePayload { + readonly service: RemovableKimiService; +} + export interface GetCronTasksResult { readonly tasks: readonly CronTaskSnapshot[]; } @@ -512,6 +539,8 @@ export interface CoreAPI extends SessionAPIWithId { getConfigDiagnostics: (payload: EmptyPayload) => ConfigDiagnostics; setKimiConfig: (payload: SetKimiConfigPayload) => KimiConfig; removeKimiProvider: (payload: RemoveKimiProviderPayload) => KimiConfig; + replaceKimiService: (payload: ReplaceKimiServicePayload) => KimiConfig; + removeKimiService: (payload: RemoveKimiServicePayload) => KimiConfig; listGlobalMcpServers: (payload: EmptyPayload) => readonly GlobalMcpServerConfig[]; addGlobalMcpServer: (payload: PutGlobalMcpServerPayload) => readonly GlobalMcpServerConfig[]; updateGlobalMcpServer: (payload: PutGlobalMcpServerPayload) => readonly GlobalMcpServerConfig[]; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 19c8cfc457..c31e89a85c 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -4,9 +4,17 @@ import { homedir } from 'node:os'; import { ErrorCodes, KimiError } from '#/errors'; import { getRootLogger, log } from '#/logging/logger'; import { PluginManager } from '#/plugin'; +import type { WebSearchProvider } from '#/tools/builtin/web/web-search'; +import { LangSearchRerankProvider } from '#/tools/providers/langsearch-rerank'; +import { LangSearchWebSearchProvider } from '#/tools/providers/langsearch-web-search'; import { LocalFetchURLProvider } from '#/tools/providers/local-fetch-url'; import { MoonshotFetchURLProvider } from '#/tools/providers/moonshot-fetch-url'; import { MoonshotWebSearchProvider } from '#/tools/providers/moonshot-web-search'; +import { RerankingWebSearchProvider } from '#/tools/providers/reranking-web-search'; +import { + LANGSEARCH_TIER_LIMITS, + RateLimiter, +} from '#/tools/providers/rate-limiter'; import { ImageLimits } from '#/tools/support/image-limits'; import type { PromisableMethods } from '#/utils/types'; import { getCoreVersion } from '#/version'; @@ -121,6 +129,8 @@ import type { ReloadSessionPayload, ReloadPluginsResult, RemoveKimiProviderPayload, + RemoveKimiServicePayload, + ReplaceKimiServicePayload, RemovePluginPayload, RenameSessionPayload, ResumeSessionPayload, @@ -686,6 +696,26 @@ export class KimiCore implements PromisableMethods { return this.reloadRuntimeConfig(); } + async replaceKimiService(input: ReplaceKimiServicePayload): Promise { + const config = this.readConfigForWrite(); + config.services ??= {}; + config.services[input.service] = input.config; + await writeConfigFile(this.configPath, config); + return this.reloadRuntimeConfig(); + } + + async removeKimiService(input: RemoveKimiServicePayload): Promise { + const config = this.readConfigForWrite(); + if (config.services !== undefined) { + delete config.services[input.service]; + if (Object.keys(config.services).length === 0) { + config.services = undefined; + } + } + await writeConfigFile(this.configPath, config); + return this.reloadRuntimeConfig(); + } + async listGlobalMcpServers(_input?: EmptyPayload): Promise { return this.globalMcpConfig.list(); } @@ -1148,6 +1178,7 @@ export class KimiCore implements PromisableMethods { if (this.runtime !== undefined) return this.runtime; const runtime = await createRuntimeConfig({ config, + langSearchEnabled: this.experimentalFlags.enabled('langsearch-web-search'), kimiRequestHeaders: this.kimiRequestHeaders, resolveOAuthTokenProvider: this.resolveOAuthTokenProvider, }); @@ -1380,12 +1411,16 @@ function standaloneMcpTestResult( async function createRuntimeConfig(input: { readonly config: KimiConfig; + readonly langSearchEnabled: boolean; readonly kimiRequestHeaders?: Record | undefined; readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver | undefined; }): Promise { const localFetcher = new LocalFetchURLProvider(); - const searchService = input.config.services?.moonshotSearch; - const fetchService = input.config.services?.moonshotFetch; + const services = input.config.services; + const fetchService = services?.moonshotFetch; + const langSearchLimiter = createLangSearchRateLimiter(input.config, input.langSearchEnabled); + const search = createWebSearchProvider(input, langSearchLimiter); + const reranker = createRerankProvider(input.config, input.langSearchEnabled, langSearchLimiter); return { urlFetcher: @@ -1398,16 +1433,90 @@ async function createRuntimeConfig(input: { ...serviceCredentials(fetchService, input.resolveOAuthTokenProvider), }), webSearcher: - searchService?.baseUrl === undefined - ? undefined - : new MoonshotWebSearchProvider({ - baseUrl: searchService.baseUrl, - defaultHeaders: input.kimiRequestHeaders, - ...serviceCredentials(searchService, input.resolveOAuthTokenProvider), - }), + search !== undefined && reranker !== undefined + ? new RerankingWebSearchProvider(search, reranker) + : search, }; } +function createWebSearchProvider( + input: { + readonly config: KimiConfig; + readonly langSearchEnabled: boolean; + readonly kimiRequestHeaders?: Record | undefined; + readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver | undefined; + }, + limiter: RateLimiter | undefined, +): WebSearchProvider | undefined { + const services = input.config.services; + const langsearch = services?.langsearch; + const langsearchApiKey = nonEmptyString(langsearch?.apiKey); + if (input.langSearchEnabled && langsearchApiKey !== undefined) { + return new LangSearchWebSearchProvider({ + apiKey: langsearchApiKey, + baseUrl: langsearch?.baseUrl, + tier: langsearch?.tier, + freshness: langsearch?.freshness, + summary: langsearch?.summary, + count: langsearch?.count, + customHeaders: langsearch?.customHeaders, + limiter, + }); + } + + const moonshot = services?.moonshotSearch; + if (moonshot?.baseUrl === undefined) return undefined; + return new MoonshotWebSearchProvider({ + baseUrl: moonshot.baseUrl, + defaultHeaders: input.kimiRequestHeaders, + ...serviceCredentials(moonshot, input.resolveOAuthTokenProvider), + }); +} + +function createRerankProvider( + config: KimiConfig, + langSearchEnabled: boolean, + limiter: RateLimiter | undefined, +): LangSearchRerankProvider | undefined { + const services = config.services; + const rerank = services?.rerank; + if ( + !langSearchEnabled || + rerank?.enabled === false || + rerank?.provider !== 'langsearch' + ) { + return undefined; + } + + const apiKey = nonEmptyString(rerank.apiKey) ?? nonEmptyString(services?.langsearch?.apiKey); + if (apiKey === undefined) return undefined; + return new LangSearchRerankProvider({ + apiKey, + baseUrl: rerank.baseUrl, + tier: services?.langsearch?.tier, + customHeaders: rerank.customHeaders, + limiter, + }); +} + +function createLangSearchRateLimiter( + config: KimiConfig, + langSearchEnabled: boolean, +): RateLimiter | undefined { + if (!langSearchEnabled) return undefined; + const services = config.services; + const searchConfigured = nonEmptyString(services?.langsearch?.apiKey) !== undefined; + const rerankConfigured = + services?.rerank?.enabled !== false && + services?.rerank?.provider === 'langsearch' && + (nonEmptyString(services.rerank.apiKey) ?? nonEmptyString(services.langsearch?.apiKey)) !== + undefined; + if (!searchConfigured && !rerankConfigured) return undefined; + + const tier = services?.langsearch?.tier ?? 'free'; + return new RateLimiter(LANGSEARCH_TIER_LIMITS[tier], tier); +} + function serviceCredentials( service: MoonshotServiceConfig, resolveOAuthTokenProvider: OAuthTokenProviderResolver | undefined, diff --git a/packages/agent-core/src/tools/builtin/web/web-search.ts b/packages/agent-core/src/tools/builtin/web/web-search.ts index 85b22b7cc0..db9bf25a80 100644 --- a/packages/agent-core/src/tools/builtin/web/web-search.ts +++ b/packages/agent-core/src/tools/builtin/web/web-search.ts @@ -27,7 +27,10 @@ export interface WebSearchResult { } export interface WebSearchProvider { - search(query: string, options?: { toolCallId?: string }): Promise; + search( + query: string, + options?: { toolCallId?: string; signal?: AbortSignal }, + ): Promise; } // ── Input schema ───────────────────────────────────────────────────── @@ -60,13 +63,10 @@ export class WebSearchTool implements BuiltinTool { private async execution( args: WebSearchInput, - { - toolCallId, - }: ExecutableToolContext, + { toolCallId, signal }: ExecutableToolContext, ): Promise { try { - const opts: { toolCallId?: string } = { toolCallId }; - const results = await this.provider.search(args.query, opts); + const results = await this.provider.search(args.query, { toolCallId, signal }); const builder = new ToolResultBuilder({ maxLineLength: null }); if (results.length === 0) { diff --git a/packages/agent-core/src/tools/providers/langsearch-rerank.ts b/packages/agent-core/src/tools/providers/langsearch-rerank.ts new file mode 100644 index 0000000000..cc2e608c42 --- /dev/null +++ b/packages/agent-core/src/tools/providers/langsearch-rerank.ts @@ -0,0 +1,126 @@ +/** LangSearch Semantic Rerank API provider. */ + +import type { WebSearchResult } from '../builtin'; +import type { RerankProvider } from './rerank'; +import { + LANGSEARCH_TIER_LIMITS, + RateLimiter, + type LangSearchTier, +} from './rate-limiter'; + +export interface LangSearchRerankProviderOptions { + apiKey: string; + baseUrl?: string; + tier?: LangSearchTier; + customHeaders?: Record; + fetchImpl?: typeof fetch; + limiter?: RateLimiter; +} + +interface LangSearchRerankResult { + index: number; + relevance_score?: number; +} + +interface LangSearchRerankResponse { + code?: number; + msg?: string | null; + results?: LangSearchRerankResult[]; +} + +const DEFAULT_BASE_URL = 'https://api.langsearch.com'; +const RERANK_MODEL = 'langsearch-reranker-v1'; + +export class LangSearchRerankProvider implements RerankProvider { + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly customHeaders: Record; + private readonly fetchImpl: typeof fetch; + private readonly limiter: RateLimiter; + + constructor(options: LangSearchRerankProviderOptions) { + const tier = options.tier ?? 'free'; + this.apiKey = options.apiKey; + this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); + this.customHeaders = options.customHeaders ?? {}; + this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + this.limiter = options.limiter ?? new RateLimiter(LANGSEARCH_TIER_LIMITS[tier], tier); + } + + async rerank( + query: string, + results: WebSearchResult[], + signal?: AbortSignal, + ): Promise { + if (results.length === 0) return results; + + const body = JSON.stringify({ + model: RERANK_MODEL, + query, + documents: results.map((result) => result.snippet || result.title), + top_n: results.length, + return_documents: false, + }); + + await this.limiter.acquire(signal); + const response = await this.fetchImpl(`${this.baseUrl}/v1/rerank`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + ...this.customHeaders, + }, + body, + signal, + }); + + if (response.status !== 200) { + throw new Error( + `LangSearch rerank request failed: HTTP ${String(response.status)}. ${await safeReadText(response)}`.trim(), + ); + } + + const json = (await response.json()) as LangSearchRerankResponse; + assertLangSearchSuccess('rerank request', json.code, json.msg); + if (!Array.isArray(json.results)) return results; + + const reordered: WebSearchResult[] = []; + const used = new Set(); + const ranked = json.results.toSorted( + (left, right) => (right.relevance_score ?? 0) - (left.relevance_score ?? 0), + ); + for (const entry of ranked) { + if ( + Number.isInteger(entry.index) && + entry.index >= 0 && + entry.index < results.length && + !used.has(entry.index) + ) { + reordered.push(results[entry.index]!); + used.add(entry.index); + } + } + for (let index = 0; index < results.length; index++) { + if (!used.has(index)) reordered.push(results[index]!); + } + return reordered; + } +} + +function assertLangSearchSuccess( + operation: string, + code: number | undefined, + message: string | null | undefined, +): void { + if (code === undefined || code === 200) return; + const detail = typeof message === 'string' && message.length > 0 ? ` ${message}` : ''; + throw new Error(`LangSearch ${operation} failed: API code ${String(code)}.${detail}`); +} + +async function safeReadText(response: Response): Promise { + try { + return await response.text(); + } catch { + return ''; + } +} diff --git a/packages/agent-core/src/tools/providers/langsearch-web-search.ts b/packages/agent-core/src/tools/providers/langsearch-web-search.ts new file mode 100644 index 0000000000..0def8ef52d --- /dev/null +++ b/packages/agent-core/src/tools/providers/langsearch-web-search.ts @@ -0,0 +1,151 @@ +/** + * LangSearch Web Search API provider for the in-process agent-core runtime. + */ + +import type { WebSearchProvider, WebSearchResult } from '../builtin'; +import { + LANGSEARCH_TIER_LIMITS, + RateLimiter, + type LangSearchTier, +} from './rate-limiter'; + +export type { LangSearchTier } from './rate-limiter'; + +export interface LangSearchWebSearchProviderOptions { + apiKey: string; + baseUrl?: string; + tier?: LangSearchTier; + freshness?: string; + summary?: boolean; + count?: number; + customHeaders?: Record; + fetchImpl?: typeof fetch; + limiter?: RateLimiter; +} + +interface LangSearchWebPageValue { + name?: string; + url?: string; + snippet?: string; + summary?: string; + siteName?: string; + datePublished?: string; +} + +interface LangSearchSearchResponse { + code?: number; + msg?: string | null; + data?: { + webPages?: { + value?: LangSearchWebPageValue[]; + }; + }; +} + +const DEFAULT_BASE_URL = 'https://api.langsearch.com'; + +export class LangSearchWebSearchProvider implements WebSearchProvider { + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly tier: LangSearchTier; + private readonly freshness: string; + private readonly summary: boolean; + private readonly count: number; + private readonly customHeaders: Record; + private readonly fetchImpl: typeof fetch; + private readonly limiter: RateLimiter; + + constructor(options: LangSearchWebSearchProviderOptions) { + this.apiKey = options.apiKey; + this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); + this.tier = options.tier ?? 'free'; + this.freshness = options.freshness ?? 'noLimit'; + this.summary = options.summary ?? true; + this.count = options.count ?? 10; + this.customHeaders = options.customHeaders ?? {}; + this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + this.limiter = + options.limiter ?? new RateLimiter(LANGSEARCH_TIER_LIMITS[this.tier], this.tier); + } + + async search( + query: string, + options?: { toolCallId?: string; signal?: AbortSignal }, + ): Promise { + const body = JSON.stringify({ + query, + freshness: this.freshness, + summary: this.summary, + count: this.count, + }); + + await this.limiter.acquire(options?.signal); + const response = await this.fetchImpl(`${this.baseUrl}/v1/web-search`, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + ...this.customHeaders, + }, + body, + signal: options?.signal, + }); + + if (response.status === 401) { + const detail = await safeReadText(response); + throw new Error( + `LangSearch request failed: HTTP 401 (auth/unauthorized). ${detail}`.trim(), + ); + } + if (response.status === 429) { + const detail = await safeReadText(response); + throw new Error( + `LangSearch rate limit exceeded (tier: ${this.tier}). ${detail}`.trim(), + ); + } + if (response.status !== 200) { + const detail = await safeReadText(response); + throw new Error( + `LangSearch request failed: HTTP ${String(response.status)}. ${detail}`.trim(), + ); + } + + const json = (await response.json()) as LangSearchSearchResponse; + assertLangSearchSuccess('web search request', json.code, json.msg); + const raw = json.data?.webPages?.value; + if (!Array.isArray(raw)) return []; + + return raw.map((result): WebSearchResult => { + const mapped: WebSearchResult = { + title: result.name ?? '', + url: result.url ?? '', + snippet: this.summary && result.summary ? result.summary : (result.snippet ?? ''), + }; + if (typeof result.siteName === 'string' && result.siteName.length > 0) { + mapped.siteName = result.siteName; + } + if (typeof result.datePublished === 'string' && result.datePublished.length > 0) { + mapped.date = result.datePublished; + } + return mapped; + }); + } +} + +function assertLangSearchSuccess( + operation: string, + code: number | undefined, + message: string | null | undefined, +): void { + if (code === undefined || code === 200) return; + const detail = typeof message === 'string' && message.length > 0 ? ` ${message}` : ''; + throw new Error(`LangSearch ${operation} failed: API code ${String(code)}.${detail}`); +} + +async function safeReadText(response: Response): Promise { + try { + return await response.text(); + } catch { + return ''; + } +} diff --git a/packages/agent-core/src/tools/providers/rate-limiter.ts b/packages/agent-core/src/tools/providers/rate-limiter.ts new file mode 100644 index 0000000000..1f3494908c --- /dev/null +++ b/packages/agent-core/src/tools/providers/rate-limiter.ts @@ -0,0 +1,96 @@ +/** + * In-memory LangSearch rate limiter for a single Kimi Code process. + * + * The limits mirror LangSearch's published account tiers. This is best-effort: + * multiple processes using the same account do not share counters. + */ + +export type LangSearchTier = 'free' | 'tier1' | 'tier2' | 'tier3'; + +export interface TierLimit { + readonly qps: number; + readonly qpm: number; + readonly qpd: number; +} + +export const LANGSEARCH_TIER_LIMITS: Record = { + free: { qps: 1, qpm: 60, qpd: 1000 }, + tier1: { qps: 5, qpm: 200, qpd: 2000 }, + tier2: { qps: 10, qpm: 500, qpd: 10000 }, + tier3: { qps: 30, qpm: 2000, qpd: 100000 }, +}; + +export class RateLimiter { + private readonly secondWindow: number[] = []; + private readonly minuteWindow: number[] = []; + private readonly dayWindow: number[] = []; + + constructor( + private readonly limit: TierLimit, + private readonly tierLabel: string, + ) {} + + async acquire(signal?: AbortSignal): Promise { + for (;;) { + if (signal?.aborted) { + throw new DOMException('The operation was aborted.', 'AbortError'); + } + + const now = Date.now(); + this.prune(now); + + if (this.dayWindow.length >= this.limit.qpd) { + throw new Error( + `LangSearch daily quota exhausted (tier: ${this.tierLabel}). Try again tomorrow or upgrade your tier.`, + ); + } + if (this.minuteWindow.length >= this.limit.qpm) { + await this.sleepUntil(this.minuteWindow[0]! + 60_000, signal); + continue; + } + if (this.secondWindow.length >= this.limit.qps) { + await this.sleepUntil(this.secondWindow[0]! + 1_000, signal); + continue; + } + + this.secondWindow.push(now); + this.minuteWindow.push(now); + this.dayWindow.push(now); + return; + } + } + + private prune(now: number): void { + const oneSecondAgo = now - 1_000; + const oneMinuteAgo = now - 60_000; + const oneDayAgo = now - 86_400_000; + + while (this.secondWindow.length > 0 && this.secondWindow[0]! <= oneSecondAgo) { + this.secondWindow.shift(); + } + while (this.minuteWindow.length > 0 && this.minuteWindow[0]! <= oneMinuteAgo) { + this.minuteWindow.shift(); + } + while (this.dayWindow.length > 0 && this.dayWindow[0]! <= oneDayAgo) { + this.dayWindow.shift(); + } + } + + private async sleepUntil(target: number, signal?: AbortSignal): Promise { + const delay = Math.max(0, target - Date.now()); + if (delay <= 0) return; + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, delay); + const onAbort = () => { + clearTimeout(timer); + reject(new DOMException('The operation was aborted.', 'AbortError')); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) onAbort(); + }); + } +} diff --git a/packages/agent-core/src/tools/providers/rerank.ts b/packages/agent-core/src/tools/providers/rerank.ts new file mode 100644 index 0000000000..68f4dcab3b --- /dev/null +++ b/packages/agent-core/src/tools/providers/rerank.ts @@ -0,0 +1,10 @@ +import type { WebSearchResult } from '../builtin'; + +/** A backend capable of reordering search results by semantic relevance. */ +export interface RerankProvider { + rerank( + query: string, + results: WebSearchResult[], + signal?: AbortSignal, + ): Promise; +} diff --git a/packages/agent-core/src/tools/providers/reranking-web-search.ts b/packages/agent-core/src/tools/providers/reranking-web-search.ts new file mode 100644 index 0000000000..c8e6c629f7 --- /dev/null +++ b/packages/agent-core/src/tools/providers/reranking-web-search.ts @@ -0,0 +1,25 @@ +import type { WebSearchProvider, WebSearchResult } from '../builtin'; +import type { RerankProvider } from './rerank'; + +/** Applies an optional, best-effort rerank pass to any web-search backend. */ +export class RerankingWebSearchProvider implements WebSearchProvider { + constructor( + private readonly delegate: WebSearchProvider, + private readonly reranker: RerankProvider, + ) {} + + async search( + query: string, + options?: { toolCallId?: string; signal?: AbortSignal }, + ): Promise { + const results = await this.delegate.search(query, options); + if (results.length === 0) return results; + + try { + return await this.reranker.rerank(query, results, options?.signal); + } catch (error) { + if (options?.signal?.aborted) throw error; + return results; + } + } +} diff --git a/packages/agent-core/test/harness/runtime.test.ts b/packages/agent-core/test/harness/runtime.test.ts index 227f498258..ed7588baf0 100644 --- a/packages/agent-core/test/harness/runtime.test.ts +++ b/packages/agent-core/test/harness/runtime.test.ts @@ -331,6 +331,151 @@ custom_headers = { "X-Test" = "1" } }); }); + it('registers WebSearch from services.langsearch in the v1 runtime', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile( + join(homeDir, 'config.toml'), + `${baseModelConfig()} +[experimental] +langsearch-web-search = true + +[services.langsearch] +api_key = "sk-test" +tier = "tier1" +count = 5 +`, + ); + const fetchImpl = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: { + webPages: { + value: [ + { + name: 'LangSearch result', + url: 'https://example.test/result', + snippet: 'Result snippet', + }, + ], + }, + }, + }), + { status: 200 }, + ), + ); + vi.stubGlobal('fetch', fetchImpl); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + + const created = await rpc.createSession({ + id: 'ses_runtime_langsearch_v1', + workDir, + model: 'default-mock', + }); + const session = core.sessions.get(created.id); + const mainAgent = session?.getReadyAgent('main'); + + expect(mainAgent?.tools.data().some((tool) => tool.name === 'WebSearch')).toBe(true); + await expect(session?.options.toolServices?.webSearcher?.search('query')).resolves.toEqual([ + { + title: 'LangSearch result', + url: 'https://example.test/result', + snippet: 'Result snippet', + }, + ]); + const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.langsearch.com/v1/web-search'); + expect(typeof init.body).toBe('string'); + expect(JSON.parse(init.body as string)).toMatchObject({ query: 'query', count: 5 }); + }); + + it('applies configured LangSearch rerank to the Moonshot search backend', async () => { + tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); + const homeDir = join(tmp, 'home'); + const workDir = join(tmp, 'work'); + await mkdir(homeDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); + await writeFile( + join(homeDir, 'config.toml'), + `${baseModelConfig()} +[experimental] +langsearch-web-search = true + +[services.moonshot_search] +base_url = "https://moonshot.example.test/search" +api_key = "sk-moonshot-test" + +[services.rerank] +enabled = true +provider = "langsearch" +api_key = "sk-rerank-test" +base_url = "https://rerank.example.test" +`, + ); + const fetchImpl = vi.fn().mockImplementation((url: string) => { + if (url === 'https://moonshot.example.test/search') { + return Promise.resolve( + new Response( + JSON.stringify({ + search_results: [ + { title: 'First', url: 'https://example.test/first', snippet: 'First' }, + { title: 'Second', url: 'https://example.test/second', snippet: 'Second' }, + ], + }), + { status: 200 }, + ), + ); + } + return Promise.resolve( + new Response( + JSON.stringify({ + results: [ + { index: 1, relevance_score: 0.9 }, + { index: 0, relevance_score: 0.2 }, + ], + }), + { status: 200 }, + ), + ); + }); + vi.stubGlobal('fetch', fetchImpl); + + const [coreRpc, sdkRpc] = createRPC(); + const core = new KimiCore(coreRpc, { homeDir }); + const rpc = await sdkRpc({ + emitEvent: vi.fn(), + requestApproval: vi.fn(async (): Promise => ({ decision: 'rejected' })), + requestQuestion: vi.fn(async () => null), + toolCall: vi.fn(async () => ({ output: '' })), + }); + const created = await rpc.createSession({ + id: 'ses_runtime_moonshot_rerank_v1', + workDir, + model: 'default-mock', + }); + const search = core.sessions.get(created.id)?.options.toolServices?.webSearcher; + + await expect(search?.search('query')).resolves.toEqual([ + { title: 'Second', url: 'https://example.test/second', snippet: 'Second' }, + { title: 'First', url: 'https://example.test/first', snippet: 'First' }, + ]); + expect(fetchImpl.mock.calls.map((call) => call[0])).toEqual([ + 'https://moonshot.example.test/search', + 'https://rerank.example.test/v1/rerank', + ]); + }); + it('falls back to defaultModel when createSession receives no model option', async () => { tmp = await mkdtemp(join(tmpdir(), 'kimi-core-runtime-')); const homeDir = join(tmp, 'home'); diff --git a/packages/agent-core/test/tools/web-search.test.ts b/packages/agent-core/test/tools/web-search.test.ts index b8888e8c68..c27a1fc215 100644 --- a/packages/agent-core/test/tools/web-search.test.ts +++ b/packages/agent-core/test/tools/web-search.test.ts @@ -11,7 +11,10 @@ import { WebSearchTool, type WebSearchProvider, } from '../../src/tools/builtin/web/web-search'; +import { LangSearchRerankProvider } from '../../src/tools/providers/langsearch-rerank'; +import { LangSearchWebSearchProvider } from '../../src/tools/providers/langsearch-web-search'; import { MoonshotWebSearchProvider } from '../../src/tools/providers/moonshot-web-search'; +import { RerankingWebSearchProvider } from '../../src/tools/providers/reranking-web-search'; import { toolContentString } from './fixtures/fake-kaos'; import { executeTool } from './fixtures/execute-tool'; @@ -266,7 +269,7 @@ describe('WebSearchTool', () => { expect(content).toContain('The operation was aborted'); }); - it('passes only the query and toolCallId to the provider', async () => { + it('passes the query, toolCallId, and abort signal to the provider', async () => { const provider = fakeProvider([]); const tool = new WebSearchTool(provider); await executeTool(tool, { @@ -277,6 +280,7 @@ describe('WebSearchTool', () => { }); expect(provider.search).toHaveBeenCalledWith('test', { toolCallId: 'c4', + signal, }); }); @@ -298,6 +302,189 @@ describe('WebSearchTool', () => { }); }); +describe('LangSearch providers', () => { + it('sends configured web search options and maps summary metadata', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + code: 200, + data: { + webPages: { + value: [ + { + name: 'Result', + url: 'https://example.test/result', + snippet: 'Search snippet', + summary: 'Generated summary', + siteName: 'Example', + datePublished: '2026-07-18', + }, + ], + }, + }, + }), + { status: 200 }, + ), + ); + const provider = new LangSearchWebSearchProvider({ + apiKey: 'sk-test', + baseUrl: 'https://search.example.test/', + tier: 'tier1', + freshness: 'oneMonth', + summary: true, + count: 7, + customHeaders: { 'X-Test': 'yes' }, + fetchImpl, + }); + + await expect(provider.search('kimi search')).resolves.toEqual([ + { + title: 'Result', + url: 'https://example.test/result', + snippet: 'Generated summary', + siteName: 'Example', + date: '2026-07-18', + }, + ]); + expect(fetchImpl).toHaveBeenCalledWith('https://search.example.test/v1/web-search', { + method: 'POST', + headers: { + Authorization: 'Bearer sk-test', + 'Content-Type': 'application/json', + 'X-Test': 'yes', + }, + body: JSON.stringify({ + query: 'kimi search', + freshness: 'oneMonth', + summary: true, + count: 7, + }), + signal: undefined, + }); + }); + + it('rejects unsuccessful API codes returned with HTTP 200', async () => { + const provider = new LangSearchWebSearchProvider({ + apiKey: 'sk-test', + fetchImpl: vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 500, msg: 'backend unavailable' }), { + status: 200, + }), + ), + }); + + await expect(provider.search('query')).rejects.toThrow( + 'LangSearch web search request failed: API code 500. backend unavailable', + ); + }); + + it('reports authentication and rate-limit failures', async () => { + const unauthorized = new LangSearchWebSearchProvider({ + apiKey: 'sk-test', + fetchImpl: vi.fn().mockResolvedValue( + new Response('bad key', { status: 401 }), + ), + }); + const rateLimited = new LangSearchWebSearchProvider({ + apiKey: 'sk-test', + fetchImpl: vi.fn().mockResolvedValue( + new Response('slow down', { status: 429 }), + ), + }); + + await expect(unauthorized.search('query')).rejects.toThrow( + 'HTTP 401 (auth/unauthorized). bad key', + ); + await expect(rateLimited.search('query')).rejects.toThrow( + 'LangSearch rate limit exceeded (tier: free). slow down', + ); + }); + + it('reranks any search backend using the semantic rerank response', async () => { + const original = [ + { title: 'First', url: 'https://example.test/first', snippet: 'First snippet' }, + { title: 'Second', url: 'https://example.test/second', snippet: 'Second snippet' }, + ]; + const delegate = fakeProvider(original); + const fetchImpl = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + results: [ + { index: 1, relevance_score: 0.9 }, + { index: 0, relevance_score: 0.4 }, + ], + }), + { status: 200 }, + ), + ); + const reranker = new LangSearchRerankProvider({ + apiKey: 'sk-rerank-test', + baseUrl: 'https://rerank.example.test', + fetchImpl, + }); + const provider = new RerankingWebSearchProvider(delegate, reranker); + + await expect(provider.search('best result')).resolves.toEqual([ + original[1], + original[0], + ]); + const request = fetchImpl.mock.calls[0]?.[1]; + expect(request?.headers).toMatchObject({ Authorization: 'Bearer sk-rerank-test' }); + expect(typeof request?.body).toBe('string'); + expect(JSON.parse(request?.body as string)).toEqual({ + model: 'langsearch-reranker-v1', + query: 'best result', + documents: ['First snippet', 'Second snippet'], + top_n: 2, + return_documents: false, + }); + }); + + it('ignores fractional rerank indexes', async () => { + const original = [ + { title: 'First', url: 'https://example.test/first', snippet: 'First snippet' }, + { title: 'Second', url: 'https://example.test/second', snippet: 'Second snippet' }, + ]; + const reranker = new LangSearchRerankProvider({ + apiKey: 'sk-test', + fetchImpl: vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + results: [ + { index: 0.5, relevance_score: 1 }, + { index: 1, relevance_score: 0.9 }, + ], + }), + { status: 200 }, + ), + ), + }); + + await expect(reranker.rerank('query', original)).resolves.toEqual([ + original[1], + original[0], + ]); + }); + + it('keeps the original order when rerank fails', async () => { + const original = [ + { title: 'First', url: 'https://example.test/first', snippet: 'First snippet' }, + { title: 'Second', url: 'https://example.test/second', snippet: 'Second snippet' }, + ]; + const provider = new RerankingWebSearchProvider( + fakeProvider(original), + new LangSearchRerankProvider({ + apiKey: 'sk-test', + fetchImpl: vi.fn().mockResolvedValue( + new Response('service unavailable', { status: 503 }), + ), + }), + ); + + await expect(provider.search('query')).resolves.toEqual(original); + }); +}); + describe('MoonshotWebSearchProvider', () => { it('maps site_name to siteName and does not forward page content', async () => { const fetchImpl = vi.fn().mockResolvedValue( diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 653933c783..52e62ba6c7 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -5,6 +5,9 @@ import { ImageLimits, withTelemetryContext, type ExperimentalFeatureState, + type RemovableKimiService, + type ReplaceableKimiService, + type ReplaceableKimiServices, } from '@moonshot-ai/agent-core'; import { Session } from '#/session'; @@ -278,6 +281,17 @@ export class KimiHarness { return this.rpc.removeProvider(providerId); } + async replaceService( + service: Service, + config: ReplaceableKimiServices[Service], + ): Promise { + return this.rpc.replaceService(service, config); + } + + async removeService(service: RemovableKimiService): Promise { + return this.rpc.removeService(service); + } + /** User-global MCP entries from `/mcp.json` only. */ async listMcpServers(): Promise { return this.rpc.listGlobalMcpServers(); diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index df5a2825e2..419ff90521 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -13,6 +13,9 @@ import { type GetCronTasksResult, type QuestionRequest, type QuestionResult, + type RemovableKimiService, + type ReplaceableKimiService, + type ReplaceableKimiServices, type RPCMethods, type SDKAPI, type ToolCallRequest, @@ -259,6 +262,19 @@ export abstract class SDKRpcClientBase { return rpc.removeKimiProvider({ providerId }); } + async replaceService( + service: Service, + config: ReplaceableKimiServices[Service], + ): Promise { + const rpc = await this.getRpc(); + return rpc.replaceKimiService({ service, config }); + } + + async removeService(service: RemovableKimiService): Promise { + const rpc = await this.getRpc(); + return rpc.removeKimiService({ service }); + } + async listGlobalMcpServers(): Promise { const rpc = await this.getRpc(); return rpc.listGlobalMcpServers({}); diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index c72419e3f4..3e25626851 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -39,11 +39,16 @@ export type { GoalToolResult, KimiConfig, KimiConfigPatch, + LangSearchServiceConfig, LoopControl, McpServerInfo, McpStartupMetrics, ModelAlias, MoonshotServiceConfig, + RerankServiceConfig, + RemovableKimiService, + ReplaceableKimiService, + ReplaceableKimiServices, OAuthRef, PluginCommandDef, PluginGithubMetadata, diff --git a/packages/node-sdk/test/config.test.ts b/packages/node-sdk/test/config.test.ts index 9484facf1f..f900af5c88 100644 --- a/packages/node-sdk/test/config.test.ts +++ b/packages/node-sdk/test/config.test.ts @@ -296,6 +296,152 @@ describe('KimiHarness config API', () => { expect(text).toContain('claim_stale_after_ms = 15000'); }); + it('persists LangSearch and rerank config through setConfig', async () => { + const homeDir = await makeTempDir(); + const configPath = join(homeDir, 'config.toml'); + await writeFile(configPath, COMPLETE_TOML, 'utf-8'); + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + + await harness.setConfig({ + services: { + langsearch: { + apiKey: 'sk-langsearch-test', + tier: 'tier2', + freshness: 'oneMonth', + summary: true, + count: 8, + }, + rerank: { + enabled: true, + provider: 'langsearch', + apiKey: 'sk-rerank-test', + }, + }, + }); + + const config = await harness.getConfig({ reload: true }); + expect(config.services?.langsearch).toMatchObject({ + apiKey: 'sk-langsearch-test', + tier: 'tier2', + freshness: 'oneMonth', + summary: true, + count: 8, + }); + expect(config.services?.rerank).toMatchObject({ + enabled: true, + provider: 'langsearch', + apiKey: 'sk-rerank-test', + }); + const text = await readFile(configPath, 'utf-8'); + expect(text).toContain('[services.langsearch]'); + expect(text).toContain('api_key = "sk-langsearch-test"'); + expect(text).toContain('freshness = "oneMonth"'); + expect(text).toContain('count = 8'); + expect(text).toContain('[services.rerank]'); + expect(text).toContain('api_key = "sk-rerank-test"'); + }); + + it('atomically replaces service sections instead of deep-merging stale credentials', async () => { + const homeDir = await makeTempDir(); + const configPath = join(homeDir, 'config.toml'); + await writeFile( + configPath, + `${COMPLETE_TOML} +[services.rerank] +enabled = true +provider = "langsearch" +api_key = "sk-rerank-test" +`, + 'utf-8', + ); + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + + await harness.replaceService('moonshotSearch', { + baseUrl: 'https://search.example.test', + apiKey: 'sk-search-stale', + oauth: { storage: 'file', key: 'oauth/kimi-code' }, + }); + await harness.replaceService('moonshotSearch', { + baseUrl: 'https://search.example.test', + apiKey: 'sk-search-replaced', + }); + await harness.replaceService('rerank', { + enabled: false, + provider: 'langsearch', + }); + + const config = await harness.getConfig({ reload: true }); + expect(config.services?.moonshotSearch).toEqual({ + baseUrl: 'https://search.example.test', + apiKey: 'sk-search-replaced', + }); + expect(config.services?.rerank).toEqual({ + enabled: false, + provider: 'langsearch', + }); + const text = await readFile(configPath, 'utf-8'); + expect(text).not.toContain('oauth/kimi-code'); + expect(text).not.toContain('sk-rerank-test'); + }); + + it('removes search and rerank sections without changing other services', async () => { + const homeDir = await makeTempDir(); + const configPath = join(homeDir, 'config.toml'); + await writeFile( + configPath, + `${COMPLETE_TOML} +[services.langsearch] +api_key = "sk-langsearch-test" +tier = "tier1" +count = 5 + +[services.rerank] +enabled = true +provider = "langsearch" +api_key = "sk-rerank-test" +`, + 'utf-8', + ); + const harness = createKimiHarness({ homeDir, identity: TEST_IDENTITY }); + + await harness.removeService('moonshotSearch'); + + let config = await harness.getConfig({ reload: true }); + expect(config.services?.moonshotSearch).toBeUndefined(); + expect(config.services?.moonshotFetch?.apiKey).toBe('sk-fetch'); + expect(config.services?.langsearch?.apiKey).toBe('sk-langsearch-test'); + expect(config.services?.rerank?.apiKey).toBe('sk-rerank-test'); + let text = await readFile(configPath, 'utf-8'); + expect(text).not.toContain('[services.moonshot_search]'); + expect(text).toContain('[services.moonshot_fetch]'); + expect(text).toContain('[services.langsearch]'); + expect(text).toContain('[services.rerank]'); + + await harness.removeService('langsearch'); + + config = await harness.getConfig({ reload: true }); + expect(config.services?.langsearch).toBeUndefined(); + expect(config.services?.rerank).toMatchObject({ + enabled: true, + provider: 'langsearch', + apiKey: 'sk-rerank-test', + }); + expect(config.services?.moonshotFetch?.apiKey).toBe('sk-fetch'); + text = await readFile(configPath, 'utf-8'); + expect(text).not.toContain('[services.langsearch]'); + expect(text).toContain('[services.rerank]'); + expect(text).toContain('[services.moonshot_fetch]'); + + await harness.removeService('rerank'); + + config = await harness.getConfig({ reload: true }); + expect(config.services?.rerank).toBeUndefined(); + expect(config.services?.moonshotFetch?.apiKey).toBe('sk-fetch'); + text = await readFile(configPath, 'utf-8'); + expect(text).not.toContain('[services.rerank]'); + expect(text).toContain('[services.moonshot_fetch]'); + }); + it('does not write invalid config patches', async () => { const homeDir = await makeTempDir(); const configPath = join(homeDir, 'config.toml'); @@ -345,6 +491,17 @@ describe('KimiHarness config API', () => { enabled: false, source: 'default', }, + { + id: 'langsearch-web-search', + title: 'LangSearch web search', + description: + 'Use LangSearch as a configurable WebSearch backend and optionally rerank search results with its semantic reranker.', + surface: 'both', + env: 'KIMI_CODE_EXPERIMENTAL_LANGSEARCH_WEB_SEARCH', + defaultEnabled: false, + enabled: false, + source: 'default', + }, ]); });