From 400a86cddd7d5b3fc0035ba1d3aa9a371632cf7b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 13 Jul 2026 16:36:54 +0800 Subject: [PATCH 1/2] fix: send the product User-Agent on provider registry and catalog fetches Registry (api.json) and models.dev catalog fetches only carried the runtime default User-Agent while every other outbound request sends kimi-code-cli/. Thread an optional userAgent through fetchCustomRegistry / fetchCatalog and the shared refresh host, pass the product UA from the CLI, TUI, and both daemons, and seed a default product UA in kap-server that hosts can override via opts.seeds. --- .changeset/registry-fetch-user-agent.md | 5 ++ apps/kimi-code/src/cli/sub/provider.ts | 6 +-- apps/kimi-code/src/cli/version.ts | 10 +++- apps/kimi-code/src/tui/commands/provider.ts | 10 +++- .../src/tui/controllers/auth-flow.ts | 4 ++ .../src/tui/utils/refresh-providers.ts | 3 ++ apps/kimi-code/test/cli/version.test.ts | 5 ++ .../app/modelCatalog/modelCatalogService.ts | 5 ++ .../app/modelCatalog/modelCatalog.test.ts | 44 +++++++++++++++++ .../modelCatalog/modelCatalogService.ts | 1 + .../services/model-catalog-service.test.ts | 48 +++++++++++++++++++ packages/kap-server/src/start.ts | 6 +++ packages/kap-server/test/boot.test.ts | 29 +++++++++++ packages/node-sdk/src/catalog.ts | 11 ++++- packages/node-sdk/test/catalog.test.ts | 20 ++++++++ packages/oauth/src/custom-registry.ts | 7 +++ packages/oauth/src/refreshProviderModels.ts | 11 ++++- packages/oauth/test/custom-registry.test.ts | 22 +++++++++ 18 files changed, 237 insertions(+), 10 deletions(-) create mode 100644 .changeset/registry-fetch-user-agent.md 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..a3589724cc 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, fetch, undefined, 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, undefined, fetch, 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..d3d1fd0cfc 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,12 @@ 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, + controller.signal, + fetch, + createKimiCodeUserAgent(), + ); spinner.stop({ ok: true, label: 'Catalog loaded.' }); } catch (error) { if (controller.signal.aborted) { @@ -276,7 +282,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise let entries: Awaited>; try { - entries = await fetchCustomRegistry(source); + entries = await fetchCustomRegistry(source, fetch, undefined, 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..3e84baf5a7 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -23,13 +23,20 @@ export class CatalogFetchError extends Error { } } -/** Fetches a models.dev-style catalog. Public endpoint, no credentials needed. */ +/** + * 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, + userAgent?: string, ): Promise { - const res = await fetchImpl(url, { headers: { Accept: 'application/json' }, signal }); + 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/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts index 069f9d38f3..f180a5d994 100644 --- a/packages/node-sdk/test/catalog.test.ts +++ b/packages/node-sdk/test/catalog.test.ts @@ -52,6 +52,26 @@ describe('fetchCatalog', () => { fetchCatalog('https://x', undefined, 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', + undefined, + fetchMock as unknown as typeof fetch, + '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', undefined, 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..1ad668feda 100644 --- a/packages/oauth/src/custom-registry.ts +++ b/packages/oauth/src/custom-registry.ts @@ -189,15 +189,22 @@ 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, + userAgent?: string, ): Promise> { 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/refreshProviderModels.ts b/packages/oauth/src/refreshProviderModels.ts index 654374323e..df357d9160 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, fetch, undefined, 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..6c7ccab8ef 100644 --- a/packages/oauth/test/custom-registry.test.ts +++ b/packages/oauth/test/custom-registry.test.ts @@ -127,6 +127,28 @@ 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, + fetchMock as unknown as typeof fetch, + undefined, + '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, 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(); From c9707024acb5bf4626a67e00a8e5bf2e9b05001b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Mon, 13 Jul 2026 17:21:47 +0800 Subject: [PATCH 2/2] refactor: use options for registry fetches --- apps/kimi-code/src/cli/sub/provider.ts | 4 +-- apps/kimi-code/src/tui/commands/provider.ts | 12 ++++---- packages/node-sdk/src/catalog.ts | 11 ++++++-- packages/node-sdk/src/index.ts | 1 + packages/node-sdk/test/catalog.test.ts | 19 ++++++++----- packages/oauth/src/custom-registry.ts | 11 ++++++-- packages/oauth/src/index.ts | 1 + packages/oauth/src/refreshProviderModels.ts | 2 +- packages/oauth/test/custom-registry.test.ts | 31 +++++++++++++-------- 9 files changed, 57 insertions(+), 35 deletions(-) diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index a3589724cc..509ec2d220 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -99,7 +99,7 @@ export async function handleProviderAdd( let entries: Awaited>; try { - entries = await fetchCustomRegistry(source, fetch, undefined, createKimiCodeUserAgent()); + 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, undefined, fetch, createKimiCodeUserAgent()); + 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/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index d3d1fd0cfc..994cae8b3f 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -161,12 +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, - fetch, - createKimiCodeUserAgent(), - ); + catalog = await fetchCatalog(DEFAULT_CATALOG_URL, { + signal: controller.signal, + userAgent: createKimiCodeUserAgent(), + }); spinner.stop({ ok: true, label: 'Catalog loaded.' }); } catch (error) { if (controller.signal.aborted) { @@ -282,7 +280,7 @@ async function handleCustomRegistryAddViaDialog(host: SlashCommandHost): Promise let entries: Awaited>; try { - entries = await fetchCustomRegistry(source, fetch, undefined, createKimiCodeUserAgent()); + entries = await fetchCustomRegistry(source, { userAgent: createKimiCodeUserAgent() }); } catch (error) { host.showError(`Failed to import registry: ${formatErrorMessage(error)}`); return false; diff --git a/packages/node-sdk/src/catalog.ts b/packages/node-sdk/src/catalog.ts index 3e84baf5a7..63a6f0bfe7 100644 --- a/packages/node-sdk/src/catalog.ts +++ b/packages/node-sdk/src/catalog.ts @@ -23,6 +23,12 @@ export class CatalogFetchError extends Error { } } +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 @@ -30,10 +36,9 @@ export class CatalogFetchError extends Error { */ export async function fetchCatalog( url: string, - signal?: AbortSignal, - fetchImpl: typeof fetch = fetch, - userAgent?: string, + options: FetchCatalogOptions = {}, ): Promise { + 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 }); 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 f180a5d994..106c3c64aa 100644 --- a/packages/node-sdk/test/catalog.test.ts +++ b/packages/node-sdk/test/catalog.test.ts @@ -35,21 +35,23 @@ 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/); }); @@ -58,9 +60,10 @@ describe('fetchCatalog', () => { await fetchCatalog( 'https://x/api.json', - undefined, - fetchMock as unknown as typeof fetch, - 'kimi-code-cli/1.2.3', + { + 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; @@ -68,7 +71,9 @@ describe('fetchCatalog', () => { expect(withUaHeaders['Accept']).toBe('application/json'); fetchMock.mockClear(); - await fetchCatalog('https://x/api.json', undefined, fetchMock as unknown as typeof fetch); + 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(); }); diff --git a/packages/oauth/src/custom-registry.ts b/packages/oauth/src/custom-registry.ts index 1ad668feda..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 @@ -195,10 +201,9 @@ function toProviderEntry(value: unknown): CustomRegistryProviderEntry | undefine */ export async function fetchCustomRegistry( source: CustomRegistrySource, - fetchImpl: typeof fetch = fetch, - signal?: AbortSignal, - userAgent?: string, + options: FetchCustomRegistryOptions = {}, ): Promise> { + const { signal, fetchImpl = fetch, userAgent } = options; const headers: Record = { Accept: 'application/json', }; 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 df357d9160..cebfafffc1 100644 --- a/packages/oauth/src/refreshProviderModels.ts +++ b/packages/oauth/src/refreshProviderModels.ts @@ -126,7 +126,7 @@ async function fetchCustomRegistryFromSources( for (const source of sources) { try { return { - entries: await fetchCustomRegistry(source, fetch, undefined, userAgent), + entries: await fetchCustomRegistry(source, { userAgent }), source, }; } catch (error) { diff --git a/packages/oauth/test/custom-registry.test.ts b/packages/oauth/test/custom-registry.test.ts index 6c7ccab8ef..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); @@ -132,9 +132,10 @@ describe('fetchCustomRegistry', () => { await fetchCustomRegistry( KOKUB_SOURCE, - fetchMock as unknown as typeof fetch, - undefined, - 'kimi-code-cli/1.2.3', + { + 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]; @@ -143,7 +144,9 @@ describe('fetchCustomRegistry', () => { ); fetchMock.mockClear(); - await fetchCustomRegistry(KOKUB_SOURCE, fetchMock as unknown as typeof fetch); + 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(); @@ -155,8 +158,10 @@ describe('fetchCustomRegistry', () => { 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); @@ -171,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); @@ -183,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/); }); @@ -208,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']);