mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-16 19:44:14 +00:00
Add Kimi provider
This commit is contained in:
parent
d9acd8c4cd
commit
78cbfd3798
18 changed files with 721 additions and 6 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -1,5 +1,16 @@
|
|||
# Changelog
|
||||
|
||||
## 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.8 - 2026-05-10
|
||||
|
||||
### Added (CLI)
|
||||
|
|
|
|||
|
|
@ -113,6 +113,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) |
|
||||
|
|
@ -380,7 +381,9 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta
|
|||
|
||||
**Roo Code / KiloCode** are Cline-family VS Code extensions. CodeBurn reads `ui_messages.json` from each task directory in VS Code's `globalStorage`, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts.
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -390,6 +393,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
|
||||
|
|
|
|||
5
assets/providers/kimi.svg
Normal file
5
assets/providers/kimi.svg
Normal 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 |
|
|
@ -128,9 +128,9 @@ type Provider = {
|
|||
}
|
||||
```
|
||||
|
||||
`src/providers/index.ts` registers eighteen providers across two tiers:
|
||||
`src/providers/index.ts` registers nineteen providers across two tiers:
|
||||
|
||||
- **Eager**: `claude`, `codex`, `copilot`, `droid`, `gemini`, `kilo-code`, `kiro`, `openclaw`, `pi`, `omp`, `qwen`, `roo-code`. Imported at module load.
|
||||
- **Eager**: `claude`, `codex`, `copilot`, `droid`, `gemini`, `kilo-code`, `kiro`, `openclaw`, `pi`, `omp`, `qwen`, `kimi`, `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.
|
||||
|
|
@ -181,7 +181,7 @@ The `prepublishOnly` hook in `package.json` runs `npm run build` so `npm publish
|
|||
|
||||
- `tests/` root (27 files) covers CLI, parser, optimize, cache, format, models, plans.
|
||||
- `tests/security/` (1 file) covers prototype-pollution guards.
|
||||
- `tests/providers/` (14 files) covers per-provider parsing.
|
||||
- `tests/providers/` (15 files) covers per-provider parsing.
|
||||
- `tests/fixtures/` holds redacted real-world session data.
|
||||
|
||||
Five providers ship without dedicated test files today: `antigravity`, `claude`, `gemini`, `goose`, `qwen`. Closing this gap is a standing good-first-issue.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ For the architectural picture, see `../architecture.md`.
|
|||
| [Gemini](gemini.md) | JSON / JSONL | `src/providers/gemini.ts` | none |
|
||||
| [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
62
docs/providers/kimi.md
Normal 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.
|
||||
|
|
@ -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',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -726,6 +726,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case droid = "Droid"
|
||||
case gemini = "Gemini"
|
||||
case kiro = "Kiro"
|
||||
case kimi = "Kimi"
|
||||
case kiloCode = "KiloCode"
|
||||
case openclaw = "OpenClaw"
|
||||
case opencode = "OpenCode"
|
||||
|
|
@ -758,6 +759,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case .gemini: "gemini"
|
||||
case .kiloCode: "kilo-code"
|
||||
case .kiro: "kiro"
|
||||
case .kimi: "kimi"
|
||||
case .openclaw: "openclaw"
|
||||
case .opencode: "opencode"
|
||||
case .pi: "pi"
|
||||
|
|
|
|||
|
|
@ -347,6 +347,7 @@ extension ProviderFilter {
|
|||
case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/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)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"claude-code",
|
||||
"cursor",
|
||||
"codex",
|
||||
"kimi",
|
||||
"opencode",
|
||||
"pi",
|
||||
"ai-coding",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const PROVIDER_COLORS: Record<string, string> = {
|
|||
cursor: '#00B4D8',
|
||||
opencode: '#A78BFA',
|
||||
pi: '#F472B6',
|
||||
kimi: '#B6E34A',
|
||||
all: '#FF8C42',
|
||||
}
|
||||
|
||||
|
|
@ -515,6 +516,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
|||
cursor: 'Cursor',
|
||||
opencode: 'OpenCode',
|
||||
pi: 'Pi',
|
||||
kimi: 'Kimi',
|
||||
}
|
||||
function getProviderDisplayName(name: string): string { return PROVIDER_DISPLAY_NAMES[name] ?? name }
|
||||
|
||||
|
|
|
|||
|
|
@ -170,6 +170,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
|
||||
|
|
@ -355,6 +358,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> = {
|
||||
|
|
@ -398,6 +402,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',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { droid } from './droid.js'
|
|||
import { gemini } from './gemini.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'
|
||||
|
|
@ -101,7 +102,7 @@ async function loadCrush(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode]
|
||||
const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, kimi, 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
394
src/providers/kimi.ts
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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', 'codex', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'kimi', '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')!
|
||||
|
|
|
|||
192
tests/providers/kimi.test.ts
Normal file
192
tests/providers/kimi.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue