diff --git a/.changeset/fix-host-headers-first-party-only.md b/.changeset/fix-host-headers-first-party-only.md new file mode 100644 index 0000000000..0662b253c3 --- /dev/null +++ b/.changeset/fix-host-headers-first-party-only.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Stop forwarding the host identity headers (including X-Msh-Device-Id) to kimi-typed providers whose endpoint is not the first-party Moonshot host, so a Kimi-compatible proxy or gateway no longer receives the device identity set. diff --git a/packages/agent-core-v2/src/kosong/model/catalogService.ts b/packages/agent-core-v2/src/kosong/model/catalogService.ts index e73f214f38..02929ce32d 100644 --- a/packages/agent-core-v2/src/kosong/model/catalogService.ts +++ b/packages/agent-core-v2/src/kosong/model/catalogService.ts @@ -21,8 +21,11 @@ * referenced provider vendor's declared `baseProtocol`; endpoint and * credential env fallbacks resolve through `resolveProviderEndpoint` against * the config env bag; host-header forwarding follows the vendor definition's - * `hostHeaders`; capability detection is `resolveCapability(protocol, name, - * providerType)`. + * `hostHeaders`, scoped by `isFirstPartyBaseUrl` — a vendor's + * `hostHeaders: 'full'` contract covers its own endpoint only, so a provider + * that speaks the same protocol but points elsewhere receives just the + * `User-Agent`, never the device identity set; capability detection is + * `resolveCapability(protocol, name, providerType)`. * * Caching (load-bearing): assembled entries are invalidated ONLY by the * model/provider config-change events. Tests that mutate config @@ -100,7 +103,7 @@ import { toProtocolProvider, } from './catalog'; import { ModelCatalogErrors } from './errors'; -import { IHostRequestHeaders } from './hostRequestHeaders'; +import { IHostRequestHeaders, isFirstPartyBaseUrl } from './hostRequestHeaders'; import { assembleModelInspection, attributeEffectiveFields, @@ -409,6 +412,7 @@ export class ModelCatalog extends Disposable implements IModelCatalog { providerConfig?.type, providerConfig?.customHeaders, this.hostRequestHeaders, + resolvedBaseUrl, ), capabilities, maxContextSize: model.maxContextSize, @@ -571,10 +575,12 @@ export function resolveOutboundHeaders( providerType: string | undefined, customHeaders: Readonly> | undefined, host: Pick, + baseUrl: string | undefined, ): Readonly> { const forwardsAll = providerType !== undefined && - getProviderDefinition(providerType)?.hostHeaders === 'full'; + getProviderDefinition(providerType)?.hostHeaders === 'full' && + isFirstPartyBaseUrl(baseUrl); const hostLayer = forwardsAll ? host.headers : host.thirdPartyHeaders; return { ...parseKimiCodeCustomHeaders(), ...hostLayer, ...customHeaders }; } diff --git a/packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts b/packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts index e334943cec..7201b3a83e 100644 --- a/packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts +++ b/packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts @@ -28,3 +28,24 @@ export interface IHostRequestHeaders { } export const IHostRequestHeaders = createDecorator('hostRequestHeaders'); + +const FIRST_PARTY_HOSTS = new Set(['api.moonshot.ai', 'api.moonshot.cn']); + +/** + * True when a base URL points at the vendor's own endpoint, the only place + * the full host identity set (device id included) may be forwarded to. + * HTTPS is required: the same hostname over plain HTTP must not receive + * those headers in the clear. An unset base URL means the vendor's default + * endpoint, which is first-party by definition. + */ +export function isFirstPartyBaseUrl(baseUrl: string | undefined): boolean { + if (baseUrl === undefined) { + return true; + } + try { + const url = new URL(baseUrl); + return url.protocol === 'https:' && FIRST_PARTY_HOSTS.has(url.hostname); + } catch { + return false; + } +} diff --git a/packages/agent-core-v2/src/kosong/model/inspection.ts b/packages/agent-core-v2/src/kosong/model/inspection.ts index e2d6c29d70..75a75a0b56 100644 --- a/packages/agent-core-v2/src/kosong/model/inspection.ts +++ b/packages/agent-core-v2/src/kosong/model/inspection.ts @@ -31,6 +31,7 @@ import { getProviderDefinition } from '../provider/providerDefinition'; import type { ModelRecord } from './model'; import type { ResolvedModelAuthMaterial } from './model.types'; +import { isFirstPartyBaseUrl } from './hostRequestHeaders'; export interface InspectedAuth { @@ -489,10 +490,13 @@ function attributeHeaders( ): void { const envLayer = parseKimiCodeCustomHeaders(); const rawHost = trace.captured>>(TRACE.hostHeaders) ?? {}; + // Keep the attribution aligned with resolveOutboundHeaders: full host + // identity only counts as forwarded on a first-party endpoint. const identitySlug = trace.captured(TRACE.identitySlug); const forwardsAll = providerConfig?.type !== undefined && - getProviderDefinition(providerConfig.type)?.hostHeaders === 'full'; + getProviderDefinition(providerConfig.type)?.hostHeaders === 'full' && + isFirstPartyBaseUrl(providerConfig.baseUrl); const hostLayer: Readonly> = forwardsAll ? rawHost : trace.captured>>(TRACE.thirdPartyHeaders) ?? {}; diff --git a/packages/agent-core-v2/test/kosong/model/catalog.test.ts b/packages/agent-core-v2/test/kosong/model/catalog.test.ts index 0a65014f29..7e88f4566f 100644 --- a/packages/agent-core-v2/test/kosong/model/catalog.test.ts +++ b/packages/agent-core-v2/test/kosong/model/catalog.test.ts @@ -186,6 +186,35 @@ describe('Model assembly (pure data)', () => { } }); + it('keeps full host headers when a kimi provider explicitly targets the first-party host', () => { + const { host, catalog } = createHost({ + providers: { kimi: { type: 'kimi', apiKey: 'sk', baseUrl: 'https://api.moonshot.ai/v1' } }, + models: { k2: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 200000 } }, + }); + try { + const model = catalog.get('k2'); + expect(model.headers).toMatchObject({ + 'User-Agent': 'kimi-test/1.0', + 'X-Msh-Device-Id': 'device-1', + }); + } finally { + host.dispose(); + } + }); + + it('withholds the identity set from a first-party hostname over plain http', () => { + const { host, catalog } = createHost({ + providers: { kimi: { type: 'kimi', apiKey: 'sk', baseUrl: 'http://api.moonshot.ai/v1' } }, + models: { k2: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 200000 } }, + }); + try { + const model = catalog.get('k2'); + expect(model.headers).toEqual({ 'User-Agent': 'kimi-test/1.0' }); + } finally { + host.dispose(); + } + }); + it('forwards only the User-Agent to vendors without a full hostHeaders declaration', () => { const { host, catalog } = createHost({ providers: { @@ -211,7 +240,7 @@ describe('Model assembly (pure data)', () => { models: { gpt: { provider: 'openai', model: 'gpt-5', maxContextSize: 128000 } }, }; const OFFICIAL = { - providers: { kimi: { type: 'kimi', apiKey: 'sk', baseUrl: 'https://api.example.test/v1' } }, + providers: { kimi: { type: 'kimi', apiKey: 'sk', baseUrl: 'https://api.moonshot.ai/v1' } }, models: { k2: { provider: 'kimi', model: 'kimi-k2', maxContextSize: 200000 } }, }; @@ -311,6 +340,9 @@ describe('Model assembly (pure data)', () => { expect(model.baseUrl).toBe('https://api.example.test'); // Kimi thinking is trait-driven: no Anthropic effort profile is inferred. expect(model.supportEfforts).toBeUndefined(); + // A kimi-typed provider pointed at a third-party host gets only the + // User-Agent, never the host identity set. + expect(model.headers).toEqual({ 'User-Agent': 'kimi-test/1.0' }); } finally { host.dispose(); }