From c73b2f2710936575396c57c1f10decc02eb6ec27 Mon Sep 17 00:00:00 2001 From: chenyang <3418786206@qq.com> Date: Fri, 7 Aug 2026 13:11:14 +0800 Subject: [PATCH] feat(acp): report Kimi account usage --- .changeset/acp-account-usage.md | 5 + docs/en/reference/kimi-acp.md | 40 ++++- docs/zh/reference/kimi-acp.md | 39 ++++- packages/acp-adapter/src/events-map.ts | 52 ++++++ packages/acp-adapter/src/server.ts | 5 +- packages/acp-adapter/src/session.ts | 117 +++++++++++++ packages/acp-adapter/test/session-new.test.ts | 4 +- .../acp-adapter/test/session-usage.test.ts | 161 ++++++++++++++++++ 8 files changed, 419 insertions(+), 4 deletions(-) create mode 100644 .changeset/acp-account-usage.md create mode 100644 packages/acp-adapter/test/session-usage.test.ts diff --git a/.changeset/acp-account-usage.md b/.changeset/acp-account-usage.md new file mode 100644 index 0000000000..6d911d3b74 --- /dev/null +++ b/.changeset/acp-account-usage.md @@ -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. diff --git a/docs/en/reference/kimi-acp.md b/docs/en/reference/kimi-acp.md index 30f0787651..3c6d3becec 100644 --- a/docs/en/reference/kimi-acp.md +++ b/docs/en/reference/kimi-acp.md @@ -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 | @@ -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: diff --git a/docs/zh/reference/kimi-acp.md b/docs/zh/reference/kimi-acp.md index d58c4460ab..4840ae8e28 100644 --- a/docs/zh/reference/kimi-acp.md +++ b/docs/zh/reference/kimi-acp.md @@ -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 层文件写入路由到客户端 | @@ -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` 时,适配层做如下转换: diff --git a/packages/acp-adapter/src/events-map.ts b/packages/acp-adapter/src/events-map.ts index 0448f2eb9c..d336fd991e 100644 --- a/packages/acp-adapter/src/events-map.ts +++ b/packages/acp-adapter/src/events-map.ts @@ -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. @@ -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 } }), + }, + }; +} diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index 6707fd4cae..8dc87894a4 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -421,6 +421,7 @@ export class AcpServer implements Agent { DEFAULT_MODE_ID, ); this.scheduleAvailableCommandsUpdate(session.id); + void acpSession.emitUsageReport(); return { sessionId: session.id, configOptions, @@ -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 }; } @@ -487,13 +489,14 @@ export class AcpServer implements Agent { * rationale, and gap-4.1 for the matching capability advertisement. */ async resumeSession(params: ResumeSessionRequest): Promise { - 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 }; } diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 747b44ea9c..f17bc54013 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -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'; @@ -149,6 +151,17 @@ export class AcpSession { */ private skillCommandMap: ReadonlyMap = 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 @@ -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 { + 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 { + 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' + : 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 @@ -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 diff --git a/packages/acp-adapter/test/session-new.test.ts b/packages/acp-adapter/test/session-new.test.ts index 61220b462b..2983d16da0 100644 --- a/packages/acp-adapter/test/session-new.test.ts +++ b/packages/acp-adapter/test/session-new.test.ts @@ -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); diff --git a/packages/acp-adapter/test/session-usage.test.ts b/packages/acp-adapter/test/session-usage.test.ts new file mode 100644 index 0000000000..096e7464e5 --- /dev/null +++ b/packages/acp-adapter/test/session-usage.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { AgentSideConnection } from '@agentclientprotocol/sdk'; +import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; + +import { AcpSession } from '../src/session'; + +function fakeSession(): Session { + return { + id: 'session-usage', + getStatus: async () => ({ + thinkingEffort: 'off', + permission: 'default', + planMode: false, + contextTokens: 250, + maxContextTokens: 1_000, + contextUsage: 0.25, + }), + } as unknown as Session; +} + +function fakeConnection(sessionUpdate: ReturnType): AgentSideConnection { + return { sessionUpdate } as unknown as AgentSideConnection; +} + +describe('AcpSession usage reporting', () => { + it('emits Coding Plan rate limits and caches the account lookup for one minute', async () => { + const sessionUpdate = vi.fn(async () => undefined); + const getManagedUsage = vi.fn(async () => ({ + kind: 'ok' as const, + summary: { name: 'Weekly', used: 30, limit: 100, resetAt: '2026-08-14T00:00:00Z' }, + limits: [ + { + name: '5h', + window: { duration: 5, unit: 'hour' as const }, + used: 20, + limit: 100, + resetAt: '2026-08-07T12:00:00Z', + }, + ], + extraUsage: { + balanceCents: 500, + totalCents: 1_000, + monthlyChargeLimitEnabled: true, + monthlyChargeLimitCents: 2_000, + monthlyUsedCents: 400, + currency: 'USD', + }, + })); + const harness = { + getConfig: async () => ({ + providers: { + 'managed:kimi-code': { + type: 'kimi', + apiKey: '', + oauth: { storage: 'keyring', key: 'kimi-code' }, + }, + }, + defaultModel: 'kimi-for-coding', + models: { + 'kimi-for-coding': { + provider: 'managed:kimi-code', + model: 'kimi-for-coding', + maxContextSize: 262_144, + }, + }, + }), + auth: { getManagedUsage }, + } as unknown as KimiHarness; + const acpSession = new AcpSession( + fakeConnection(sessionUpdate), + fakeSession(), + undefined, + undefined, + 'kimi-for-coding', + harness, + ); + + await acpSession.emitUsageReport(); + await acpSession.emitUsageReport(); + + expect(getManagedUsage).toHaveBeenCalledTimes(1); + expect(sessionUpdate).toHaveBeenLastCalledWith({ + sessionId: 'session-usage', + update: { + sessionUpdate: 'usage_update', + used: 250, + size: 1_000, + _meta: { + kimiCode: { + billingMode: 'coding_plan', + rateLimits: { + summary: { + name: 'Weekly', + used: 30, + limit: 100, + resetAt: '2026-08-14T00:00:00Z', + }, + limits: [ + { + name: '5h', + window: { duration: 5, unit: 'hour' }, + used: 20, + limit: 100, + resetAt: '2026-08-07T12:00:00Z', + }, + ], + booster: { balanceCents: 500, totalCents: 1_000, currency: 'USD' }, + }, + }, + }, + }, + }); + }); + + it('reports API-key billing without exposing or querying the credential', async () => { + const sessionUpdate = vi.fn(async () => undefined); + const getManagedUsage = vi.fn(); + const harness = { + getConfig: async () => ({ + providers: { + 'moonshot-cn': { + type: 'kimi', + apiKey: 'secret-api-key', + }, + }, + defaultModel: 'kimi-k2', + models: { + 'kimi-k2': { + provider: 'moonshot-cn', + model: 'kimi-k2', + maxContextSize: 262_144, + }, + }, + }), + auth: { getManagedUsage }, + } as unknown as KimiHarness; + const acpSession = new AcpSession( + fakeConnection(sessionUpdate), + fakeSession(), + undefined, + undefined, + 'kimi-k2', + harness, + ); + + await acpSession.emitUsageReport(); + + expect(getManagedUsage).not.toHaveBeenCalled(); + expect(sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'session-usage', + update: { + sessionUpdate: 'usage_update', + used: 250, + size: 1_000, + _meta: { kimiCode: { billingMode: 'api_key' } }, + }, + }); + expect(JSON.stringify(sessionUpdate.mock.calls)).not.toContain('secret-api-key'); + }); +});