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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/v2-moonshot-search-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Support the `services.moonshot_search` api-key config for WebSearch in the v2 engine, matching v1: the tool is now available without an OAuth login, and explicit config takes precedence over the OAuth-derived provider.
114 changes: 114 additions & 0 deletions packages/agent-core-v2/src/app/auth/configSection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* `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.
*
* 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.
*/

import { z } from 'zod';

import { registerConfigSection } from '#/app/config/configSectionContributions';
import {
camelToSnake,
cloneRecord,
isPlainObject,
plainObjectToToml,
setDefined,
snakeToCamel,
transformPlainObject,
} from '#/app/config/toml';
import { OAuthRefSchema } from '#/app/provider/provider';

export const SERVICES_SECTION = 'services';

const StringRecordSchema = z.record(z.string(), z.string());

export const MoonshotServiceConfigSchema = z.object({
baseUrl: z.string().optional(),
apiKey: z.string().optional(),
oauth: OAuthRefSchema.optional(),
customHeaders: StringRecordSchema.optional(),
});

export type MoonshotServiceConfig = z.infer<typeof MoonshotServiceConfigSchema>;

export const ServicesConfigSchema = z
.object({
moonshotSearch: MoonshotServiceConfigSchema.optional(),
moonshotFetch: MoonshotServiceConfigSchema.optional(),
})
.passthrough();

export type ServicesConfig = z.infer<typeof ServicesConfigSchema>;

/** Read transform: snake_case file → camelCase in-memory `services` object. */
export const servicesFromToml = (rawSnake: unknown): unknown => {
if (!isPlainObject(rawSnake)) return rawSnake;
const out: Record<string, unknown> = {};
for (const [name, entry] of Object.entries(rawSnake)) {
out[snakeToCamel(name)] = isPlainObject(entry) ? serviceEntryFromToml(entry) : entry;
}
return out;
};

function serviceEntryFromToml(data: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
const targetKey = snakeToCamel(key);
if (targetKey === 'oauth') {
out[targetKey] = isPlainObject(value) ? transformPlainObject(value) : value;
} else if (targetKey === 'customHeaders') {
out[targetKey] = isPlainObject(value) ? cloneRecord(value) : value;
} else {
out[targetKey] = value;
}
}
return out;
}

/** Write transform: camelCase in-memory `services` object → snake_case file. */
export const servicesToToml = (value: unknown, rawSnake: unknown): unknown => {
if (!isPlainObject(value)) return value;
// Preserve unknown top-level entries (e.g. a user-defined service) verbatim,
// mirroring v1's `servicesToToml` cloning `rawServices`.
const out = cloneRecord(rawSnake);
writeService(out, 'moonshot_search', value['moonshotSearch']);
writeService(out, 'moonshot_fetch', value['moonshotFetch']);
return out;
};

function writeService(out: Record<string, unknown>, snakeKey: string, service: unknown): void {
if (isPlainObject(service)) {
out[snakeKey] = serviceEntryToToml(service);
} else {
delete out[snakeKey];
}
}

function serviceEntryToToml(service: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(service)) {
if (key === 'oauth' && isPlainObject(value)) {
out[camelToSnake(key)] = plainObjectToToml(value, undefined);
} else if (key === 'customHeaders' && value !== undefined) {
out[camelToSnake(key)] = cloneRecord(value);
} else {
setDefined(out, camelToSnake(key), value);
}
}
return out;
}

registerConfigSection(SERVICES_SECTION, ServicesConfigSchema, {
fromToml: servicesFromToml,
toToml: servicesToToml,
});
57 changes: 46 additions & 11 deletions packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
/**
* `auth` domain (cross-cutting) — `IWebSearchProviderService` implementation.
*
* Resolves the OAuth-backed `WebSearch` backend for the managed Kimi OAuth
* provider. When `managed:kimi-code` is configured with an `oauth` ref (the
* state after a successful Kimi login), this service builds a
* `MoonshotWebSearchProvider` whose bearer token comes from
* `IOAuthService.resolveTokenProvider(...)` and whose default headers are the
* host's Kimi identity headers (`IHostRequestHeaders`, mirroring v1's
* `kimiRequestHeaders`); otherwise 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.
* 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
* `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.
*/

import {
Expand All @@ -22,9 +27,11 @@ import {
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 { IHostRequestHeaders } from '#/app/model/hostRequestHeaders';
import { IProviderService } from '#/app/provider/provider';

import { SERVICES_SECTION, type ServicesConfig } from '../configSection';
import { MoonshotWebSearchProvider } from './providers/moonshot-web-search';
import type { WebSearchProvider } from './tools/web-search';
import { IWebSearchProviderService } from './webSearch';
Expand All @@ -36,9 +43,32 @@ export class WebSearchProviderService implements IWebSearchProviderService {
@IProviderService private readonly providers: IProviderService,
@IOAuthService private readonly oauth: IOAuthService,
@IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders,
@IConfigService private readonly config: IConfigService,
) {}

getWebSearchProvider(): WebSearchProvider | undefined {
return this.fromServicesConfig() ?? this.fromManagedOAuth();
}

private fromServicesConfig(): WebSearchProvider | undefined {
const search = this.config.get<ServicesConfig>(SERVICES_SECTION)?.moonshotSearch;
if (search?.baseUrl === undefined) {
return undefined;
}
const tokenProvider =
search.oauth === undefined
? undefined
: this.oauth.resolveTokenProvider(KIMI_CODE_PROVIDER_NAME, search.oauth);
return new MoonshotWebSearchProvider({
baseUrl: search.baseUrl,
tokenProvider,
apiKey: nonEmptyString(search.apiKey),
defaultHeaders: { ...this.hostHeaders.headers },
customHeaders: search.customHeaders,
});
}

private fromManagedOAuth(): WebSearchProvider | undefined {
const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME);
if (provider?.type !== 'kimi' || provider.oauth === undefined) {
return undefined;
Expand All @@ -60,6 +90,11 @@ export class WebSearchProviderService implements IWebSearchProviderService {
}
}

function nonEmptyString(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed;
}

registerScopedService(
LifecycleScope.App,
IWebSearchProviderService,
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ export * from '#/persistence/backends/memory/inMemoryStorageService';
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/providers/moonshot-web-search';
Expand Down
Loading
Loading