Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/kimi-code-custom-headers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@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.
5 changes: 5 additions & 0 deletions .changeset/managed-kimi-code-anthropic-beta-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Route managed Kimi Code models on the Anthropic-compatible protocol through the beta Messages API.
5 changes: 5 additions & 0 deletions .changeset/turn-error-telemetry-protocol.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Add provider type and protocol attributes to turn and API error telemetry.
2 changes: 1 addition & 1 deletion .changeset/web-completion-sound.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,8 @@ export class TurnFlow {
const properties: Record<string, TelemetryPropertyValue> = {
error_type: classification.errorType,
model: this.agent.config.model,
alias: this.agent.config.modelAlias,
...this.requestProtocolProps(),
retryable: summary.retryable,
duration_ms: Date.now() - startedAt,
};
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(),
});
}

Expand Down
4 changes: 4 additions & 0 deletions packages/agent-core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ModelAliasSchema>;
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core/src/services/coreProcess/coreProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic
*/
public readonly rpc: CoreRPC;

public readonly kimiRequestHeaders: Record<string, string> | undefined;

/**
* The in-process `KimiCore` instance. Kept private so daemon-side code can't
* grab it and bypass the peer-service indirection.
Expand Down Expand Up @@ -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<string, string> | undefined =
this.kimiRequestHeaders =
options.kimiRequestHeaders ??
CoreProcessService._defaultKimiRequestHeaders(env.homeDir, options.identity);

Expand All @@ -107,7 +109,7 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic
...options,
homeDir: env.homeDir,
configPath: env.configPath,
kimiRequestHeaders,
kimiRequestHeaders: this.kimiRequestHeaders,
appVersion,
resolveOAuthTokenProvider,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
66 changes: 61 additions & 5 deletions packages/agent-core/src/session/provider-manager.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -31,6 +32,8 @@ interface ProviderManagerOptions {
readonly kimiRequestHeaders?: Record<string, string>;
readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver;
readonly promptCacheKey?: string;
// remove before commit
readonly adaptiveThinkingOverride?: () => boolean | undefined;
}

type AuthorizedRequest = <T>(
Expand Down Expand Up @@ -110,6 +113,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,
Expand All @@ -118,7 +124,8 @@ export class ProviderManager implements ModelProvider {
alias.maxOutputSize,
alias.reasoningKey,
this.options.promptCacheKey,
alias.adaptiveThinking,
effectiveAdaptiveThinking,
alias.betaApi,
);

return {
Expand Down Expand Up @@ -234,8 +241,10 @@ function toKosongProviderConfig(
reasoningKey: string | undefined,
promptCacheKey: string | undefined,
adaptiveThinking: boolean | undefined,
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');
Expand All @@ -249,10 +258,22 @@ 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 } } : {}),
...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':
Expand All @@ -262,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 {
Expand All @@ -271,21 +296,34 @@ 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 {
type: 'openai_responses',
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);
Expand All @@ -296,6 +334,11 @@ function toKosongProviderConfig(
apiKey: useServiceAccount ? undefined : providerApiKey(provider),
project: vertexAIProject(provider),
location: vertexAILocation(provider),
...defaultHeadersField({
...envCustomHeaders,
...kimiUserAgentHeader(kimiRequestHeaders),
...provider.customHeaders,
}),
};
}
default: {
Expand All @@ -318,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/<version>` 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<string, string> | undefined,
): Record<string, string> {
const userAgent = kimiRequestHeaders?.['User-Agent'];
return userAgent === undefined ? {} : { 'User-Agent': userAgent };
}

function providerApiKey(provider: ProviderConfig): string | undefined {
switch (provider.type) {
case 'anthropic':
Expand Down
31 changes: 31 additions & 0 deletions packages/agent-core/test/agent/turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,34 @@
});
});

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({

Check failure on line 85 in packages/agent-core/test/agent/turn.test.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/agent/turn.test.ts > Agent turn flow > tracks turn_ended telemetry with protocol props

AssertionError: expected { event: 'turn_started', …(1) } to deeply equal { event: 'turn_started', …(1) } - Expected + Received { "event": "turn_started", "properties": { "mode": "agent", "protocol": "kimi", - "type": "kimi", + "provider_type": "kimi", }, } ❯ test/agent/turn.test.ts:85:21
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({
Expand Down Expand Up @@ -1426,6 +1454,9 @@
const expectedProperties: Record<string, unknown> = {
error_type: errorType,
model: 'mock-model',
alias: 'mock-model',
type: 'kimi',
protocol: 'kimi',
retryable: expect.any(Boolean),
duration_ms: expect.any(Number),
};
Expand All @@ -1434,7 +1465,7 @@
}

const record = records.find((candidate) => candidate.event === 'api_error');
expect(record).toEqual({

Check failure on line 1468 in packages/agent-core/test/agent/turn.test.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/agent/turn.test.ts > Agent turn flow > tracks api_error telemetry for 'timeout error'

AssertionError: expected { event: 'api_error', …(1) } to deeply equal { event: 'api_error', …(1) } - Expected + Received @@ -4,9 +4,9 @@ "alias": "mock-model", "duration_ms": 1, "error_type": "timeout", "model": "mock-model", "protocol": "kimi", + "provider_type": "kimi", "retryable": true, - "type": "kimi", }, } ❯ test/agent/turn.test.ts:1468:20

Check failure on line 1468 in packages/agent-core/test/agent/turn.test.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/agent/turn.test.ts > Agent turn flow > tracks api_error telemetry for 'connection error'

AssertionError: expected { event: 'api_error', …(1) } to deeply equal { event: 'api_error', …(1) } - Expected + Received @@ -4,9 +4,9 @@ "alias": "mock-model", "duration_ms": 0, "error_type": "network", "model": "mock-model", "protocol": "kimi", + "provider_type": "kimi", "retryable": true, - "type": "kimi", }, } ❯ test/agent/turn.test.ts:1468:20

Check failure on line 1468 in packages/agent-core/test/agent/turn.test.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/agent/turn.test.ts > Agent turn flow > tracks api_error telemetry for 'context overflow token count status'

AssertionError: expected { event: 'api_error', …(1) } to deeply equal { event: 'api_error', …(1) } - Expected + Received @@ -4,10 +4,10 @@ "alias": "mock-model", "duration_ms": 1, "error_type": "context_overflow", "model": "mock-model", "protocol": "kimi", + "provider_type": "kimi", "retryable": false, "status_code": 400, - "type": "kimi", }, } ❯ test/agent/turn.test.ts:1468:20

Check failure on line 1468 in packages/agent-core/test/agent/turn.test.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/agent/turn.test.ts > Agent turn flow > tracks api_error telemetry for 'context overflow status'

AssertionError: expected { event: 'api_error', …(1) } to deeply equal { event: 'api_error', …(1) } - Expected + Received @@ -4,10 +4,10 @@ "alias": "mock-model", "duration_ms": 1, "error_type": "context_overflow", "model": "mock-model", "protocol": "kimi", + "provider_type": "kimi", "retryable": false, "status_code": 422, - "type": "kimi", }, } ❯ test/agent/turn.test.ts:1468:20

Check failure on line 1468 in packages/agent-core/test/agent/turn.test.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/agent/turn.test.ts > Agent turn flow > tracks api_error telemetry for '400 status'

AssertionError: expected { event: 'api_error', …(1) } to deeply equal { event: 'api_error', …(1) } - Expected + Received @@ -4,10 +4,10 @@ "alias": "mock-model", "duration_ms": 1, "error_type": "4xx_client", "model": "mock-model", "protocol": "kimi", + "provider_type": "kimi", "retryable": false, "status_code": 400, - "type": "kimi", }, } ❯ test/agent/turn.test.ts:1468:20

Check failure on line 1468 in packages/agent-core/test/agent/turn.test.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/agent/turn.test.ts > Agent turn flow > tracks api_error telemetry for '500 status'

AssertionError: expected { event: 'api_error', …(1) } to deeply equal { event: 'api_error', …(1) } - Expected + Received @@ -4,10 +4,10 @@ "alias": "mock-model", "duration_ms": 1, "error_type": "5xx_server", "model": "mock-model", "protocol": "kimi", + "provider_type": "kimi", "retryable": false, "status_code": 500, - "type": "kimi", }, } ❯ test/agent/turn.test.ts:1468:20

Check failure on line 1468 in packages/agent-core/test/agent/turn.test.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/agent/turn.test.ts > Agent turn flow > tracks api_error telemetry for '403 status'

AssertionError: expected { event: 'api_error', …(1) } to deeply equal { event: 'api_error', …(1) } - Expected + Received @@ -4,10 +4,10 @@ "alias": "mock-model", "duration_ms": 1, "error_type": "auth", "model": "mock-model", "protocol": "kimi", + "provider_type": "kimi", "retryable": false, "status_code": 403, - "type": "kimi", }, } ❯ test/agent/turn.test.ts:1468:20

Check failure on line 1468 in packages/agent-core/test/agent/turn.test.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/agent/turn.test.ts > Agent turn flow > tracks api_error telemetry for '401 status'

AssertionError: expected { event: 'api_error', …(1) } to deeply equal { event: 'api_error', …(1) } - Expected + Received @@ -4,10 +4,10 @@ "alias": "mock-model", "duration_ms": 0, "error_type": "auth", "model": "mock-model", "protocol": "kimi", + "provider_type": "kimi", "retryable": false, "status_code": 401, - "type": "kimi", }, } ❯ test/agent/turn.test.ts:1468:20

Check failure on line 1468 in packages/agent-core/test/agent/turn.test.ts

View workflow job for this annotation

GitHub Actions / test

[kimi-core] test/agent/turn.test.ts > Agent turn flow > tracks api_error telemetry for '429 status'

AssertionError: expected { event: 'api_error', …(1) } to deeply equal { event: 'api_error', …(1) } - Expected + Received @@ -4,10 +4,10 @@ "alias": "mock-model", "duration_ms": 1, "error_type": "rate_limit", "model": "mock-model", "protocol": "kimi", + "provider_type": "kimi", "retryable": true, "status_code": 429, - "type": "kimi", }, } ❯ test/agent/turn.test.ts:1468:20
event: 'api_error',
properties: expect.objectContaining(expectedProperties),
});
Expand Down
41 changes: 39 additions & 2 deletions packages/agent-core/test/harness/runtime-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -453,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',
Expand All @@ -479,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<string, string> })
.defaultHeaders;
expect(headers).toBeDefined();
expect('X-Msh-Platform' in headers!).toBe(false);
expect('generationKwargs' in resolved.provider).toBe(false);
});
});
Expand Down
Loading
Loading