Skip to content
Open
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/acp-account-usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Expose ACP context usage and Kimi Coding Plan or API-key billing metadata in session usage updates.
40 changes: 39 additions & 1 deletion docs/en/reference/kimi-acp.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ The spec divides methods into a **stable** surface and an evolving **unstable**

| Method | Implemented | Description |
| --- | --- | --- |
| `session/update` | Yes | Streams `agent_message_chunk` / `tool_call*` / `plan` / `config_option_update` / `available_commands_update` |
| `session/update` | Yes | Streams `agent_message_chunk` / `tool_call*` / `plan` / `usage_update` / `config_option_update` / `available_commands_update` |
| `session/request_permission` | Yes | Shared channel for tool approval and question elicitation |
| `fs/read_text_file` | Yes | File reads at the kaos layer are routed to the client (advertised via `fsCapabilities`) |
| `fs/write_text_file` | Yes | File writes at the kaos layer are routed to the client |
Expand All @@ -68,6 +68,44 @@ The spec divides methods into a **stable** surface and an evolving **unstable**

All methods not listed above return `methodNotFound`.

## Account usage updates

The adapter emits a standard ACP `usage_update` when a session opens and after
each main-agent turn. `used` and `size` report the current context window. Kimi
Code account information is attached under `_meta.kimiCode` so clients can opt
in without changing the standard ACP fields:

```json
{
"sessionUpdate": "usage_update",
"used": 25000,
"size": 262144,
"_meta": {
"kimiCode": {
"billingMode": "coding_plan",
"rateLimits": {
"summary": { "used": 33, "limit": 100, "resetAt": "..." },
"limits": [
{
"window": { "duration": 5, "unit": "hour" },
"used": 20,
"limit": 100,
"resetAt": "..."
}
],
"booster": null
}
}
}
}
```

`billingMode` is `coding_plan` for the managed OAuth account and `api_key` for
an API-key-backed provider. API keys are never included in the notification.
Managed rate limits are fetched best-effort and cached for one minute; if the
account endpoint is temporarily unavailable, the adapter still reports
`coding_plan` without stale or invented limits.

## MCP Forwarding

When an ACP client provides `mcpServers` in `session/new` or `session/load`, the adapter layer performs the following conversions:
Expand Down
39 changes: 38 additions & 1 deletion docs/zh/reference/kimi-acp.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ kimi acp

| 方法 | 状态 | 说明 |
| --- | --- | --- |
| `session/update` | 是 | 流式推送 `agent_message_chunk` / `tool_call*` / `plan` / `config_option_update` / `available_commands_update` |
| `session/update` | 是 | 流式推送 `agent_message_chunk` / `tool_call*` / `plan` / `usage_update` / `config_option_update` / `available_commands_update` |
| `session/request_permission` | 是 | 工具审批和问题 elicitation 共用此通道 |
| `fs/read_text_file` | 是 | kaos 层文件读取路由到客户端(通过 `fsCapabilities` 公告) |
| `fs/write_text_file` | 是 | kaos 层文件写入路由到客户端 |
Expand All @@ -68,6 +68,43 @@ kimi acp

上述未列出的方法一律返回 `methodNotFound`。

## 账号用量更新

会话打开时以及主 Agent 每轮结束后,适配器都会发送标准 ACP
`usage_update`。其中 `used` 和 `size` 表示当前上下文窗口;Kimi Code
账号信息放在 `_meta.kimiCode` 下,客户端可以按需读取,不会改变标准
ACP 字段:

```json
{
"sessionUpdate": "usage_update",
"used": 25000,
"size": 262144,
"_meta": {
"kimiCode": {
"billingMode": "coding_plan",
"rateLimits": {
"summary": { "used": 33, "limit": 100, "resetAt": "..." },
"limits": [
{
"window": { "duration": 5, "unit": "hour" },
"used": 20,
"limit": 100,
"resetAt": "..."
}
],
"booster": null
}
}
}
}
```

托管 OAuth 账号的 `billingMode` 为 `coding_plan`,API Key Provider 为
`api_key`。通知中绝不会包含 API Key。托管套餐额度采用尽力而为的方式
获取,并缓存一分钟;账号接口暂时不可用时,适配器仍会上报
`coding_plan`,但不会沿用或伪造额度。

## MCP 转发

ACP 客户端在 `session/new` 或 `session/load` 中提供 `mcpServers` 时,适配层做如下转换:
Expand Down
52 changes: 52 additions & 0 deletions packages/acp-adapter/src/events-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,34 @@ import type {
import { displayBlockToAcpContent, toolResultToAcpContent } from './convert';
import type { AcpStopReason } from './types';

export type KimiCodeBillingMode = 'coding_plan' | 'api_key';

export interface KimiCodeUsageRow {
readonly name?: string;
readonly window?: {
readonly duration: number;
readonly unit: 'minute' | 'hour' | 'day' | 'week';
};
readonly used: number;
readonly limit: number;
readonly resetAt?: string;
}

export interface KimiCodeBoosterWallet {
readonly balanceCents: number;
readonly totalCents: number;
readonly currency: string;
}

export interface KimiCodeUsageMeta {
readonly billingMode: KimiCodeBillingMode;
readonly rateLimits?: {
readonly summary: KimiCodeUsageRow | null;
readonly limits: readonly KimiCodeUsageRow[];
readonly booster: KimiCodeBoosterWallet | null;
};
}

/**
* Build an ACP `session/update` notification with an
* `agent_message_chunk` payload from an SDK `assistant.delta` event.
Expand Down Expand Up @@ -525,3 +553,27 @@ export function configOptionUpdateNotification(
},
};
}

/**
* Build the standard ACP context-usage update and attach Kimi Code account
* usage as namespaced metadata. Clients that only understand ACP still get
* `used` / `size`; clients that opt into Kimi Code's metadata can distinguish
* Coding Plan rate limits from API-key billing without reading local config or
* credentials.
*/
export function usageReportToSessionUpdate(
sessionId: string,
used: number,
size: number,
kimiCode?: KimiCodeUsageMeta,
): SessionNotification {
return {
sessionId,
update: {
sessionUpdate: 'usage_update',
used,
size,
...(kimiCode === undefined ? {} : { _meta: { kimiCode } }),
},
};
}
5 changes: 4 additions & 1 deletion packages/acp-adapter/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ export class AcpServer implements Agent {
DEFAULT_MODE_ID,
);
this.scheduleAvailableCommandsUpdate(session.id);
void acpSession.emitUsageReport();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add account usage reporting to the default ACP server

The new reporting is wired only into @moonshot-ai/acp-adapter, but apps/kimi-code/src/cli/sub/acp.ts:40-44 routes ordinary kimi acp invocations to @moonshot-ai/acp-server; this adapter is used only when KIMI_CODE_LEGACY_FLAG is enabled. The default server's emitUsageUpdate() still sends context fields without _meta.kimiCode, so nearly all users will not receive the advertised account usage. Implement the metadata and opening update in packages/acp-server as well, or route the default command through this implementation.

Useful? React with 👍 / 👎.

return {
sessionId: session.id,
configOptions,
Expand Down Expand Up @@ -464,6 +465,7 @@ export class AcpServer implements Agent {
// `resumeSession`, which intentionally omits this step.
await acpSession.replayHistory();
this.scheduleAvailableCommandsUpdate(session.id);
void acpSession.emitUsageReport();
return { configOptions };
}

Expand All @@ -487,13 +489,14 @@ export class AcpServer implements Agent {
* rationale, and gap-4.1 for the matching capability advertisement.
*/
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
const { session, configOptions } = await this.setupSessionFromExisting({
const { session, acpSession, configOptions } = await this.setupSessionFromExisting({
cwd: params.cwd,
sessionId: params.sessionId,
mcpServers: params.mcpServers,
mode: 'resume',
});
this.scheduleAvailableCommandsUpdate(session.id);
void acpSession.emitUsageReport();
return { configOptions };
}

Expand Down
117 changes: 117 additions & 0 deletions packages/acp-adapter/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ import {
toolProgressToSessionUpdate,
toolResultToSessionUpdate,
turnEndReasonToStopReason,
type KimiCodeUsageMeta,
usageReportToSessionUpdate,
} from './events-map';
import { acpModeToToggles, DEFAULT_MODE_ID, isAcpModeId, type AcpModeId } from './modes';
import { outcomeToQuestionAnswer, questionItemToPermissionOptions } from './question';
Expand Down Expand Up @@ -149,6 +151,17 @@ export class AcpSession {
*/
private skillCommandMap: ReadonlyMap<string, string> = new Map();

/** Cached managed-account usage so opening/finishing turns cannot poll the
* account endpoint more than once per minute. The cache key follows the
* selected model/provider, so switching models invalidates it immediately. */
private accountUsageCache:
| {
readonly key: string;
readonly expiresAt: number;
readonly value: KimiCodeUsageMeta | undefined;
}
| undefined;

// One token per in-flight `prompt()` that is still awaiting image compression
// (before any turn exists). A `session/cancel` in that window has no turn to
// abort, so it flips every token and each affected `prompt()` returns
Expand Down Expand Up @@ -269,6 +282,109 @@ export class AcpSession {
return this.currentModeIdInternal;
}

/**
* Emit ACP context usage plus Kimi Code account metadata. Account lookup is
* best-effort: API-key mode never reads or serializes the key, and a failed
* Coding Plan usage request still reports the billing mode so clients can
* render an honest account state.
*/
async emitUsageReport(): Promise<void> {
if (typeof this.session.getStatus !== 'function') return;
try {
const [status, kimiCode] = await Promise.all([
this.session.getStatus(),
this.loadKimiCodeUsageMeta(),
]);
if (
!Number.isFinite(status.contextTokens) ||
status.contextTokens < 0 ||
!Number.isFinite(status.maxContextTokens) ||
status.maxContextTokens <= 0
) {
return;
}
await this.conn.sessionUpdate(
usageReportToSessionUpdate(
this.session.id,
status.contextTokens,
status.maxContextTokens,
kimiCode,
),
);
} catch (error) {
log.warn('acp: failed to push usage_update', {
sessionId: this.session.id,
error: error instanceof Error ? error.message : String(error),
});
}
}

private async loadKimiCodeUsageMeta(): Promise<KimiCodeUsageMeta | undefined> {
if (this.harness === undefined || typeof this.harness.getConfig !== 'function') {
return undefined;
}

const config = await this.harness.getConfig();
const selectedModelId = this.currentModelIdInternal || config.defaultModel;
const selectedModel =
selectedModelId === undefined ? undefined : config.models?.[selectedModelId];
const providerId = selectedModel?.provider ?? config.defaultProvider;
const provider = providerId === undefined ? undefined : config.providers[providerId];
const billingMode =
providerId === 'managed:kimi-code' && provider?.oauth !== undefined
? 'coding_plan'
: typeof provider?.apiKey === 'string' && provider.apiKey.length > 0
? 'api_key'
Comment on lines +333 to +337

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize API keys supplied through the provider env table

In the legacy adapter, a Kimi provider configured with [providers.<name>.env] KIMI_API_KEY = "..." is valid and is resolved by providerValue(provider.apiKey, provider.env, 'KIMI_API_KEY') in packages/agent-core/src/session/provider-manager.ts, but this classification checks only provider.apiKey. For that supported configuration the session is actually API-key billed while _meta.kimiCode is omitted entirely; resolve the credential using the same precedence without serializing its value.

Useful? React with 👍 / 👎.

: undefined;
const cacheKey = `${selectedModelId ?? ''}:${providerId ?? ''}:${billingMode ?? ''}`;
const now = Date.now();
if (
this.accountUsageCache?.key === cacheKey &&
this.accountUsageCache.expiresAt > now
) {
return this.accountUsageCache.value;
}

let value: KimiCodeUsageMeta | undefined;
if (billingMode === 'api_key') {
value = { billingMode };
} else if (billingMode === 'coding_plan') {
value = { billingMode };
try {
const result = await this.harness.auth.getManagedUsage(providerId);
if (result.kind === 'ok') {
value = {
billingMode,
rateLimits: {
summary: result.summary,
limits: result.limits,
booster:
result.extraUsage === null
? null
: {
balanceCents: result.extraUsage.balanceCents,
totalCents: result.extraUsage.totalCents,
currency: result.extraUsage.currency,
},
},
};
}
} catch (error) {
log.warn('acp: failed to load Kimi Code managed usage', {
sessionId: this.session.id,
error: error instanceof Error ? error.message : String(error),
});
}
}

this.accountUsageCache = {
key: cacheKey,
expiresAt: now + 60_000,
value,
};
return value;
}

/**
* Forward an ACP `session/cancel` notification to the underlying SDK
* session. The SDK's `cancel()` is idempotent at the RPC layer, so
Expand Down Expand Up @@ -1229,6 +1345,7 @@ export class AcpSession {
if (settled) return;
if (!isFromMainAgent(event)) return;
settled = true;
void this.emitUsageReport();
if (event.reason === 'failed') {
// Failures bubble up via the SDK `error` payload. Phase 11.1
// upgrades the prior "log + resolve end_turn" behaviour to
Expand Down
4 changes: 3 additions & 1 deletion packages/acp-adapter/test/session-new.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,9 @@ describe('AcpServer session/new', () => {

const response = await client.newSession({ cwd: '/tmp/work', mcpServers: [] });

expect(fakeSession.getStatus).toHaveBeenCalledOnce();
// Session setup reads status for the thinking picker, then the
// best-effort opening usage report retries it independently.
expect(fakeSession.getStatus).toHaveBeenCalledTimes(2);
const thinking = response.configOptions?.find((option) => option.id === 'thinking');
if (thinking?.type !== 'select') throw new Error('thinking option must be a select');
expect(thinking.currentValue).toBe(expected);
Expand Down
Loading