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/registry-fetch-user-agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Send the kimi-code-cli User-Agent on provider registry (api.json) and model catalog fetches, so registries can identify the client version.
6 changes: 3 additions & 3 deletions apps/kimi-code/src/cli/sub/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
} from '@moonshot-ai/kimi-code-sdk';
import type { Command } from 'commander';

import { createKimiCodeHostIdentity } from '#/cli/version';
import { createKimiCodeHostIdentity, createKimiCodeUserAgent } from '#/cli/version';

interface WritableLike {
write(chunk: string): boolean;
Expand Down Expand Up @@ -99,7 +99,7 @@ export async function handleProviderAdd(

let entries: Awaited<ReturnType<typeof fetchCustomRegistry>>;
try {
entries = await fetchCustomRegistry(source);
entries = await fetchCustomRegistry(source, { userAgent: createKimiCodeUserAgent() });
} catch (error) {
const suffix = error instanceof CustomRegistryApiError ? ` (HTTP ${String(error.status)})` : '';
deps.stderr.write(`Failed to fetch registry${suffix}: ${errorMessage(error)}\n`);
Expand Down Expand Up @@ -398,7 +398,7 @@ export async function handleCatalogAdd(

async function loadCatalogOrExit(deps: ProviderDeps, url: string): Promise<Catalog> {
try {
return await fetchCatalog(url);
return await fetchCatalog(url, { userAgent: createKimiCodeUserAgent() });
} catch (error) {
const suffix = error instanceof CatalogFetchError ? ` (HTTP ${String(error.status)})` : '';
deps.stderr.write(`Failed to fetch catalog from ${url}${suffix}: ${errorMessage(error)}\n`);
Expand Down
10 changes: 9 additions & 1 deletion apps/kimi-code/src/cli/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';

import { createKimiDefaultHeaders, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth';
import { createKimiDefaultHeaders, createKimiUserAgent, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth';

import { CLI_USER_AGENT_PRODUCT } from '#/constant/app';

Expand Down Expand Up @@ -55,6 +55,14 @@ export function createKimiCodeHostIdentity(version = getVersion()): KimiHostIden
};
}

/**
* Product User-Agent (`kimi-code-cli/<version>`) for ad-hoc outbound fetches
* that don't go through the provider pipeline (registry / catalog imports).
*/
export function createKimiCodeUserAgent(version = getVersion()): string {
return createKimiUserAgent(createKimiCodeHostIdentity(version));
}

export function buildKimiDefaultHeaders(version: string): Record<string, string> {
return createKimiDefaultHeaders({
homeDir: getDataDir(),
Expand Down
8 changes: 6 additions & 2 deletions apps/kimi-code/src/tui/commands/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type ThinkingEffort,
} from '@moonshot-ai/kimi-code-sdk';

import { createKimiCodeUserAgent } from '#/cli/version';
import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
import {
CustomRegistryImportDialogComponent,
Expand Down Expand Up @@ -160,7 +161,10 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {
const spinner = host.showLoginProgressSpinner(`Fetching catalog from ${DEFAULT_CATALOG_URL}`);
let catalog: Catalog | undefined;
try {
catalog = await fetchCatalog(DEFAULT_CATALOG_URL, controller.signal);
catalog = await fetchCatalog(DEFAULT_CATALOG_URL, {
signal: controller.signal,
userAgent: createKimiCodeUserAgent(),
});
spinner.stop({ ok: true, label: 'Catalog loaded.' });
} catch (error) {
if (controller.signal.aborted) {
Expand Down Expand Up @@ -276,7 +280,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise

let entries: Awaited<ReturnType<typeof fetchCustomRegistry>>;
try {
entries = await fetchCustomRegistry(source);
entries = await fetchCustomRegistry(source, { userAgent: createKimiCodeUserAgent() });
} catch (error) {
host.showError(`Failed to import registry: ${formatErrorMessage(error)}`);
return false;
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/controllers/auth-flow.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { CreateSessionOptions, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';

import { createKimiCodeUserAgent } from '#/cli/version';

import type { SkillListSession } from '../commands';

import { OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE } from '../constant/kimi-tui';
Expand Down Expand Up @@ -173,6 +176,7 @@ export class AuthFlowController {
const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef);
return tokenProvider.getAccessToken();
},
userAgent: createKimiCodeUserAgent(),
},
{ scope },
);
Expand Down
3 changes: 3 additions & 0 deletions apps/kimi-code/src/tui/utils/refresh-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface RefreshProviderHost {
removeProvider(providerId: string): Promise<KimiConfig>;
setConfig(patch: KimiConfigPatch): Promise<KimiConfig>;
resolveOAuthToken(providerName: string, oauthRef?: OAuthRef): Promise<string>;
/** Product User-Agent sent on custom-registry (api.json) fetches. */
readonly userAgent?: string;
}

export type { ProviderChange, RefreshProviderOptions, RefreshProviderScope, RefreshResult };
Expand All @@ -37,6 +39,7 @@ export async function refreshAllProviderModels(
setConfig: (patch) => host.setConfig(patch as unknown as KimiConfigPatch),
resolveOAuthToken: (providerName, oauthRef) =>
host.resolveOAuthToken(providerName, oauthRef as unknown as OAuthRef),
userAgent: host.userAgent,
},
options,
);
Expand Down
5 changes: 5 additions & 0 deletions apps/kimi-code/test/cli/version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest';

import {
buildKimiDefaultHeaders,
createKimiCodeUserAgent,
getHostPackageJsonPath,
getHostPackageRoot,
getVersion,
Expand All @@ -25,4 +26,8 @@ describe('cli version helpers', () => {

expect(headers['User-Agent']).toBe('kimi-code-cli/1.2.3');
});

it('builds the product user-agent for ad-hoc fetches', () => {
expect(createKimiCodeUserAgent('1.2.3')).toBe('kimi-code-cli/1.2.3');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { IConfigService } from '#/app/config/config';
import { ErrorCodes, Error2 } from '#/errors';
import { IEventService } from '#/app/event/event';
import { IModelService, MODELS_SECTION, type ModelAlias } from '#/app/model/model';
import { IHostRequestHeaders } from '#/app/model/hostRequestHeaders';
import {
IProviderService,
type OAuthRef,
Expand Down Expand Up @@ -66,6 +67,7 @@ export class ModelCatalogService implements IModelCatalogService {
@IConfigService private readonly config: IConfigService,
@IOAuthService private readonly oauth: IOAuthService,
@IEventService private readonly events: IEventService,
@IHostRequestHeaders private readonly hostRequestHeaders: IHostRequestHeaders,
) {}

async listModels(): Promise<readonly ModelCatalogItem[]> {
Expand Down Expand Up @@ -151,6 +153,9 @@ export class ModelCatalogService implements IModelCatalogService {
removeProvider: (providerId) => this.removeProviderForRefresh(providerId),
setConfig: (patch) => this.applyRefreshPatch(patch),
resolveOAuthToken: (providerName, oauthRef) => this.resolveOAuthToken(providerName, oauthRef),
// Mirrors ModelResolverService: only the User-Agent leaves the host, so
// device identity never reaches third-party registry endpoints.
userAgent: this.hostRequestHeaders.headers['User-Agent'],
};
}

Expand Down
44 changes: 44 additions & 0 deletions packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { MODEL_CATALOG_SECTION } from '#/app/modelCatalog/configSection';
import { IModelCatalogService } from '#/app/modelCatalog/modelCatalog';
import { ModelCatalogService } from '#/app/modelCatalog/modelCatalogService';
import { IModelService, type ModelAlias } from '#/app/model/model';
import { HostRequestHeaders, IHostRequestHeaders } from '#/app/model/hostRequestHeaders';
import { ModelService } from '#/app/model/modelService';
import { IProviderService, type ProviderConfig } from '#/app/provider/provider';
import { ProviderService } from '#/app/provider/providerService';
Expand Down Expand Up @@ -118,6 +119,10 @@ describe('ModelCatalogService', () => {
reg.define(IModelService, ModelService);
reg.define(IProviderService, ProviderService);
reg.define(IModelCatalogService, ModelCatalogService);
reg.defineInstance(
IHostRequestHeaders,
new HostRequestHeaders({ 'User-Agent': 'kimi-code-cli/test' }),
);
},
});
});
Expand Down Expand Up @@ -314,4 +319,43 @@ describe('ModelCatalogService', () => {
expect(maxInFlight).toBe(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
});

it('refreshProviderModels sends the host User-Agent on custom-registry fetches', async () => {
backing.providers = {
acme: {
type: 'openai',
apiKey: 'sk-acme',
source: {
kind: 'apiJson',
url: 'https://registry.example.test/api.json',
apiKey: 'sk-registry',
},
},
};
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({
acme: {
id: 'acme',
name: 'Acme',
api: 'https://acme.example.test/v1',
type: 'openai',
models: { m1: { id: 'm1', name: 'M1' } },
},
}),
{ headers: { 'Content-Type': 'application/json' } },
),
);
vi.stubGlobal('fetch', fetchMock);

await catalog().refreshProviderModels({ scope: 'all' });

expect(fetchMock).toHaveBeenCalledWith(
'https://registry.example.test/api.json',
expect.objectContaining({
headers: expect.objectContaining({ 'User-Agent': 'kimi-code-cli/test' }),
}),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export class ModelCatalogService
setConfig: (patch) => this.core.rpc.setKimiConfig(patch as Record<string, unknown>),
resolveOAuthToken: (providerName, oauthRef) =>
this._resolveOAuthToken(providerName, oauthRef),
userAgent: this.core.kimiRequestHeaders?.['User-Agent'],
};
}

Expand Down
48 changes: 48 additions & 0 deletions packages/agent-core/test/services/model-catalog-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,4 +439,52 @@ describe('ModelCatalogService', () => {
expect(result.unchanged).toEqual([KIMI_CODE_PROVIDER_NAME]);
expect(published).toEqual([]);
});

it('sends the host User-Agent on custom-registry fetches', async () => {
const configRef: { current: KimiConfig } = {
current: {
providers: {
acme: {
type: 'openai',
apiKey: 'sk-acme',
source: {
kind: 'apiJson',
url: 'https://registry.example.test/api.json',
apiKey: 'sk-registry',
},
},
},
models: {},
},
};
const { core } = makeCore(configRef);
(core as { kimiRequestHeaders?: Record<string, string> }).kimiRequestHeaders = {
'User-Agent': 'kimi-code-cli/test',
};
const fetchMock = vi.fn(
async () =>
new Response(
JSON.stringify({
acme: {
id: 'acme',
name: 'Acme',
api: 'https://acme.example.test/v1',
type: 'openai',
models: { m1: { id: 'm1', name: 'M1' } },
},
}),
),
);
vi.stubGlobal('fetch', fetchMock);
const svc = ModelCatalogService._createForTest(makeEnv(), core, authFacade());

await svc.refreshProviderModels();

expect(fetchMock).toHaveBeenCalledWith(
'https://registry.example.test/api.json',
expect.objectContaining({
headers: expect.objectContaining({ 'User-Agent': 'kimi-code-cli/test' }),
}),
);
});
});
6 changes: 6 additions & 0 deletions packages/kap-server/src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import {
bootstrap,
hostRequestHeadersSeed,
IConfigService,
IModelCatalogService,
logSeed,
Expand Down Expand Up @@ -218,6 +219,11 @@ export async function startServer(opts: ServerStartOptions = {}): Promise<Runnin
// the session index — all persist to disk.
const { app: core } = bootstrap({ homeDir, configPath }, [
...logSeed(logging),
// Default host identity so outbound requests (model, WebSearch, registry
// refresh) carry a product User-Agent even when the embedding host did not
// seed its own headers. Hosts like the CLI pass full Kimi identity headers
// through `opts.seeds`, which override this entry (last seed wins).
...hostRequestHeadersSeed({ 'User-Agent': `kimi-code-cli/${hostVersion}` }),
...(opts.seeds ?? []),
]);

Expand Down
29 changes: 29 additions & 0 deletions packages/kap-server/test/boot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import { join } from 'node:path';
import { pino } from 'pino';
import { afterEach, describe, expect, it } from 'vitest';

import { hostRequestHeadersSeed, IHostRequestHeaders } from '@moonshot-ai/agent-core-v2';

import type { LockContents } from '../src/lock';
import { listenWithPortRetry, type RunningServer, startServer } from '../src/start';
import { getServerVersion } from '../src/version';
import { authedFetch } from './helpers/auth';

describe('server-v2 boot', () => {
Expand Down Expand Up @@ -76,6 +79,32 @@ describe('server-v2 boot', () => {
expect(oauthBody.code).toBe(0);
expect(oauthBody.data).toBeNull();
});

it('seeds a default product User-Agent that opts.seeds can override', async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-ua-'));
server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
});
const defaults = server.core.accessor.get(IHostRequestHeaders);
expect(defaults.headers['User-Agent']).toBe(`kimi-code-cli/${getServerVersion()}`);

// Restart on the same homeDir with a host-provided seed; it must win over
// the default (the CLI passes full Kimi identity headers this way).
await server.close();
server = undefined;
server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
seeds: hostRequestHeadersSeed({ 'User-Agent': 'custom-host/9.9' }),
});
const overridden = server.core.accessor.get(IHostRequestHeaders);
expect(overridden.headers['User-Agent']).toBe('custom-host/9.9');
});
});

function silentLogger() {
Expand Down
20 changes: 16 additions & 4 deletions packages/node-sdk/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,25 @@ export class CatalogFetchError extends Error {
}
}

/** Fetches a models.dev-style catalog. Public endpoint, no credentials needed. */
export interface FetchCatalogOptions {
readonly signal?: AbortSignal;
readonly fetchImpl?: typeof fetch;
readonly userAgent?: string;
}

/**
* Fetches a models.dev-style catalog. Public endpoint, no credentials needed.
* `userAgent` identifies the host product (e.g. `kimi-code-cli/1.2.3`); when
* omitted the request falls back to the runtime default (`User-Agent: node`).
*/
export async function fetchCatalog(
url: string,
signal?: AbortSignal,
fetchImpl: typeof fetch = fetch,
options: FetchCatalogOptions = {},
): Promise<Catalog> {
const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal });
const { signal, fetchImpl = fetch, userAgent } = options;
const headers: Record<string, string> = { Accept: 'application/json' };
if (userAgent !== undefined) headers['User-Agent'] = userAgent;
const res = await fetchImpl(url, { headers, signal });
if (!res.ok) {
throw new CatalogFetchError(`Failed to fetch catalog (HTTP ${res.status}).`, res.status);
}
Expand Down
1 change: 1 addition & 0 deletions packages/node-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type {
Catalog,
CatalogModel,
CatalogProviderEntry,
FetchCatalogOptions,
} from '#/catalog';

export {
Expand Down
Loading
Loading