Merge pull request #306 from ozymandiashh/feat/kimi-code-provider

Support Kimi Code CLI sessions
This commit is contained in:
Resham Joshi 2026-05-16 07:25:23 -07:00 committed by GitHub
commit c455203b0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 718 additions and 5 deletions

View file

@ -2,6 +2,15 @@
## Unreleased
### Added (CLI)
- **Kimi Code CLI provider.** CodeBurn now reads Kimi session usage from
`$KIMI_SHARE_DIR/sessions/` or `~/.kimi/sessions/`, including subagent
`wire.jsonl` files. The parser consumes Kimi's official `StatusUpdate`
token usage fields (`input_other`, `input_cache_read`,
`input_cache_creation`, `output`), normalizes Kimi tool names such as
`Shell`, `ReadFile`, and `WriteFile`, and maps hidden managed Kimi Code
model aliases to priced Kimi K2 entries.
## 0.9.9 - 2026-05-15
### Added (CLI)

View file

@ -115,6 +115,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr
| <img src="assets/providers/roo-code.png" width="28" /> | Roo Code | Yes | [roo-code.md](docs/providers/roo-code.md) |
| <img src="assets/providers/kilo-code.png" width="28" /> | KiloCode | Yes | [kilo-code.md](docs/providers/kilo-code.md) |
| <img src="assets/providers/qwen.png" width="28" /> | Qwen | Yes | [qwen.md](docs/providers/qwen.md) |
| <img src="assets/providers/kimi.svg" width="28" /> | Kimi Code CLI | Yes | [kimi.md](docs/providers/kimi.md) |
| <img src="assets/providers/goose.png" width="28" /> | Goose | Yes | [goose.md](docs/providers/goose.md) |
| <img src="assets/providers/antigravity.png" width="28" /> | Antigravity | Yes | [antigravity.md](docs/providers/antigravity.md) |
| <img src="assets/providers/crush.png" width="28" /> | Crush | Yes | [crush.md](docs/providers/crush.md) |
@ -384,7 +385,9 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta
**IBM Bob** stores IDE task history in `User/globalStorage/ibm.bob-code/tasks/<task-id>/` under the IBM Bob application data directory. CodeBurn reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model, with support for both GA (`IBM Bob`) and preview (`Bob-IDE`) app data folders.
CodeBurn deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session ID for Gemini, by session+message ID for OpenCode, by responseId for Pi/OMP), filters by date range per entry, and classifies each turn.
**Kimi Code CLI** stores session logs under `$KIMI_SHARE_DIR/sessions/<workdir-hash>/<session-id>/` or `~/.kimi/sessions/<workdir-hash>/<session-id>/`. CodeBurn reads `wire.jsonl` `StatusUpdate.token_usage` records, maps `input_other`, `input_cache_read`, `input_cache_creation`, and `output` into the standard token columns, and includes subagent sessions under each session's `subagents/` folder.
CodeBurn deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session ID for Gemini, by session+message ID for OpenCode, by responseId for Pi/OMP, by session+message ID for Kimi), filters by date range per entry, and classifies each turn.
## Environment Variables
@ -394,6 +397,8 @@ CodeBurn deduplicates messages (by API message ID for Claude, by cumulative toke
| `CLAUDE_CONFIG_DIRS` | OS-delimited list of Claude data directories to scan together (e.g. `~/.claude-work:~/.claude-personal`). Sessions merge into one row per project. Overrides `CLAUDE_CONFIG_DIR` when set. |
| `CODEX_HOME` | Override Codex data directory (default: `~/.codex`) |
| `FACTORY_DIR` | Override Droid data directory (default: `~/.factory`) |
| `KIMI_SHARE_DIR` | Override Kimi Code CLI share directory (default: `~/.kimi`) |
| `KIMI_MODEL_NAME` | Override Kimi model name when Kimi sessions do not record the model |
| `QWEN_DATA_DIR` | Override Qwen data directory (default: `~/.qwen/projects`) |
## Sponsoring CodeBurn

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Kimi">
<rect width="64" height="64" rx="14" fill="#101820"/>
<path d="M18 46V18h7v11.2L36 18h9L33 30.3 46.5 46h-9.2L25 31.4V46h-7z" fill="#C8F35A"/>
<circle cx="48" cy="16" r="5" fill="#7EE787"/>
</svg>

After

Width:  |  Height:  |  Size: 292 B

View file

@ -128,9 +128,9 @@ type Provider = {
}
```
`src/providers/index.ts` registers twenty providers across two tiers:
`src/providers/index.ts` registers twenty-one providers across two tiers:
- **Eager**: `claude`, `cline`, `codex`, `copilot`, `droid`, `gemini`, `ibm-bob`, `kilo-code`, `kiro`, `openclaw`, `pi`, `omp`, `qwen`, `roo-code`. Imported at module load.
- **Eager**: `claude`, `cline`, `codex`, `copilot`, `droid`, `gemini`, `ibm-bob`, `kilo-code`, `kiro`, `kimi`, `openclaw`, `pi`, `omp`, `qwen`, `roo-code`. Imported at module load.
- **Lazy**: `antigravity`, `goose`, `cursor`, `opencode`, `cursor-agent`, `crush`. Imported via dynamic `import()` so the heavy dependencies (SQLite, protobuf) do not touch users who do not have those tools installed.
Both lists hit the same `getAllProviders()` aggregator. A failed lazy import is silent and excludes that provider from the run.

View file

@ -19,6 +19,7 @@ For the architectural picture, see `../architecture.md`.
| [IBM Bob](ibm-bob.md) | JSON | `src/providers/ibm-bob.ts` | `tests/providers/ibm-bob.test.ts` |
| [KiloCode](kilo-code.md) | JSON | `src/providers/kilo-code.ts` | `tests/providers/kilo-code.test.ts` |
| [Kiro](kiro.md) | JSON | `src/providers/kiro.ts` | `tests/providers/kiro.test.ts` |
| [Kimi](kimi.md) | JSONL | `src/providers/kimi.ts` | `tests/providers/kimi.test.ts` |
| [OpenClaw](openclaw.md) | JSONL | `src/providers/openclaw.ts` | `tests/providers/openclaw.test.ts` |
| [Pi](pi.md) | JSONL | `src/providers/pi.ts` | `tests/providers/pi.test.ts` |
| [OMP](omp.md) | JSONL | `src/providers/pi.ts` | `tests/providers/omp.test.ts` |

62
docs/providers/kimi.md Normal file
View file

@ -0,0 +1,62 @@
# Kimi
Kimi Code CLI session parser.
- **Source:** `src/providers/kimi.ts`
- **Loading:** eager (`src/providers/index.ts`)
- **Test:** `tests/providers/kimi.test.ts`
## Where it reads from
`$KIMI_SHARE_DIR/sessions/` if set, otherwise `~/.kimi/sessions/`.
Kimi stores sessions by work-directory hash:
```text
~/.kimi/
kimi.json
config.toml
sessions/
<workdir-md5>/
<session-id>/
context.jsonl
wire.jsonl
state.json
subagents/
<agent-id>/
context.jsonl
wire.jsonl
```
`kimi.json` maps each work-directory hash back to the original working path. CodeBurn uses that to display the project basename; if the metadata file is missing, the hash directory name is used.
## Storage Format
CodeBurn reads `wire.jsonl`. Each data line is a persisted wire record:
```json
{"timestamp":1776162403,"message":{"type":"StatusUpdate","payload":{"message_id":"msg-1","token_usage":{"input_other":100,"input_cache_read":25,"input_cache_creation":10,"output":40}}}}
```
`TurnBegin` / `SteerInput` provide the user prompt, `ToolCall` / `ToolCallRequest` provide tool names and shell commands, and `StatusUpdate.token_usage` provides the billable token counts.
## Caching
None.
## Deduplication
Per `kimi:<session-id>:<message_id>`, falling back to the status-update line index if the message id is absent.
## Quirks
- Kimi's official `TokenUsage` separates `input_other`, `input_cache_read`, `input_cache_creation`, and `output`. CodeBurn maps those directly into input, cache read, cache write, and output.
- The current Kimi wire schema does not persist the model on every usage update. CodeBurn uses `KIMI_MODEL_NAME` when set, then the active `~/.kimi/config.toml` default model, then `kimi-auto`.
- `kimi-auto`, `kimi-code`, and `kimi-for-coding` are priced as `kimi-k2-thinking` so managed Kimi Code sessions do not show as `$0` when the exact backend model is hidden.
- Subagent sessions are discovered from `subagents/<agent-id>/wire.jsonl` and parsed as separate Kimi sessions under the same project.
## When Fixing A Bug Here
1. Reproduce with a tiny `wire.jsonl` fixture in `tests/providers/kimi.test.ts`.
2. If token totals look wrong, inspect `StatusUpdate.token_usage` first; `context.jsonl` only stores context checkpoints and cumulative counts, not per-step billing detail.
3. If tools are missing, check whether Kimi emitted `ToolCall`, `ToolCallRequest`, or nested `SubagentEvent`; CodeBurn intentionally counts subagent wire files separately to avoid double-counting parent mirrors.

View file

@ -41,6 +41,7 @@ const PROVIDERS = [
{ id: 'gemini', label: 'Gemini' },
{ id: 'kilo-code', label: 'Kilo Code' },
{ id: 'kiro', label: 'Kiro' },
{ id: 'kimi', label: 'Kimi' },
{ id: 'roo-code', label: 'Roo Code' },
];
@ -69,6 +70,7 @@ const PROVIDER_PATHS = {
codex: '.codex/sessions',
cursor: '.config/Cursor/User/globalStorage/state.vscdb',
copilot: '.copilot/session-state',
kimi: '.kimi/sessions',
pi: '.pi/agent/sessions',
};

View file

@ -13,6 +13,7 @@ const PROVIDERS = [
{ id: 'goose', label: 'Goose' },
{ id: 'kilo-code', label: 'Kilo Code' },
{ id: 'kiro', label: 'Kiro' },
{ id: 'kimi', label: 'Kimi' },
{ id: 'openclaw', label: 'OpenClaw' },
{ id: 'opencode', label: 'OpenCode' },
{ id: 'pi', label: 'Pi' },

View file

@ -851,6 +851,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case gemini = "Gemini"
case ibmBob = "IBM Bob"
case kiro = "Kiro"
case kimi = "Kimi"
case kiloCode = "KiloCode"
case openclaw = "OpenClaw"
case opencode = "OpenCode"
@ -893,6 +894,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case .ibmBob: "ibm-bob"
case .kiloCode: "kilo-code"
case .kiro: "kiro"
case .kimi: "kimi"
case .openclaw: "openclaw"
case .opencode: "opencode"
case .pi: "pi"

View file

@ -489,6 +489,7 @@ extension ProviderFilter {
case .ibmBob: return Color(red: 0x0F/255.0, green: 0x62/255.0, blue: 0xFE/255.0)
case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0)
case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0)
case .kimi: return Color(red: 0xA4/255.0, green: 0xC6/255.0, blue: 0x39/255.0)
case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0)
case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0)
case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0)

View file

@ -21,6 +21,7 @@
"claude-code",
"cursor",
"codex",
"kimi",
"ibm-bob",
"opencode",
"pi",

View file

@ -55,6 +55,7 @@ const PROVIDER_COLORS: Record<string, string> = {
'ibm-bob': '#0F62FE',
opencode: '#A78BFA',
pi: '#F472B6',
kimi: '#B6E34A',
all: '#FF8C42',
}
@ -528,6 +529,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
'ibm-bob': 'IBM Bob',
opencode: 'OpenCode',
pi: 'Pi',
kimi: 'Kimi',
}
function getProviderDisplayName(name: string): string { return PROVIDER_DISPLAY_NAMES[name] ?? name }

View file

@ -172,6 +172,9 @@ const BUILTIN_ALIASES: Record<string, string> = {
'cline-auto': 'claude-sonnet-4-5',
'openclaw-auto': 'claude-sonnet-4-5',
'qwen-auto': 'claude-sonnet-4-5',
'kimi-auto': 'kimi-k2-thinking',
'kimi-code': 'kimi-k2-thinking',
'kimi-for-coding': 'kimi-k2-thinking',
// Cursor emits dot-version tier-last names plus tier/reasoning suffixes
// that LiteLLM does not index (`-high`, `-low`, `-medium`, `-thinking`,
// `-high-thinking`, `-fast-mode`). Missing aliases here surface as $0 in
@ -363,6 +366,7 @@ const autoModelNames: Record<string, string> = {
'cline-auto': 'Cline (auto)',
'openclaw-auto': 'OpenClaw (auto)',
'qwen-auto': 'Qwen (auto)',
'kimi-auto': 'Kimi (auto)',
}
const SHORT_NAMES: Record<string, string> = {
@ -406,6 +410,17 @@ const SHORT_NAMES: Record<string, string> = {
'gemini-3-flash-preview': 'Gemini 3 Flash',
'gemini-2.5-pro': 'Gemini 2.5 Pro',
'gemini-2.5-flash': 'Gemini 2.5 Flash',
'kimi-k2-thinking-turbo': 'Kimi K2 Thinking Turbo',
'kimi-k2-thinking': 'Kimi K2 Thinking',
'kimi-thinking-preview': 'Kimi Thinking',
'kimi-k2.6': 'Kimi K2.6',
'kimi-k2.5': 'Kimi K2.5',
'kimi-k2p5': 'Kimi K2.5',
'kimi-k2-instruct': 'Kimi K2 Instruct',
'kimi-k2-0905': 'Kimi K2',
'kimi-k2': 'Kimi K2',
'kimi-latest': 'Kimi Latest',
'moonshot-v1': 'Moonshot v1',
'deepseek-coder-max': 'DeepSeek Coder Max',
'deepseek-coder': 'DeepSeek Coder',
'deepseek-r1': 'DeepSeek R1',

View file

@ -7,6 +7,7 @@ import { gemini } from './gemini.js'
import { ibmBob } from './ibm-bob.js'
import { kiloCode } from './kilo-code.js'
import { kiro } from './kiro.js'
import { kimi } from './kimi.js'
import { openclaw } from './openclaw.js'
import { pi, omp } from './pi.js'
import { qwen } from './qwen.js'
@ -103,7 +104,7 @@ async function loadCrush(): Promise<Provider | null> {
}
}
const coreProviders: Provider[] = [claude, cline, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode]
const coreProviders: Provider[] = [claude, cline, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, kimi, openclaw, pi, omp, qwen, rooCode]
export async function getAllProviders(): Promise<Provider[]> {
const [ag, gs, cursor, opencode, cursorAgent, crush] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush()])

394
src/providers/kimi.ts Normal file
View file

@ -0,0 +1,394 @@
import { createHash } from 'crypto'
import { readdir, readFile, stat } from 'fs/promises'
import { basename, dirname, join } from 'path'
import { homedir } from 'os'
import { extractBashCommands } from '../bash-utils.js'
import { readSessionLines } from '../fs-utils.js'
import { calculateCost, getShortModelName } from '../models.js'
import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
type JsonObject = Record<string, unknown>
const toolNameMap: Record<string, string> = {
Shell: 'Bash',
Bash: 'Bash',
bash: 'Bash',
ReadFile: 'Read',
ReadMediaFile: 'Read',
WriteFile: 'Write',
StrReplaceFile: 'Edit',
Grep: 'Grep',
Glob: 'Glob',
SearchWeb: 'WebSearch',
FetchURL: 'WebFetch',
Agent: 'Agent',
AgentTool: 'Agent',
TaskList: 'Agent',
TaskOutput: 'Agent',
TaskStop: 'Agent',
AskUserQuestion: 'AskUser',
SetTodoList: 'TodoWrite',
Think: 'Think',
EnterPlanMode: 'EnterPlanMode',
ExitPlanMode: 'ExitPlanMode',
SendDMail: 'DMail',
}
function asObject(value: unknown): JsonObject | null {
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : null
}
function stringField(obj: JsonObject | null, key: string): string | undefined {
const value = obj?.[key]
return typeof value === 'string' ? value : undefined
}
function numericField(obj: JsonObject, ...keys: string[]): number {
for (const key of keys) {
const raw = obj[key]
const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
if (Number.isFinite(n) && n > 0) return Math.trunc(n)
}
return 0
}
function getShareDir(overrideDir?: string): string {
return overrideDir ?? process.env['KIMI_SHARE_DIR'] ?? join(homedir(), '.kimi')
}
function md5(text: string): string {
return createHash('md5').update(text, 'utf-8').digest('hex')
}
function projectNameFromPath(pathValue: string): string {
const cleaned = pathValue.replace(/\/+$/, '')
return basename(cleaned) || cleaned || 'kimi'
}
async function loadProjectNames(shareDir: string): Promise<Map<string, string>> {
const projects = new Map<string, string>()
const raw = await readFile(join(shareDir, 'kimi.json'), 'utf-8').catch(() => null)
if (!raw) return projects
let data: unknown
try {
data = JSON.parse(raw)
} catch {
return projects
}
const workDirs = asObject(data)?.['work_dirs']
if (!Array.isArray(workDirs)) return projects
for (const entry of workDirs) {
const obj = asObject(entry)
const pathValue = stringField(obj, 'path')
if (!pathValue) continue
const hash = md5(pathValue)
const project = projectNameFromPath(pathValue)
projects.set(hash, project)
const kaos = stringField(obj, 'kaos')
if (kaos && kaos !== 'local') projects.set(`${kaos}_${hash}`, project)
}
return projects
}
function parseTomlString(raw: string): string | null {
const value = raw.trim()
if (!value) return null
if (value.startsWith('"')) {
const match = value.match(/^"((?:[^"\\]|\\.)*)"/)
if (!match) return null
try {
return JSON.parse(`"${match[1]}"`) as string
} catch {
return match[1] ?? null
}
}
if (value.startsWith("'")) {
const match = value.match(/^'([^']*)'/)
return match?.[1] ?? null
}
const match = value.match(/^([^#\s]+)/)
return match?.[1] ?? null
}
function parseDefaultModelKey(configToml: string): string | null {
for (const line of configToml.split('\n')) {
const match = line.match(/^\s*default_model\s*=\s*(.+)$/)
if (!match) continue
return parseTomlString(match[1]!)
}
return null
}
function parseModelSectionName(line: string): string | null {
const match = line.trim().match(/^\[models\.(?:"([^"]+)"|'([^']+)'|([^\]]+))\]$/)
if (!match) return null
return (match[1] ?? match[2] ?? match[3] ?? '').trim() || null
}
function parseModelIdForKey(configToml: string, modelKey: string): string | null {
let inSection = false
for (const line of configToml.split('\n')) {
const section = parseModelSectionName(line)
if (section !== null) {
inSection = section === modelKey
continue
}
if (!inSection) continue
if (/^\s*\[/.test(line)) {
inSection = false
continue
}
const match = line.match(/^\s*model\s*=\s*(.+)$/)
if (!match) continue
return parseTomlString(match[1]!)
}
return null
}
async function getConfiguredModel(shareDir: string): Promise<string> {
const envModel = process.env['KIMI_MODEL_NAME']?.trim()
if (envModel) return envModel
const raw = await readFile(join(shareDir, 'config.toml'), 'utf-8').catch(() => null)
if (!raw) return 'kimi-auto'
const defaultModel = parseDefaultModelKey(raw)
if (!defaultModel) return 'kimi-auto'
return parseModelIdForKey(raw, defaultModel) ?? defaultModel
}
function parseJsonObject(text: string | undefined): JsonObject | null {
if (!text) return null
try {
return asObject(JSON.parse(text))
} catch {
return null
}
}
function extractUserText(value: unknown): string {
if (typeof value === 'string') return value.slice(0, 500)
if (!Array.isArray(value)) return ''
return value
.map(part => stringField(asObject(part), 'text') ?? '')
.filter(Boolean)
.join(' ')
.slice(0, 500)
}
function timestampToIso(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value !== 'number' || !Number.isFinite(value)) return ''
const millis = value > 1_000_000_000_000 ? value : value * 1000
const date = new Date(millis)
return Number.isFinite(date.getTime()) ? date.toISOString() : ''
}
function extractEnvelope(record: JsonObject): { type: string; payload: JsonObject; timestamp: string } | null {
const message = asObject(record['message'])
const envelope = message ?? record
const type = stringField(envelope, 'type')
const payload = asObject(envelope['payload'])
if (!type || !payload) return null
return { type, payload, timestamp: timestampToIso(record['timestamp']) }
}
function extractUsage(payload: JsonObject): {
inputTokens: number
outputTokens: number
cacheReadInputTokens: number
cacheCreationInputTokens: number
} | null {
const usage = asObject(payload['token_usage']) ?? asObject(payload['usage'])
if (!usage) return null
const cacheReadInputTokens = numericField(usage, 'input_cache_read', 'cache_read_input_tokens', 'cached_input_tokens')
const cacheCreationInputTokens = numericField(usage, 'input_cache_creation', 'cache_creation_input_tokens')
let inputTokens = numericField(usage, 'input_other', 'input_tokens')
if (inputTokens === 0) {
const totalInput = numericField(usage, 'input')
inputTokens = Math.max(0, totalInput - cacheReadInputTokens - cacheCreationInputTokens)
}
const outputTokens = numericField(usage, 'output', 'output_tokens')
if (inputTokens === 0 && outputTokens === 0 && cacheReadInputTokens === 0 && cacheCreationInputTokens === 0) {
return null
}
return { inputTokens, outputTokens, cacheReadInputTokens, cacheCreationInputTokens }
}
function extractTool(payload: JsonObject): { tool: string; bashCommands: string[] } | null {
const fn = asObject(payload['function'])
const rawName = stringField(fn, 'name') ?? stringField(payload, 'name')
if (!rawName) return null
const tool = toolNameMap[rawName] ?? rawName
const argsText = stringField(fn, 'arguments') ?? stringField(payload, 'arguments')
const args = parseJsonObject(argsText)
const command = stringField(args, 'command')
const bashCommands = tool === 'Bash' && command ? extractBashCommands(command) : []
return { tool, bashCommands }
}
function createParser(source: SessionSource, shareDir: string, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const configuredModel = await getConfiguredModel(shareDir)
const tools = new Set<string>()
const bashCommands = new Set<string>()
let currentUserMessage = ''
const sessionId = basename(dirname(source.path))
let index = 0
for await (const line of readSessionLines(source.path)) {
if (!line.trim()) continue
let record: JsonObject | null = null
try {
record = asObject(JSON.parse(line))
} catch {
continue
}
if (!record) continue
const envelope = extractEnvelope(record)
if (!envelope || envelope.type === 'metadata') continue
if (envelope.type === 'TurnBegin' || envelope.type === 'SteerInput') {
currentUserMessage = extractUserText(envelope.payload['user_input'])
continue
}
if (envelope.type === 'TurnEnd') {
currentUserMessage = ''
tools.clear()
bashCommands.clear()
continue
}
if (envelope.type === 'ToolCall' || envelope.type === 'ToolCallRequest') {
const extracted = extractTool(envelope.payload)
if (!extracted) continue
tools.add(extracted.tool)
for (const command of extracted.bashCommands) bashCommands.add(command)
continue
}
if (envelope.type !== 'StatusUpdate') continue
const usage = extractUsage(envelope.payload)
if (!usage) continue
const rawMessageId = stringField(envelope.payload, 'message_id')
const dedupKey = `kimi:${sessionId}:${rawMessageId ?? index}`
index++
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)
const model = stringField(envelope.payload, 'model') ?? stringField(envelope.payload, 'model_name') ?? configuredModel
const costUSD = calculateCost(
model,
usage.inputTokens,
usage.outputTokens,
usage.cacheCreationInputTokens,
usage.cacheReadInputTokens,
0,
)
yield {
provider: 'kimi',
model,
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cacheCreationInputTokens: usage.cacheCreationInputTokens,
cacheReadInputTokens: usage.cacheReadInputTokens,
cachedInputTokens: usage.cacheReadInputTokens,
reasoningTokens: 0,
webSearchRequests: 0,
costUSD,
tools: [...tools],
bashCommands: [...bashCommands],
timestamp: envelope.timestamp,
speed: 'standard',
deduplicationKey: dedupKey,
userMessage: currentUserMessage,
sessionId,
}
tools.clear()
bashCommands.clear()
}
},
}
}
async function addWireSource(sources: SessionSource[], filePath: string, project: string): Promise<void> {
const s = await stat(filePath).catch(() => null)
if (!s?.isFile()) return
sources.push({ path: filePath, project, provider: 'kimi' })
}
export function createKimiProvider(overrideDir?: string): Provider {
const shareDir = getShareDir(overrideDir)
return {
name: 'kimi',
displayName: 'Kimi',
modelDisplayName(model: string): string {
return getShortModelName(model)
},
toolDisplayName(rawTool: string): string {
return toolNameMap[rawTool] ?? rawTool
},
async discoverSessions(): Promise<SessionSource[]> {
const sources: SessionSource[] = []
const sessionsRoot = join(shareDir, 'sessions')
const projectNames = await loadProjectNames(shareDir)
const workDirs = await readdir(sessionsRoot, { withFileTypes: true }).catch(() => [])
for (const workDir of workDirs) {
if (!workDir.isDirectory()) continue
const project = projectNames.get(workDir.name) ?? workDir.name
const workDirPath = join(sessionsRoot, workDir.name)
const sessionDirs = await readdir(workDirPath, { withFileTypes: true }).catch(() => [])
for (const sessionDir of sessionDirs) {
if (!sessionDir.isDirectory()) continue
const sessionPath = join(workDirPath, sessionDir.name)
await addWireSource(sources, join(sessionPath, 'wire.jsonl'), project)
const subagentsPath = join(sessionPath, 'subagents')
const subagents = await readdir(subagentsPath, { withFileTypes: true }).catch(() => [])
for (const subagent of subagents) {
if (!subagent.isDirectory()) continue
await addWireSource(sources, join(subagentsPath, subagent.name, 'wire.jsonl'), project)
}
}
}
return sources
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return createParser(source, shareDir, seenKeys)
},
}
}
export const kimi = createKimiProvider()

View file

@ -50,6 +50,10 @@ const KNOWN_NAMES = [
'kiro-auto',
'cline-auto',
'qwen-auto',
'kimi-auto',
'kimi-for-coding',
'kimi-k2-thinking-turbo',
'kimi-k2.6',
'o3',
'o4-mini',
'deepseek-coder',
@ -86,6 +90,14 @@ describe('post-hoist resolution stability', () => {
expect(getShortModelName('claude-3-5-haiku')).toBe('Haiku 3.5')
})
it('kimi managed aliases resolve to priced Kimi models', () => {
expect(getShortModelName('kimi-auto')).toBe('Kimi (auto)')
expect(getShortModelName('kimi-for-coding')).toBe('Kimi K2 Thinking')
expect(getShortModelName('kimi-k2-thinking-turbo')).toBe('Kimi K2 Thinking Turbo')
expect(getShortModelName('kimi-k2.6')).toBe('Kimi K2.6')
expect(getModelCosts('kimi-auto')?.inputCostPerToken).toBeGreaterThan(0)
})
it('getModelCosts returns positive token costs for every known name', () => {
for (const name of KNOWN_NAMES) {
const c = getModelCosts(name)

View file

@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js'
describe('provider registry', () => {
it('has core providers registered synchronously', () => {
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
})
it('includes sqlite providers after async load', async () => {
@ -60,6 +60,14 @@ describe('provider registry', () => {
expect(claude.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6')
})
it('kimi model and tool display names are normalized', () => {
const kimi = providers.find(p => p.name === 'kimi')!
expect(kimi.modelDisplayName('kimi-auto')).toBe('Kimi (auto)')
expect(kimi.modelDisplayName('kimi-k2-thinking-turbo')).toBe('Kimi K2 Thinking Turbo')
expect(kimi.toolDisplayName('Shell')).toBe('Bash')
expect(kimi.toolDisplayName('WriteFile')).toBe('Write')
})
it('cursor model display names handle auto mode', async () => {
const all = await getAllProviders()
const cursor = all.find(p => p.name === 'cursor')!

View file

@ -0,0 +1,192 @@
import { createHash } from 'crypto'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { createKimiProvider } from '../../src/providers/kimi.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'kimi-test-'))
})
afterEach(async () => {
delete process.env.KIMI_MODEL_NAME
await rm(tmpDir, { recursive: true, force: true })
})
function md5(value: string): string {
return createHash('md5').update(value, 'utf-8').digest('hex')
}
function record(timestamp: number, type: string, payload: Record<string, unknown>): string {
return JSON.stringify({
timestamp,
message: { type, payload },
})
}
async function writeSession(workDir: string, sessionId: string, lines: string[]): Promise<string> {
const hash = md5(workDir)
const sessionDir = join(tmpDir, 'sessions', hash, sessionId)
await mkdir(sessionDir, { recursive: true })
const wirePath = join(sessionDir, 'wire.jsonl')
await writeFile(wirePath, [
JSON.stringify({ type: 'metadata', protocol_version: '2' }),
...lines,
].join('\n') + '\n')
return wirePath
}
async function collect(provider: ReturnType<typeof createKimiProvider>, path: string, seen = new Set<string>()): Promise<ParsedProviderCall[]> {
const parser = provider.createSessionParser({ path, project: 'app', provider: 'kimi' }, seen)
const calls: ParsedProviderCall[] = []
for await (const call of parser.parse()) calls.push(call)
return calls
}
describe('Kimi provider', () => {
it('discovers session and subagent wire logs under KIMI_SHARE_DIR layout', async () => {
const workDir = '/Users/test/work/app'
const hash = md5(workDir)
await writeFile(join(tmpDir, 'kimi.json'), JSON.stringify({
work_dirs: [{ path: workDir, kaos: 'local', last_session_id: 'sess-1' }],
}))
const sessionDir = join(tmpDir, 'sessions', hash, 'sess-1')
const subagentDir = join(sessionDir, 'subagents', 'agent-1')
await mkdir(subagentDir, { recursive: true })
await writeFile(join(sessionDir, 'wire.jsonl'), '\n')
await writeFile(join(subagentDir, 'wire.jsonl'), '\n')
const sources = await createKimiProvider(tmpDir).discoverSessions()
expect(sources).toHaveLength(2)
expect(sources.map(s => s.project)).toEqual(['app', 'app'])
expect(sources.map(s => s.provider)).toEqual(['kimi', 'kimi'])
expect(sources.map(s => s.path).sort()).toEqual([
join(sessionDir, 'subagents', 'agent-1', 'wire.jsonl'),
join(sessionDir, 'wire.jsonl'),
].sort())
})
it('parses Kimi wire StatusUpdate usage, tools, bash commands, and configured model', async () => {
await writeFile(join(tmpDir, 'config.toml'), [
'default_model = "kimi-code/k2"',
'',
'[models."kimi-code/k2"]',
'model = "kimi-k2-thinking-turbo"',
].join('\n'))
const wirePath = await writeSession('/Users/test/work/app', 'sess-1', [
record(1776162400, 'TurnBegin', { user_input: 'add status endpoint' }),
record(1776162401, 'ToolCall', {
type: 'function',
id: 'call-shell',
function: { name: 'Shell', arguments: JSON.stringify({ command: 'git status && npm test' }) },
}),
record(1776162402, 'ToolCall', {
type: 'function',
id: 'call-read',
function: { name: 'ReadFile', arguments: JSON.stringify({ path: 'src/index.ts' }) },
}),
record(1776162403, 'StatusUpdate', {
message_id: 'msg-1',
token_usage: {
input_other: 100,
input_cache_read: 25,
input_cache_creation: 10,
output: 40,
},
}),
])
const calls = await collect(createKimiProvider(tmpDir), wirePath)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
provider: 'kimi',
model: 'kimi-k2-thinking-turbo',
inputTokens: 100,
outputTokens: 40,
cacheReadInputTokens: 25,
cacheCreationInputTokens: 10,
cachedInputTokens: 25,
tools: ['Bash', 'Read'],
bashCommands: ['git', 'npm'],
timestamp: '2026-04-14T10:26:43.000Z',
deduplicationKey: 'kimi:sess-1:msg-1',
userMessage: 'add status endpoint',
sessionId: 'sess-1',
})
expect(calls[0]!.costUSD).toBeGreaterThan(0)
})
it('uses content parts, model payload overrides, and message-id deduplication', async () => {
process.env.KIMI_MODEL_NAME = 'kimi-k2-thinking'
const wirePath = await writeSession('/Users/test/work/app', 'sess-2', [
record(1776023300, 'TurnBegin', {
user_input: [
{ type: 'text', text: 'refactor parser' },
{ type: 'image_url', image_url: { url: 'file://diagram.png' } },
{ type: 'text', text: 'carefully' },
],
}),
record(1776023301, 'ToolCallRequest', {
id: 'call-write',
name: 'WriteFile',
arguments: JSON.stringify({ path: 'src/parser.ts', content: 'x' }),
}),
record(1776023302, 'StatusUpdate', {
message_id: 'msg-2',
model_name: 'kimi-k2.6',
token_usage: { input_other: 5, output: 7 },
}),
record(1776023303, 'StatusUpdate', {
message_id: 'msg-2',
model_name: 'kimi-k2.6',
token_usage: { input_other: 5, output: 7 },
}),
])
const calls = await collect(createKimiProvider(tmpDir), wirePath)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
model: 'kimi-k2.6',
userMessage: 'refactor parser carefully',
tools: ['Write'],
deduplicationKey: 'kimi:sess-2:msg-2',
})
})
it('skips non-usage updates and supports legacy input total fields defensively', async () => {
const wirePath = await writeSession('/Users/test/work/app', 'sess-3', [
record(1776023400, 'TurnBegin', { user_input: 'summarize' }),
record(1776023401, 'StatusUpdate', { context_usage: 0.5 }),
record(1776023402, 'StatusUpdate', {
message_id: 'msg-3',
token_usage: {
input: 120,
input_cache_read: 30,
input_cache_creation: 10,
output_tokens: 20,
},
}),
])
const calls = await collect(createKimiProvider(tmpDir), wirePath)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
inputTokens: 80,
cacheReadInputTokens: 30,
cacheCreationInputTokens: 10,
outputTokens: 20,
model: 'kimi-auto',
})
})
})