diff --git a/.changeset/registry-fetch-user-agent.md b/.changeset/registry-fetch-user-agent.md new file mode 100644 index 0000000000..4193b031ef --- /dev/null +++ b/.changeset/registry-fetch-user-agent.md @@ -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. diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index 8ed4546aa6..509ec2d220 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -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; @@ -99,7 +99,7 @@ export async function handleProviderAdd( let entries: Awaited>; 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`); @@ -398,7 +398,7 @@ export async function handleCatalogAdd( async function loadCatalogOrExit(deps: ProviderDeps, url: string): Promise { 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`); diff --git a/apps/kimi-code/src/cli/version.ts b/apps/kimi-code/src/cli/version.ts index f0a9f695de..d5a4f36cdb 100644 --- a/apps/kimi-code/src/cli/version.ts +++ b/apps/kimi-code/src/cli/version.ts @@ -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'; @@ -55,6 +55,14 @@ export function createKimiCodeHostIdentity(version = getVersion()): KimiHostIden }; } +/** + * Product User-Agent (`kimi-code-cli/`) 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 { return createKimiDefaultHeaders({ homeDir: getDataDir(), diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index eb416a54e6..994cae8b3f 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -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, @@ -160,7 +161,10 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { 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) { @@ -276,7 +280,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise let entries: Awaited>; try { - entries = await fetchCustomRegistry(source); + entries = await fetchCustomRegistry(source, { userAgent: createKimiCodeUserAgent() }); } catch (error) { host.showError(`Failed to import registry: ${formatErrorMessage(error)}`); return false; diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index ae70c0cb8f..a7acc77ce8 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -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'; @@ -173,6 +176,7 @@ export class AuthFlowController { const tokenProvider = host.harness.auth.resolveOAuthTokenProvider(providerName, oauthRef); return tokenProvider.getAccessToken(); }, + userAgent: createKimiCodeUserAgent(), }, { scope }, ); diff --git a/apps/kimi-code/src/tui/utils/refresh-providers.ts b/apps/kimi-code/src/tui/utils/refresh-providers.ts index 926b458e1e..0801575f98 100644 --- a/apps/kimi-code/src/tui/utils/refresh-providers.ts +++ b/apps/kimi-code/src/tui/utils/refresh-providers.ts @@ -17,6 +17,8 @@ export interface RefreshProviderHost { removeProvider(providerId: string): Promise; setConfig(patch: KimiConfigPatch): Promise; resolveOAuthToken(providerName: string, oauthRef?: OAuthRef): Promise; + /** Product User-Agent sent on custom-registry (api.json) fetches. */ + readonly userAgent?: string; } export type { ProviderChange, RefreshProviderOptions, RefreshProviderScope, RefreshResult }; @@ -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, ); diff --git a/apps/kimi-code/test/cli/version.test.ts b/apps/kimi-code/test/cli/version.test.ts index 6729cb377d..073d5c727b 100644 --- a/apps/kimi-code/test/cli/version.test.ts +++ b/apps/kimi-code/test/cli/version.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import { buildKimiDefaultHeaders, + createKimiCodeUserAgent, getHostPackageJsonPath, getHostPackageRoot, getVersion, @@ -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'); + }); }); diff --git a/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts b/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts index d9e304c5e9..b656b949a7 100644 --- a/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts +++ b/packages/agent-core-v2/src/app/modelCatalog/modelCatalogService.ts @@ -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, @@ -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 { @@ -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'], }; } diff --git a/packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts b/packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts index a2a9a9922e..4f387e9cfa 100644 --- a/packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts +++ b/packages/agent-core-v2/test/app/modelCatalog/modelCatalog.test.ts @@ -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'; @@ -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' }), + ); }, }); }); @@ -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' }), + }), + ); + }); }); diff --git a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts index 6c5c4b4344..69418ca6a8 100644 --- a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts +++ b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts @@ -150,6 +150,7 @@ export class ModelCatalogService setConfig: (patch) => this.core.rpc.setKimiConfig(patch as Record), resolveOAuthToken: (providerName, oauthRef) => this._resolveOAuthToken(providerName, oauthRef), + userAgent: this.core.kimiRequestHeaders?.['User-Agent'], }; } diff --git a/packages/agent-core/test/services/model-catalog-service.test.ts b/packages/agent-core/test/services/model-catalog-service.test.ts index 636a696435..a6d6440c2b 100644 --- a/packages/agent-core/test/services/model-catalog-service.test.ts +++ b/packages/agent-core/test/services/model-catalog-service.test.ts @@ -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 }).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' }), + }), + ); + }); }); diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index da36665b94..392d97548b 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -9,6 +9,7 @@ import { bootstrap, + hostRequestHeadersSeed, IConfigService, IModelCatalogService, logSeed, @@ -218,6 +219,11 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { @@ -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() { diff --git a/packages/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts index 592a1647f4..63a6f0bfe7 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -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 { - const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal }); + const { signal, fetchImpl = fetch, userAgent } = options; + const headers: Record = { 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); } diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index 7f5806c97e..f58e060751 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -32,6 +32,7 @@ export type { Catalog, CatalogModel, CatalogProviderEntry, + FetchCatalogOptions, } from '#/catalog'; export { diff --git a/packages/node-sdk/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts index 069f9d38f3..106c3c64aa 100644 --- a/packages/node-sdk/test/catalog.test.ts +++ b/packages/node-sdk/test/catalog.test.ts @@ -35,23 +35,48 @@ describe('fetchCatalog', () => { it('fetches and returns the catalog map', async () => { const catalog = { anthropic: { id: 'anthropic', models: { x: { id: 'x', limit: { context: 1000 } } } } }; const fetchMock = vi.fn(async () => catalogResponse(catalog)); - const result = await fetchCatalog('https://x/api.json', undefined, fetchMock as unknown as typeof fetch); + const result = await fetchCatalog('https://x/api.json', { + fetchImpl: fetchMock as unknown as typeof fetch, + }); expect(result).toEqual(catalog); }); it('throws CatalogFetchError on HTTP error', async () => { const fetchMock = vi.fn(async () => catalogResponse('no', 500)); await expect( - fetchCatalog('https://x', undefined, fetchMock as unknown as typeof fetch), + fetchCatalog('https://x', { fetchImpl: fetchMock as unknown as typeof fetch }), ).rejects.toBeInstanceOf(CatalogFetchError); }); it('throws on a non-object payload', async () => { const fetchMock = vi.fn(async () => catalogResponse([1, 2])); await expect( - fetchCatalog('https://x', undefined, fetchMock as unknown as typeof fetch), + fetchCatalog('https://x', { fetchImpl: fetchMock as unknown as typeof fetch }), ).rejects.toThrow(/Unexpected catalog response/); }); + + it('sends the given User-Agent, and none by default', async () => { + const fetchMock = vi.fn(async () => catalogResponse({})); + + await fetchCatalog( + 'https://x/api.json', + { + fetchImpl: fetchMock as unknown as typeof fetch, + userAgent: 'kimi-code-cli/1.2.3', + }, + ); + const withUa = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + const withUaHeaders = withUa[1].headers as Record; + expect(withUaHeaders['User-Agent']).toBe('kimi-code-cli/1.2.3'); + expect(withUaHeaders['Accept']).toBe('application/json'); + + fetchMock.mockClear(); + await fetchCatalog('https://x/api.json', { + fetchImpl: fetchMock as unknown as typeof fetch, + }); + const withoutUa = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect((withoutUa[1].headers as Record)['User-Agent']).toBeUndefined(); + }); }); describe('catalogModelToAlias', () => { diff --git a/packages/oauth/src/custom-registry.ts b/packages/oauth/src/custom-registry.ts index b0faf2eeae..e7bef6dd70 100644 --- a/packages/oauth/src/custom-registry.ts +++ b/packages/oauth/src/custom-registry.ts @@ -18,6 +18,12 @@ export interface CustomRegistrySource { readonly apiKey: string; } +export interface FetchCustomRegistryOptions { + readonly signal?: AbortSignal; + readonly fetchImpl?: typeof fetch; + readonly userAgent?: string; +} + /** * The kosong `ProviderConfig` union (`packages/kosong/src/providers/index.ts`) * mirrors these literal values. `kimi` is included because the api.json schema @@ -189,15 +195,21 @@ function toProviderEntry(value: unknown): CustomRegistryProviderEntry | undefine * Fetches and validates an api.json document. The returned record is keyed by * the top-level provider key in the document (which may differ from * `entry.id`); callers should iterate `Object.values` to apply each entry. + * + * `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 fetchCustomRegistry( source: CustomRegistrySource, - fetchImpl: typeof fetch = fetch, - signal?: AbortSignal, + options: FetchCustomRegistryOptions = {}, ): Promise> { + const { signal, fetchImpl = fetch, userAgent } = options; const headers: Record = { Accept: 'application/json', }; + if (userAgent !== undefined) { + headers['User-Agent'] = userAgent; + } if (source.apiKey.length > 0) { headers['Authorization'] = `Bearer ${source.apiKey}`; } diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 3c45ba960b..e6037c2953 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -145,6 +145,7 @@ export type { CustomRegistryProviderEntry, CustomRegistryProviderType, CustomRegistrySource, + FetchCustomRegistryOptions, } from './custom-registry'; export { KimiOAuthToolkit, resolveKimiTokenStorageName } from './toolkit'; diff --git a/packages/oauth/src/refreshProviderModels.ts b/packages/oauth/src/refreshProviderModels.ts index 654374323e..cebfafffc1 100644 --- a/packages/oauth/src/refreshProviderModels.ts +++ b/packages/oauth/src/refreshProviderModels.ts @@ -33,6 +33,12 @@ export interface RefreshProviderHost { removeProvider(providerId: string): Promise; setConfig(patch: ManagedKimiConfigShape): Promise; resolveOAuthToken(providerName: string, oauthRef?: ManagedKimiOAuthRef): Promise; + /** + * Product User-Agent sent on custom-registry (api.json) fetches, e.g. + * `kimi-code-cli/1.2.3`. When omitted the fetch falls back to the runtime + * default (`User-Agent: node`). + */ + readonly userAgent?: string; } export interface ProviderChange { @@ -111,6 +117,7 @@ function customRegistrySourceCredentialKey(source: CustomRegistrySource): string async function fetchCustomRegistryFromSources( sources: readonly CustomRegistrySource[], + userAgent?: string, ): Promise<{ readonly entries: Awaited>; readonly source: CustomRegistrySource; @@ -119,7 +126,7 @@ async function fetchCustomRegistryFromSources( for (const source of sources) { try { return { - entries: await fetchCustomRegistry(source), + entries: await fetchCustomRegistry(source, { userAgent }), source, }; } catch (error) { @@ -543,7 +550,7 @@ export async function refreshProviderModels( // are left untouched). if (targetId !== undefined && !providerIds.includes(targetId)) continue; try { - const { entries, source } = await fetchCustomRegistryFromSources(sources); + const { entries, source } = await fetchCustomRegistryFromSources(sources, host.userAgent); // Build the whole batch on one clone so that several changed providers // from the same source do not overwrite each other's aliases, and so the // config we compare is exactly the config we persist. diff --git a/packages/oauth/test/custom-registry.test.ts b/packages/oauth/test/custom-registry.test.ts index a8a0d4441d..465edcbd35 100644 --- a/packages/oauth/test/custom-registry.test.ts +++ b/packages/oauth/test/custom-registry.test.ts @@ -66,7 +66,7 @@ describe('fetchCustomRegistry', () => { const result = await fetchCustomRegistry( KOKUB_SOURCE, - fetchMock as unknown as typeof fetch, + { fetchImpl: fetchMock as unknown as typeof fetch }, ); expect(Object.keys(result)).toHaveLength(3); @@ -101,7 +101,7 @@ describe('fetchCustomRegistry', () => { const result = await fetchCustomRegistry( KOKUB_SOURCE, - fetchMock as unknown as typeof fetch, + { fetchImpl: fetchMock as unknown as typeof fetch }, ); expect(result['registry_chat-completions']?.models['gpt-5.5']).toEqual({ @@ -117,7 +117,7 @@ describe('fetchCustomRegistry', () => { await fetchCustomRegistry( { kind: 'apiJson', url: KOKUB_SOURCE.url, apiKey: '' }, - fetchMock as unknown as typeof fetch, + { fetchImpl: fetchMock as unknown as typeof fetch }, ); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -127,14 +127,41 @@ describe('fetchCustomRegistry', () => { expect(headers['Accept']).toBe('application/json'); }); + it('sends the given User-Agent, and none by default', async () => { + const fetchMock = vi.fn(async () => makeJsonResponse(makeKokubResponseBody())); + + await fetchCustomRegistry( + KOKUB_SOURCE, + { + fetchImpl: fetchMock as unknown as typeof fetch, + userAgent: 'kimi-code-cli/1.2.3', + }, + ); + + const withUa = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect((withUa[1].headers as Record)['User-Agent']).toBe( + 'kimi-code-cli/1.2.3', + ); + + fetchMock.mockClear(); + await fetchCustomRegistry(KOKUB_SOURCE, { + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + const withoutUa = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect((withoutUa[1].headers as Record)['User-Agent']).toBeUndefined(); + }); + it('forwards an AbortSignal when provided', async () => { const fetchMock = vi.fn(async () => makeJsonResponse(makeKokubResponseBody())); const controller = new AbortController(); await fetchCustomRegistry( KOKUB_SOURCE, - fetchMock as unknown as typeof fetch, - controller.signal, + { + fetchImpl: fetchMock as unknown as typeof fetch, + signal: controller.signal, + }, ); expect(fetchMock).toHaveBeenCalledTimes(1); @@ -149,7 +176,7 @@ describe('fetchCustomRegistry', () => { const error = await fetchCustomRegistry( KOKUB_SOURCE, - fetchMock as unknown as typeof fetch, + { fetchImpl: fetchMock as unknown as typeof fetch }, ).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(CustomRegistryApiError); @@ -161,7 +188,9 @@ describe('fetchCustomRegistry', () => { const fetchMock = vi.fn(async () => makeJsonResponse(['not', 'an', 'object'])); await expect( - fetchCustomRegistry(KOKUB_SOURCE, fetchMock as unknown as typeof fetch), + fetchCustomRegistry(KOKUB_SOURCE, { + fetchImpl: fetchMock as unknown as typeof fetch, + }), ).rejects.toThrow(/expected a JSON object/); }); @@ -186,7 +215,7 @@ describe('fetchCustomRegistry', () => { try { const result = await fetchCustomRegistry( KOKUB_SOURCE, - fetchMock as unknown as typeof fetch, + { fetchImpl: fetchMock as unknown as typeof fetch }, ); expect(Object.keys(result)).toEqual(['registry_chat-completions']);