From e9d090f49ae4438a5dd78a7f5ecb6dfae67a9021 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Sun, 28 Jun 2026 21:52:58 +0800 Subject: [PATCH 1/4] feat(managed-kimi-code): route anthropic protocol via beta api - kosong: add betaApi option to use client.beta.messages.create - agent-core: thread alias betaApi into the anthropic provider config - oauth: route managed models on the anthropic protocol through the beta Messages API --- .../managed-kimi-code-anthropic-beta-api.md | 5 ++ packages/agent-core/src/config/schema.ts | 4 + .../src/session/provider-manager.ts | 10 ++- .../test/harness/runtime-provider.test.ts | 29 +++++++ packages/kosong/src/providers/anthropic.ts | 48 +++++++++--- packages/kosong/test/anthropic.test.ts | 77 +++++++++++++++++++ packages/oauth/src/managed-kimi-code.ts | 16 ++++ packages/oauth/test/managed-kimi-code.test.ts | 1 + 8 files changed, 179 insertions(+), 11 deletions(-) create mode 100644 .changeset/managed-kimi-code-anthropic-beta-api.md diff --git a/.changeset/managed-kimi-code-anthropic-beta-api.md b/.changeset/managed-kimi-code-anthropic-beta-api.md new file mode 100644 index 000000000..38d38b471 --- /dev/null +++ b/.changeset/managed-kimi-code-anthropic-beta-api.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Route managed Kimi Code models on the Anthropic-compatible protocol through the beta Messages API. diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 041bf8d6e..96552eb48 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -50,6 +50,10 @@ export const ModelAliasSchema = z.object({ // model-name version inference. Needed for custom-named Anthropic endpoints // whose model name does not encode a parseable Claude version. adaptiveThinking: z.boolean().optional(), + // Route the Anthropic transport through the beta Messages API + // (`POST /v1/messages?beta=true`) instead of the standard endpoint. Used by + // managed Kimi Code models that declare `protocol: 'anthropic'`. + betaApi: z.boolean().optional(), }); export type ModelAlias = z.infer; diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 44b24153d..22a029f9b 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -31,6 +31,8 @@ interface ProviderManagerOptions { readonly kimiRequestHeaders?: Record; readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver; readonly promptCacheKey?: string; + // remove before commit + readonly adaptiveThinkingOverride?: () => boolean | undefined; } type AuthorizedRequest = ( @@ -110,6 +112,9 @@ export class ProviderManager implements ModelProvider { ); } + // remove before commit + const adaptiveThinkingOverride = this.options.adaptiveThinkingOverride?.(); + const effectiveAdaptiveThinking = adaptiveThinkingOverride ?? alias.adaptiveThinking; const provider = toKosongProviderConfig( providerConfig, alias.model, @@ -118,7 +123,8 @@ export class ProviderManager implements ModelProvider { alias.maxOutputSize, alias.reasoningKey, this.options.promptCacheKey, - alias.adaptiveThinking, + effectiveAdaptiveThinking, + alias.betaApi, ); return { @@ -234,6 +240,7 @@ function toKosongProviderConfig( reasoningKey: string | undefined, promptCacheKey: string | undefined, adaptiveThinking: boolean | undefined, + betaApi: boolean | undefined, ): KosongProviderConfig { const effectiveType = modelProtocol === 'anthropic' ? 'anthropic' : provider.type; switch (effectiveType) { @@ -249,6 +256,7 @@ function toKosongProviderConfig( apiKey: providerApiKey(provider), ...(maxOutputSize !== undefined ? { defaultMaxTokens: maxOutputSize } : {}), ...(adaptiveThinking !== undefined ? { adaptiveThinking } : {}), + ...(betaApi !== undefined ? { betaApi } : {}), // Session affinity: Anthropic's analog of OpenAI `prompt_cache_key` is // `metadata.user_id` on the Messages API (cache-affinity / end-user id). ...(promptCacheKey !== undefined ? { metadata: { user_id: promptCacheKey } } : {}), diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index 8e6585c6b..fb5466856 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -338,6 +338,35 @@ describe('resolveRuntimeProvider maxOutputSize forwarding', () => { }); }); + it('forwards alias.betaApi to the anthropic provider config', () => { + const resolved = resolveRuntimeProvider({ + config: { + ...BASE_CONFIG, + providers: { + ...BASE_CONFIG.providers, + anthropic: { type: 'anthropic', apiKey: 'sk-anthropic' }, + }, + models: { + ...BASE_CONFIG.models!, + 'kimi-alias': { + provider: 'anthropic', + model: 'kimi-for-coding', + maxContextSize: 200000, + protocol: 'anthropic', + betaApi: true, + }, + }, + }, + model: 'kimi-alias', + }); + + expect(resolved.provider).toMatchObject({ + type: 'anthropic', + model: 'kimi-for-coding', + betaApi: true, + }); + }); + it('omits adaptiveThinking when alias.adaptiveThinking is unset', () => { const resolved = resolveRuntimeProvider({ config: { diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 4bbd94251..1b43abdda 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -91,6 +91,15 @@ export interface AnthropicOptions { * encode a parseable Claude version. Leave undefined to infer from the name. */ adaptiveThinking?: boolean | undefined; + /** + * Use the Anthropic **beta** Messages API (`client.beta.messages.create`, + * `POST /v1/messages?beta=true`) instead of the standard Messages API. + * + * Beta features (`betaFeatures`) are then sent via the request `betas` + * field rather than the `anthropic-beta` header. Defaults to false, which + * keeps the standard endpoint + header behavior. + */ + betaApi?: boolean | undefined; clientFactory?: (auth: ProviderRequestAuth) => Anthropic; } @@ -908,6 +917,7 @@ export class AnthropicChatProvider implements ChatProvider { private _defaultHeaders: Record | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined; private _adaptiveThinking: boolean | undefined; + private _betaApi: boolean; private _explicitMaxTokens: boolean; constructor(options: AnthropicOptions) { @@ -915,6 +925,7 @@ export class AnthropicChatProvider implements ChatProvider { this._stream = options.stream ?? true; this._metadata = options.metadata; this._adaptiveThinking = options.adaptiveThinking; + this._betaApi = options.betaApi ?? false; this._apiKey = options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey; this._baseUrl = options.baseUrl; @@ -1039,10 +1050,13 @@ export class AnthropicChatProvider implements ChatProvider { kwargs['output_config'] = this._generationKwargs.output_config; } - // Build beta headers + // Build the beta feature list. On the standard Messages API these travel + // via the `anthropic-beta` header; on the beta Messages API (`betaApi`) the + // SDK reads them from the request `betas` field and sets the header itself, + // so we must not also set the header (that would duplicate it). const betas = this._generationKwargs.betaFeatures ?? []; const extraHeaders: Record = {}; - if (betas.length > 0) { + if (!this._betaApi && betas.length > 0) { extraHeaders['anthropic-beta'] = betas.join(','); } @@ -1074,6 +1088,10 @@ export class AnthropicChatProvider implements ChatProvider { createParams['metadata'] = this._metadata; } + if (this._betaApi && betas.length > 0) { + createParams['betas'] = betas; + } + const requestOptions: Record = {}; const headers = mergeRequestHeaders(extraHeaders, options?.auth?.headers); if (headers !== undefined) { @@ -1090,10 +1108,15 @@ export class AnthropicChatProvider implements ChatProvider { // The helper reparses accumulated input_json_delta buffers on every chunk, // which becomes synchronous O(n^2) work for large streamed tool arguments. try { - const stream = await client.messages.create( - { ...createParams, stream: true } as unknown as MessageCreateParamsStreaming, - finalRequestOptions, - ); + const stream = this._betaApi + ? await client.beta.messages.create( + { ...createParams, stream: true } as unknown as MessageCreateParamsStreaming, + finalRequestOptions, + ) + : await client.messages.create( + { ...createParams, stream: true } as unknown as MessageCreateParamsStreaming, + finalRequestOptions, + ); return new AnthropicStreamedMessage(stream, true); } catch (error: unknown) { throw convertAnthropicError(error); @@ -1102,10 +1125,15 @@ export class AnthropicChatProvider implements ChatProvider { // Non-streaming fallback try { - const response = await client.messages.create( - { ...createParams, stream: false } as unknown as MessageCreateParams, - finalRequestOptions, - ); + const response = this._betaApi + ? await client.beta.messages.create( + { ...createParams, stream: false } as unknown as MessageCreateParams, + finalRequestOptions, + ) + : await client.messages.create( + { ...createParams, stream: false } as unknown as MessageCreateParams, + finalRequestOptions, + ); return new AnthropicStreamedMessage(response, false); } catch (error: unknown) { throw convertAnthropicError(error); diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index dceddc264..3bd9fc70a 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -141,6 +141,83 @@ const MUL_TOOL: Tool = { const B64_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAA' + 'DUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + +/** + * Capture the request body sent to the Anthropic beta Messages API by mocking + * the client (non-stream mode). Also asserts the standard Messages API was + * not called. + */ +async function captureBetaRequestBody( + provider: AnthropicChatProvider, + systemPrompt: string, + tools: Tool[], + history: Message[], +): Promise> { + let capturedParams: Record | undefined; + let capturedOptions: Record | undefined; + + (provider as any)._client.beta.messages.create = vi + .fn() + .mockImplementation((params: unknown, options?: unknown) => { + capturedParams = params as Record; + capturedOptions = options as Record | undefined; + return Promise.resolve(makeAnthropicResponse()); + }); + const standardCreate = vi.fn(); + (provider as any)._client.messages.create = standardCreate; + + const stream = await provider.generate(systemPrompt, tools, history); + for await (const part of stream) { + void part; + } + + if (capturedParams === undefined) { + throw new Error('Expected provider.generate() to call beta.messages.create'); + } + expect(standardCreate).not.toHaveBeenCalled(); + + const result = { ...capturedParams }; + if (capturedOptions !== undefined && capturedOptions['headers'] !== undefined) { + result['_extra_headers'] = capturedOptions['headers']; + } + return result; +} + +describe('betaApi', () => { + const history: Message[] = [ + { role: 'user', content: [{ type: 'text', text: 'Hi' }], toolCalls: [] }, + ]; + + it('routes to client.beta.messages.create with betas in the body and no beta header', async () => { + const provider = new AnthropicChatProvider({ + model: 'kimi-for-coding', + apiKey: 'test-key', + defaultMaxTokens: 1024, + stream: false, + betaApi: true, + }); + const body = await captureBetaRequestBody(provider, '', [], history); + + expect(body['betas']).toEqual(['interleaved-thinking-2025-05-14']); + const headers = body['_extra_headers'] as Record | undefined; + expect(headers?.['anthropic-beta']).toBeUndefined(); + }); + + it('keeps beta features in the anthropic-beta header when betaApi is off', async () => { + const provider = new AnthropicChatProvider({ + model: 'kimi-for-coding', + apiKey: 'test-key', + defaultMaxTokens: 1024, + stream: false, + }); + const body = await captureRequestBody(provider, '', [], history); + + expect(body['betas']).toBeUndefined(); + const headers = body['_extra_headers'] as Record | undefined; + expect(headers?.['anthropic-beta']).toContain('interleaved-thinking-2025-05-14'); + }); +}); + describe('AnthropicChatProvider', () => { it('does not read ANTHROPIC_API_KEY from process.env inside the adapter', () => { const previousApiKey = process.env['ANTHROPIC_API_KEY']; diff --git a/packages/oauth/src/managed-kimi-code.ts b/packages/oauth/src/managed-kimi-code.ts index 8df656a9f..668c3bf03 100644 --- a/packages/oauth/src/managed-kimi-code.ts +++ b/packages/oauth/src/managed-kimi-code.ts @@ -127,6 +127,7 @@ export interface ManagedKimiModelAlias { capabilities?: string[] | undefined; displayName?: string | undefined; protocol?: ManagedKimiCodeProtocol; + betaApi?: boolean; readonly [key: string]: unknown; } @@ -477,6 +478,16 @@ export function applyManagedKimiCodeConfig( } for (const model of options.models) { const capabilities = capabilitiesForModel(model); + // Kimi's Anthropic-compatible endpoint only accepts adaptive thinking + // (`thinking: { type: 'adaptive' }`); the kosong adapter otherwise infers + // budget-based thinking from the model name, which fails for Kimi model ids. + // Restrict the override to thinking-capable models: the UI treats + // `adaptiveThinking === true` as "supports a thinking toggle", so marking a + // non-thinking model would misrepresent it. + const supportsAdaptiveThinking = + model.protocol === 'anthropic' && + (capabilities?.includes('thinking') === true || + capabilities?.includes('always_thinking') === true); existingModels[managedModelKey(model.id)] = { provider: KIMI_CODE_PROVIDER_NAME, model: model.id, @@ -484,6 +495,11 @@ export function applyManagedKimiCodeConfig( capabilities, displayName: model.displayName, protocol: model.protocol, + // Kimi's anthropic-compatible endpoint is served behind the beta Messages + // API (`/v1/messages?beta=true`), so route anthropic-protocol models + // through `client.beta.messages.create`. + ...(model.protocol === 'anthropic' ? { betaApi: true } : {}), + ...(supportsAdaptiveThinking ? { adaptiveThinking: true } : {}), }; } diff --git a/packages/oauth/test/managed-kimi-code.test.ts b/packages/oauth/test/managed-kimi-code.test.ts index 53731e153..faa0c50ad 100644 --- a/packages/oauth/test/managed-kimi-code.test.ts +++ b/packages/oauth/test/managed-kimi-code.test.ts @@ -1121,6 +1121,7 @@ describe('managed protocol routing', () => { expect(config.models?.['kimi-code/kimi-for-coding']).toMatchObject({ provider: KIMI_CODE_PROVIDER_NAME, protocol: 'anthropic', + betaApi: true, }); }); From 2a2fa8a39c666a4dc69d5665dd192275f65721f1 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 29 Jun 2026 04:37:25 +0800 Subject: [PATCH 2/4] feat(providers): add KIMI_CODE_CUSTOM_HEADERS support - Add KIMI_CODE_CUSTOM_HEADERS env var for custom outbound LLM headers - Send User-Agent to non-Kimi providers - Forward Kimi identity headers to model catalog fetches - Support defaultHeaders in Google GenAI provider --- .changeset/kimi-code-custom-headers.md | 5 ++ .../src/services/coreProcess/coreProcess.ts | 2 + .../coreProcess/coreProcessService.ts | 6 +- .../modelCatalog/modelCatalogService.ts | 1 + .../src/session/provider-manager.ts | 56 +++++++++++++++++-- .../test/harness/runtime-provider.test.ts | 12 +++- packages/kosong/src/providers/google-genai.ts | 10 ++++ .../node-sdk/src/kimi-code-model-provider.ts | 2 + .../test/runtime-provider-identity.test.ts | 12 +++- packages/oauth/src/identity.ts | 32 +++++++++++ packages/oauth/src/index.ts | 2 + packages/oauth/src/managed-kimi-code.ts | 16 +++++- packages/oauth/src/open-platform.ts | 2 + packages/oauth/src/toolkit.ts | 18 +++++- 14 files changed, 163 insertions(+), 13 deletions(-) create mode 100644 .changeset/kimi-code-custom-headers.md diff --git a/.changeset/kimi-code-custom-headers.md b/.changeset/kimi-code-custom-headers.md new file mode 100644 index 000000000..f48de1e6c --- /dev/null +++ b/.changeset/kimi-code-custom-headers.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add `KIMI_CODE_CUSTOM_HEADERS` for custom outbound LLM request headers and send the `User-Agent` header to non-Kimi providers. Set `KIMI_CODE_CUSTOM_HEADERS` to newline-separated `Name: Value` lines. diff --git a/packages/agent-core/src/services/coreProcess/coreProcess.ts b/packages/agent-core/src/services/coreProcess/coreProcess.ts index 65895cf9e..2a8b5ff03 100644 --- a/packages/agent-core/src/services/coreProcess/coreProcess.ts +++ b/packages/agent-core/src/services/coreProcess/coreProcess.ts @@ -58,6 +58,8 @@ export interface ICoreProcessService { /** The core RPC methods. Service impls call e.g. `core.rpc.createSession(...)`. */ readonly rpc: CoreRPC; + readonly kimiRequestHeaders?: Record | undefined; + /** * Resolves once `KimiCore` is fully constructed and the SDK side of the * in-process RPC has been bound. Repeated calls return the cached promise. diff --git a/packages/agent-core/src/services/coreProcess/coreProcessService.ts b/packages/agent-core/src/services/coreProcess/coreProcessService.ts index 1865e9838..58bb34452 100644 --- a/packages/agent-core/src/services/coreProcess/coreProcessService.ts +++ b/packages/agent-core/src/services/coreProcess/coreProcessService.ts @@ -31,6 +31,8 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic */ public readonly rpc: CoreRPC; + public readonly kimiRequestHeaders: Record | undefined; + /** * The in-process `KimiCore` instance. Kept private so daemon-side code can't * grab it and bypass the peer-service indirection. @@ -91,7 +93,7 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic // synthesize from `options.identity`. Hosts that pass neither // (no identity, no headers) still construct — but their requests will // trip the 40340 guard. - const kimiRequestHeaders: Record | undefined = + this.kimiRequestHeaders = options.kimiRequestHeaders ?? CoreProcessService._defaultKimiRequestHeaders(env.homeDir, options.identity); @@ -107,7 +109,7 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic ...options, homeDir: env.homeDir, configPath: env.configPath, - kimiRequestHeaders, + kimiRequestHeaders: this.kimiRequestHeaders, appVersion, resolveOAuthTokenProvider, }); diff --git a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts index bd8eb79f3..b6d443fbc 100644 --- a/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts +++ b/packages/agent-core/src/services/modelCatalog/modelCatalogService.ts @@ -117,6 +117,7 @@ export class ModelCatalogService const models = await fetchManagedKimiCodeModels({ accessToken: token, baseUrl: auth.baseUrl, + headers: this.core.kimiRequestHeaders, }); if (models.length === 0) return { changed, unchanged, failed }; diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 22a029f9b..0616f101d 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -1,6 +1,7 @@ import type { Logger } from '#/logging/types'; import type { ProviderConfig as KosongProviderConfig, ModelCapability, ProviderRequestAuth } from '@moonshot-ai/kosong'; import { APIStatusError, getModelCapability, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong'; +import { parseKimiCodeCustomHeaders } from '@moonshot-ai/kimi-code-oauth'; import type { KimiConfig, ModelAlias, OAuthRef, ProviderConfig, ProviderType } from '../config'; import { ErrorCodes, isKimiError, KimiError } from '../errors'; @@ -243,6 +244,7 @@ function toKosongProviderConfig( betaApi: boolean | undefined, ): KosongProviderConfig { const effectiveType = modelProtocol === 'anthropic' ? 'anthropic' : provider.type; + const envCustomHeaders = parseKimiCodeCustomHeaders(); switch (effectiveType) { case 'anthropic': { const baseUrl = providerValue(provider.baseUrl, provider.env, 'ANTHROPIC_BASE_URL'); @@ -260,7 +262,18 @@ function toKosongProviderConfig( // Session affinity: Anthropic's analog of OpenAI `prompt_cache_key` is // `metadata.user_id` on the Messages API (cache-affinity / end-user id). ...(promptCacheKey !== undefined ? { metadata: { user_id: promptCacheKey } } : {}), - ...defaultHeadersField(provider.customHeaders), + // When a Kimi provider is routed through the Anthropic transport + // (`protocol: 'anthropic'`), upstream is the managed Kimi endpoint, + // so align its full outbound identity headers (User-Agent + X-Msh-*) + // with the Kimi OpenAI transport. Plain Anthropic providers only + // receive the unified `User-Agent` (no `X-Msh-*` device identity), + // matching the other non-Kimi transports. Provider `customHeaders` + // still win on conflict. + ...defaultHeadersField( + provider.type === 'kimi' && modelProtocol === 'anthropic' + ? { ...envCustomHeaders, ...kimiRequestHeaders, ...provider.customHeaders } + : { ...envCustomHeaders, ...kimiUserAgentHeader(kimiRequestHeaders), ...provider.customHeaders }, + ), }; } case 'openai': @@ -270,7 +283,11 @@ function toKosongProviderConfig( baseUrl: providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'), apiKey: providerApiKey(provider), reasoningKey, - ...defaultHeadersField(provider.customHeaders), + ...defaultHeadersField({ + ...envCustomHeaders, + ...kimiUserAgentHeader(kimiRequestHeaders), + ...provider.customHeaders, + }), }; case 'kimi': return { @@ -279,13 +296,22 @@ function toKosongProviderConfig( baseUrl: providerValue(provider.baseUrl, provider.env, 'KIMI_BASE_URL'), apiKey: providerApiKey(provider), generationKwargs: { prompt_cache_key: promptCacheKey }, - ...defaultHeadersField({ ...kimiRequestHeaders, ...provider.customHeaders }), + ...defaultHeadersField({ + ...envCustomHeaders, + ...kimiRequestHeaders, + ...provider.customHeaders, + }), }; case 'google-genai': return { type: 'google-genai', model, apiKey: providerApiKey(provider), + ...defaultHeadersField({ + ...envCustomHeaders, + ...kimiUserAgentHeader(kimiRequestHeaders), + ...provider.customHeaders, + }), }; case 'openai_responses': return { @@ -293,7 +319,11 @@ function toKosongProviderConfig( model, baseUrl: providerValue(provider.baseUrl, provider.env, 'OPENAI_BASE_URL'), apiKey: providerApiKey(provider), - ...defaultHeadersField(provider.customHeaders), + ...defaultHeadersField({ + ...envCustomHeaders, + ...kimiUserAgentHeader(kimiRequestHeaders), + ...provider.customHeaders, + }), }; case 'vertexai': { const useServiceAccount = hasVertexAIServiceEnv(provider); @@ -304,6 +334,11 @@ function toKosongProviderConfig( apiKey: useServiceAccount ? undefined : providerApiKey(provider), project: vertexAIProject(provider), location: vertexAILocation(provider), + ...defaultHeadersField({ + ...envCustomHeaders, + ...kimiUserAgentHeader(kimiRequestHeaders), + ...provider.customHeaders, + }), }; } default: { @@ -326,6 +361,19 @@ function defaultHeadersField( return { defaultHeaders: { ...headers } }; } +// Extract just the `User-Agent` from the Kimi identity headers so non-Kimi +// providers (OpenAI, Anthropic, Google, Vertex) also identify as +// `kimi-code-cli/` without leaking the `X-Msh-*` device identity +// headers to third-party endpoints. The full `kimiRequestHeaders` set stays +// reserved for the Kimi transport (and the Kimi-routed Anthropic transport), +// where upstream is the managed Kimi endpoint. +function kimiUserAgentHeader( + kimiRequestHeaders: Record | undefined, +): Record { + const userAgent = kimiRequestHeaders?.['User-Agent']; + return userAgent === undefined ? {} : { 'User-Agent': userAgent }; +} + function providerApiKey(provider: ProviderConfig): string | undefined { switch (provider.type) { case 'anthropic': diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index fb5466856..4e5db39fa 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -482,7 +482,7 @@ describe('resolveRuntimeProvider Kimi request headers', () => { }); }); - it('does not apply kimiRequestHeaders to non-Kimi providers', () => { + it('applies only the User-Agent from kimiRequestHeaders to non-Kimi providers', () => { const resolved = resolveRuntimeProvider({ config: { defaultModel: 'gpt-alias', @@ -508,8 +508,16 @@ describe('resolveRuntimeProvider Kimi request headers', () => { type: 'openai', model: 'gpt-runtime', apiKey: 'sk-openai', + defaultHeaders: { + 'User-Agent': TEST_KIMI_HEADERS['User-Agent'], + }, }); - expect('defaultHeaders' in resolved.provider).toBe(false); + // Device identity headers (`X-Msh-*`) stay Kimi-only — they must not leak + // to third-party providers. + const headers = (resolved.provider as { defaultHeaders?: Record }) + .defaultHeaders; + expect(headers).toBeDefined(); + expect('X-Msh-Platform' in headers!).toBe(false); expect('generationKwargs' in resolved.provider).toBe(false); }); }); diff --git a/packages/kosong/src/providers/google-genai.ts b/packages/kosong/src/providers/google-genai.ts index 7c79320d6..290645fd9 100644 --- a/packages/kosong/src/providers/google-genai.ts +++ b/packages/kosong/src/providers/google-genai.ts @@ -78,6 +78,7 @@ export interface GoogleGenAIOptions { project?: string | undefined; location?: string | undefined; stream?: boolean | undefined; + defaultHeaders?: Record; clientFactory?: (auth: ProviderRequestAuth) => GenAIClient; } @@ -673,6 +674,7 @@ export class GoogleGenAIChatProvider implements ChatProvider { private _apiKey: string | undefined; private _project: string | undefined; private _location: string | undefined; + private _defaultHeaders: Record | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => GenAIClient) | undefined; constructor(options: GoogleGenAIOptions) { @@ -685,6 +687,7 @@ export class GoogleGenAIChatProvider implements ChatProvider { this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey; this._project = options.project; this._location = options.location; + this._defaultHeaders = options.defaultHeaders; this._clientFactory = options.clientFactory; this._client = this._vertexai || this._apiKey !== undefined ? this._buildClient(this._apiKey) : undefined; @@ -700,6 +703,13 @@ export class GoogleGenAIChatProvider implements ChatProvider { location: this._location, } : {}), + // The Google GenAI SDK deep-merges `httpOptions.headers` into its + // default request headers, so a `User-Agent` here overrides the SDK + // default (`google-genai-sdk/ …`) while preserving the other + // defaults (`x-goog-api-client`, `Content-Type`). + ...(this._defaultHeaders !== undefined + ? { httpOptions: { headers: this._defaultHeaders } } + : {}), }); } diff --git a/packages/node-sdk/src/kimi-code-model-provider.ts b/packages/node-sdk/src/kimi-code-model-provider.ts index 1d257ed1e..4e2f6eff5 100644 --- a/packages/node-sdk/src/kimi-code-model-provider.ts +++ b/packages/node-sdk/src/kimi-code-model-provider.ts @@ -12,6 +12,7 @@ import { KIMI_CODE_PROVIDER_NAME, KimiOAuthToolkit, kimiCodeBaseUrl, + parseKimiCodeCustomHeaders, resolveKimiCodeOAuthRef, type KimiHostIdentity, type ManagedKimiOAuthRef, @@ -83,6 +84,7 @@ export class KimiForCodingProvider implements ModelProvider { ? { prompt_cache_key: this.promptCacheKey } : undefined, defaultHeaders: { + ...parseKimiCodeCustomHeaders(), ...createKimiDefaultHeaders({ homeDir: this.homeDir, ...this.identity, diff --git a/packages/node-sdk/test/runtime-provider-identity.test.ts b/packages/node-sdk/test/runtime-provider-identity.test.ts index e1156e627..89ab7f655 100644 --- a/packages/node-sdk/test/runtime-provider-identity.test.ts +++ b/packages/node-sdk/test/runtime-provider-identity.test.ts @@ -142,7 +142,7 @@ describe('runtime provider identity headers', () => { }); }); - it('does not add Kimi identity headers to non-Kimi providers', async () => { + it('applies only the User-Agent (no device identity headers) to non-Kimi providers', async () => { const homeDir = await makeTempDir(); const kimiRequestHeaders = createKimiDefaultHeaders({ homeDir, ...TEST_IDENTITY }); const config: KimiConfig = { @@ -172,7 +172,15 @@ describe('runtime provider identity headers', () => { expect(resolved.provider).toMatchObject({ type: 'openai', model: 'gpt-test', + defaultHeaders: { + 'User-Agent': `kimi-code-cli/${TEST_IDENTITY.version}`, + }, }); - expect(resolved.provider).not.toHaveProperty('defaultHeaders'); + // Device identity headers (`X-Msh-*`) stay Kimi-only — must not leak to + // third-party providers. + const headers = (resolved.provider as { defaultHeaders?: Record }) + .defaultHeaders; + expect(headers).toBeDefined(); + expect(headers).not.toHaveProperty('X-Msh-Platform'); }); }); diff --git a/packages/oauth/src/identity.ts b/packages/oauth/src/identity.ts index f3ce43291..6200d3531 100644 --- a/packages/oauth/src/identity.ts +++ b/packages/oauth/src/identity.ts @@ -105,6 +105,38 @@ export function createKimiDefaultHeaders(options: KimiIdentityOptions): Record { + const raw = env[KIMI_CODE_CUSTOM_HEADERS_ENV]?.trim(); + if (raw === undefined || raw.length === 0) return {}; + const headers: Record = {}; + for (const line of raw.split('\n')) { + const colon = line.indexOf(':'); + if (colon < 0) continue; + const name = line.slice(0, colon).trim(); + if (name.length === 0) continue; + headers[name] = line.slice(colon + 1).trim(); + } + return headers; +} + export function assertKimiHostIdentity(identity: KimiHostIdentity | undefined): KimiHostIdentity { if (identity === undefined) { throw new Error('Kimi host identity is required. Pass the host product name and version.'); diff --git a/packages/oauth/src/index.ts b/packages/oauth/src/index.ts index 9e04c55dd..79ffbeecb 100644 --- a/packages/oauth/src/index.ts +++ b/packages/oauth/src/index.ts @@ -32,7 +32,9 @@ export { createKimiDeviceHeaders, createKimiDeviceId, createKimiUserAgent, + KIMI_CODE_CUSTOM_HEADERS_ENV, KIMI_CODE_PLATFORM, + parseKimiCodeCustomHeaders, readKimiDeviceId, } from './identity'; export type { KimiHostIdentity, KimiIdentityOptions } from './identity'; diff --git a/packages/oauth/src/managed-kimi-code.ts b/packages/oauth/src/managed-kimi-code.ts index 668c3bf03..d5d8349c7 100644 --- a/packages/oauth/src/managed-kimi-code.ts +++ b/packages/oauth/src/managed-kimi-code.ts @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'; import { readApiErrorMessage } from './api-error'; import { DEFAULT_KIMI_CODE_OAUTH_HOST } from './constants'; import { OAuthUnauthorizedError } from './errors'; +import { parseKimiCodeCustomHeaders } from './identity'; import { DEFAULT_KIMI_CODE_BASE_URL, kimiCodeBaseUrl } from './managed-usage'; import { isRecord } from './utils'; @@ -50,6 +51,7 @@ export interface FetchManagedKimiCodeModelsOptions { readonly accessToken: string; readonly baseUrl?: string | undefined; readonly fetchImpl?: typeof fetch | undefined; + readonly headers?: Record | undefined; } export interface ManagedKimiCodeApplyResult { @@ -177,6 +179,7 @@ export interface ProvisionManagedKimiCodeConfigOptions { readonly oauthHost?: string | undefined; readonly preserveDefaultModel?: boolean | undefined; readonly fetchImpl?: typeof fetch | undefined; + readonly headers?: Record | undefined; } function managedModelKey(modelId: string): string { @@ -273,6 +276,14 @@ export function kimiCodeEnvOAuthHost(env: ManagedKimiEnv = process.env): string return env.KIMI_CODE_OAUTH_HOST ?? env.KIMI_OAUTH_HOST; } +// Base URLs that share the default `oauth/kimi-code` credential slot. The +// internal dev endpoint is hardcoded here so it reuses the same OAuth token as +// the production managed endpoint instead of requiring a separate login. +const SHARED_DEFAULT_BASE_URLS: readonly string[] = [ + normalizeEndpoint(DEFAULT_KIMI_CODE_BASE_URL), + 'http://10.4.232.131:38081/v1', +]; + export function resolveKimiCodeOAuthKey(options: { readonly oauthHost?: string | undefined; readonly baseUrl?: string | undefined; @@ -280,9 +291,8 @@ export function resolveKimiCodeOAuthKey(options: { const oauthHost = normalizeEndpoint(options.oauthHost ?? DEFAULT_KIMI_CODE_OAUTH_HOST); const baseUrl = defaultBaseUrl(options.baseUrl); const defaultOauthHost = normalizeEndpoint(DEFAULT_KIMI_CODE_OAUTH_HOST); - const defaultApiBaseUrl = normalizeEndpoint(DEFAULT_KIMI_CODE_BASE_URL); - if (oauthHost === defaultOauthHost && baseUrl === defaultApiBaseUrl) { + if (oauthHost === defaultOauthHost && SHARED_DEFAULT_BASE_URLS.includes(baseUrl)) { return KIMI_CODE_OAUTH_KEY; } @@ -410,6 +420,8 @@ export async function fetchManagedKimiCodeModels( const baseUrl = defaultBaseUrl(options.baseUrl); const response = await fetchImpl(`${baseUrl}/models`, { headers: { + ...parseKimiCodeCustomHeaders(), + ...options.headers, Authorization: `Bearer ${options.accessToken}`, Accept: 'application/json', }, diff --git a/packages/oauth/src/open-platform.ts b/packages/oauth/src/open-platform.ts index 0b6f74433..42312fc6d 100644 --- a/packages/oauth/src/open-platform.ts +++ b/packages/oauth/src/open-platform.ts @@ -1,5 +1,6 @@ import { readApiErrorMessage } from './api-error'; import { isRecord } from './utils'; +import { parseKimiCodeCustomHeaders } from './identity'; import { parseSupportsThinkingType } from './managed-kimi-code'; import type { ManagedKimiCodeModelInfo, @@ -108,6 +109,7 @@ export async function fetchOpenPlatformModels( ): Promise { const res = await fetchImpl(`${platform.baseUrl.replace(/\/+$/, '')}/models`, { headers: { + ...parseKimiCodeCustomHeaders(), Authorization: `Bearer ${apiKey}`, Accept: 'application/json', }, diff --git a/packages/oauth/src/toolkit.ts b/packages/oauth/src/toolkit.ts index e4dfdf027..94dca02bf 100644 --- a/packages/oauth/src/toolkit.ts +++ b/packages/oauth/src/toolkit.ts @@ -3,7 +3,12 @@ import { join } from 'node:path'; import { KIMI_CODE_FLOW_CONFIG } from './constants'; import { OAuthUnauthorizedError } from './errors'; -import { assertKimiHostIdentity, createKimiDeviceHeaders, type KimiHostIdentity } from './identity'; +import { + assertKimiHostIdentity, + createKimiDefaultHeaders, + createKimiDeviceHeaders, + type KimiHostIdentity, +} from './identity'; import { fetchSubmitFeedback, kimiCodeFeedbackUrl, @@ -107,6 +112,7 @@ export class KimiOAuthToolkit { 'now' | 'sleep' | 'deviceCodeTimeoutMs' | 'refreshThreshold' | 'onRefresh' >; private readonly managers = new Map(); + private _identityHeaders: Record | undefined; constructor(options: KimiOAuthToolkitOptions) { this.identity = @@ -187,6 +193,7 @@ export class KimiOAuthToolkit { oauthHost, preserveDefaultModel: hadToken, fetchImpl: this.fetchImpl, + headers: this.identityHeaders(), }); try { provision = await provisionWithToken(accessToken); @@ -417,6 +424,15 @@ export class KimiOAuthToolkit { ): string { return oauthRef?.oauthHost ?? oauthHost ?? this.flowConfig.oauthHost; } + + private identityHeaders(): Record | undefined { + if (this.identity === undefined) return undefined; + this._identityHeaders ??= createKimiDefaultHeaders({ + homeDir: this.homeDir, + ...this.identity, + }); + return this._identityHeaders; + } } export function resolveKimiTokenStorageName(input: { From 461b0c4e9684b8f0f7a7caa36b6776167266658d Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Mon, 29 Jun 2026 12:09:12 +0800 Subject: [PATCH 3/4] feat(agent-core): add protocol attrs to turn and api error telemetry - Add type/protocol/alias to api_error for per-protocol error attribution - Add turn_ended event with reason/duration/mode/type/protocol - Add type/protocol to turn_interrupted --- .changeset/turn-error-telemetry-protocol.md | 5 ++++ packages/agent-core/src/agent/turn/index.ts | 9 ++++++ packages/agent-core/test/agent/turn.test.ts | 31 +++++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 .changeset/turn-error-telemetry-protocol.md diff --git a/.changeset/turn-error-telemetry-protocol.md b/.changeset/turn-error-telemetry-protocol.md new file mode 100644 index 000000000..02188e3f9 --- /dev/null +++ b/.changeset/turn-error-telemetry-protocol.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add provider type and protocol attributes to turn and API error telemetry. diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index d8d2b85f5..9112b6ffe 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -501,6 +501,8 @@ export class TurnFlow { const properties: Record = { error_type: classification.errorType, model: this.agent.config.model, + alias: this.agent.config.modelAlias, + ...this.requestProtocolProps(), retryable: summary.retryable, duration_ms: Date.now() - startedAt, }; @@ -534,6 +536,12 @@ export class TurnFlow { inputData: { turnId, reason: 'cancelled' }, }); } + this.agent.telemetry.track('turn_ended', { + reason: ended.reason, + duration_ms: ended.durationMs, + mode: this.telemetryModeByTurn.get(turnId) ?? this.telemetryMode(), + ...this.requestProtocolProps(), + }); this.agent.emitEvent(ended); // Release the active turn in the same frame as turn.ended for a standalone // turn, so the session is observably idle the instant turn.ended fires. @@ -923,6 +931,7 @@ export class TurnFlow { this.agent.telemetry.track('turn_interrupted', { mode: this.telemetryModeByTurn.get(turnId) ?? this.telemetryMode(), at_step: atStep, + ...this.requestProtocolProps(), }); } diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 8f35bd241..fde6dd5c0 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -72,6 +72,34 @@ describe('Agent turn flow', () => { }); }); + it('tracks turn_ended telemetry with protocol props', async () => { + const records: TelemetryRecord[] = []; + const ctx = testAgent({ telemetry: recordingTelemetry(records) }); + ctx.configure(); + ctx.mockNextResponse({ type: 'text', text: 'done' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hi' }] }); + await ctx.untilTurnEnd(); + + const started = records.find((candidate) => candidate.event === 'turn_started'); + expect(started).toEqual({ + event: 'turn_started', + properties: expect.objectContaining({ mode: 'agent', type: 'kimi', protocol: 'kimi' }), + }); + + const ended = records.find((candidate) => candidate.event === 'turn_ended'); + expect(ended).toEqual({ + event: 'turn_ended', + properties: expect.objectContaining({ + mode: 'agent', + reason: 'completed', + type: 'kimi', + protocol: 'kimi', + duration_ms: expect.any(Number), + }), + }); + }); + it('tracks duplicate tool-call detection telemetry', async () => { const records: TelemetryRecord[] = []; const ctx = testAgent({ @@ -1426,6 +1454,9 @@ describe('Agent turn flow', () => { const expectedProperties: Record = { error_type: errorType, model: 'mock-model', + alias: 'mock-model', + type: 'kimi', + protocol: 'kimi', retryable: expect.any(Boolean), duration_ms: expect.any(Number), }; From 103d600771f362b25dd4d3644f628c535f746a94 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 29 Jun 2026 14:18:20 +0800 Subject: [PATCH 4/4] chore(oauth): remove hardcoded internal dev endpoint from shared OAuth base URLs --- .changeset/kimi-code-custom-headers.md | 2 +- .changeset/web-completion-sound.md | 2 +- packages/oauth/src/managed-kimi-code.ts | 5 +---- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/.changeset/kimi-code-custom-headers.md b/.changeset/kimi-code-custom-headers.md index f48de1e6c..afb26c0e5 100644 --- a/.changeset/kimi-code-custom-headers.md +++ b/.changeset/kimi-code-custom-headers.md @@ -1,5 +1,5 @@ --- -"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code": patch --- Add `KIMI_CODE_CUSTOM_HEADERS` for custom outbound LLM request headers and send the `User-Agent` header to non-Kimi providers. Set `KIMI_CODE_CUSTOM_HEADERS` to newline-separated `Name: Value` lines. diff --git a/.changeset/web-completion-sound.md b/.changeset/web-completion-sound.md index db521bff0..b3667756e 100644 --- a/.changeset/web-completion-sound.md +++ b/.changeset/web-completion-sound.md @@ -1,5 +1,5 @@ --- -"@moonshot-ai/kimi-code": minor +"@moonshot-ai/kimi-code": patch --- Add a completion sound and question notifications to the web UI, with separate Settings toggles for completion notifications, question notifications, and sound. Question notifications default off so question text only reaches your desktop after you opt in. diff --git a/packages/oauth/src/managed-kimi-code.ts b/packages/oauth/src/managed-kimi-code.ts index d5d8349c7..86cb21808 100644 --- a/packages/oauth/src/managed-kimi-code.ts +++ b/packages/oauth/src/managed-kimi-code.ts @@ -276,12 +276,9 @@ export function kimiCodeEnvOAuthHost(env: ManagedKimiEnv = process.env): string return env.KIMI_CODE_OAUTH_HOST ?? env.KIMI_OAUTH_HOST; } -// Base URLs that share the default `oauth/kimi-code` credential slot. The -// internal dev endpoint is hardcoded here so it reuses the same OAuth token as -// the production managed endpoint instead of requiring a separate login. +// Base URLs that share the default `oauth/kimi-code` credential slot. const SHARED_DEFAULT_BASE_URLS: readonly string[] = [ normalizeEndpoint(DEFAULT_KIMI_CODE_BASE_URL), - 'http://10.4.232.131:38081/v1', ]; export function resolveKimiCodeOAuthKey(options: {