P1.5.2 Kimi K2 Adapter — Behavioral Contract
Round: R92, Phase 1.5, Wave 3 (parallel slice 1/3)
Base SHA: 89adef66
1. Public surface
Module: src/domains/router/adapters/kimi.ts.
export class KimiConfigError extends Error {
readonly code: 'KIMI_CONFIG_ERROR';
readonly name: 'KimiConfigError';
}
export class KimiApiError extends Error {
readonly code: 'KIMI_API_ERROR' | 'KIMI_RETRIES_EXHAUSTED';
readonly status: number | undefined;
readonly name: 'KimiApiError';
}
export interface KimiCompletionOptions {
readonly model?: string;
readonly maxTokens?: number;
readonly systemPrompt?: string;
readonly fetchFn?: typeof fetch;
readonly logger?: (...args: unknown[]) => void;
readonly delayFn?: (ms: number) => Promise<void>;
readonly apiKey?: string;
readonly baseUrl?: string;
}
export function createKimiCompletion(
prompt: string,
options?: KimiCompletionOptions,
): Promise<CompletionResult>;
export function createKimiCompletionWithTools(
prompt: string,
tools: AnthropicTool[],
options?: KimiCompletionOptions,
): Promise<CompletionResult>;
Both return shapes (CompletionResult, AnthropicTool) are imported from ../../integrations/claude.js via type-only imports to guarantee bit-identical interface parity.
2. Behavioral invariants
| ID | Invariant |
|---|---|
| I1 | Surface parity. createKimiCompletion returns the same CompletionResult shape (content, model, promptTokens, completionTokens, latencyMs, stopReason) as createCompletion from claude.ts. |
| I2 | Call-time env validation. Module load never throws on missing env. createKimiCompletion / createKimiCompletionWithTools throw KimiConfigError when no key is present. |
| I3 | Key resolution order. options.apiKey ?? process.env['COLIBRI_KIMI_API_KEY']. |
| I4 | Base URL resolution order. options.baseUrl ?? process.env['COLIBRI_KIMI_BASE_URL'] ?? 'https://api.moonshot.ai/v1'. |
| I5 | Default model. When options.model is absent, default to 'kimi-k2-0905-preview'. Single string constant in the module. |
| I6 | Default max_tokens. When options.maxTokens is absent, default to 1024 (parity with Claude adapter). |
| I7 | Retry policy parity. MAX_RETRIES = 3, BASE_DELAY_MS = 100, doubling. 429 + 5xx retryable; everything else terminal. |
| I8 | Network error wrap. A thrown fetchFn error becomes a KimiApiError with status: undefined, code: 'KIMI_API_ERROR', NOT retried. |
| I9 | Logging shape. logger('[kimi] model=<m> prompt_tokens=<p> completion_tokens=<c> latency_ms=<l>') on success. Defaults to console.error so stdout (owned by StdioServerTransport) is never touched. |
| I10 | Empty tools array → degrade. createKimiCompletionWithTools(prompt, [], options) MUST NOT send a tools key in the request body. (Parity with claude.ts:183.) |
| I11 | Tool-use response → Anthropic-shape content. When Kimi returns tool_calls, the adapter must map to Anthropic-shape [{type:'tool_use', id, name, input}] and return JSON.stringify(blocks) as content. (Parity with claude.ts:213.) |
| I12 | Tool args parse-fail tolerance. A tool_call with non-JSON arguments → input: { _parse_error: <msg>, _raw: <string> }. NEVER throws. |
| I13 | finish_reason normalization. Map: stop→end_turn, tool_calls→tool_use, length→max_tokens, content_filter→refusal; pass-through for unknowns. |
| I14 | Injection seam parity. fetchFn, logger, delayFn are all pluggable via options. Tests inject mocks; production uses fetch / console.error / a setTimeout-based sleep. |
| I15 | No barrel export in this slice. src/domains/router/index.ts is NOT touched. Re-export coordination lands in a fold-in commit between Wave 3 and Wave 4. |
| I16 | No MCP tool registration. Library-only. Phase 1.5 router_call lands in P1.5.7. |
| I17 | Authorization header. Authorization: Bearer <key> — NOT x-api-key. |
| I18 | System prompt placement. Kimi/OpenAI shape: prepend {role:'system', content: systemPrompt} to messages, NOT a top-level system field. |
| I19 | Tool descriptor mapping (request). AnthropicTool {name, description, input_schema} → {type:'function', function:{name, description, parameters: input_schema}}. |
| I20 | No mutation of inputs. tools[], options, prompt are read-only — adapter does not mutate caller-supplied state. |
3. Tool-use mapping (binding contract)
Request side — tools translation
For each AnthropicTool input:
// Input (AnthropicTool)
{ name: 'get_weather', description: 'Get weather', input_schema: {...} }
// Output (KimiTool)
{ type: 'function', function: { name: 'get_weather', description: 'Get weather', parameters: {...} } }
The input_schema JSON schema object is mapped directly to parameters — no transformation, no validation, no field renames. (Both Anthropic and OpenAI use vanilla JSON Schema for tool parameter declarations.)
Response side — tool_calls translation
For each choices[0].message.tool_calls[i] from Kimi:
// Input (Kimi tool_call)
{ id: 'call_001', type: 'function', function: { name: 'get_weather', arguments: '{"location":"London"}' } }
// Output (Anthropic-shape tool_use block)
{ type: 'tool_use', id: 'call_001', name: 'get_weather', input: { location: 'London' } }
arguments is a JSON-string in Kimi; the adapter parses it. On JSON.parse failure:
input: { _parse_error: '<error.message>', _raw: '<original arguments string>' }
This keeps the response well-formed for downstream consumers while preserving the raw payload for diagnostics. Per I12, this never throws.
When tool_calls are present AND choices[0].message.content is non-empty, the text content is appended as a leading {type:'text', text} block before the tool_use blocks. Matches Claude’s hybrid responses (rare, but supported).
content field of returned CompletionResult
- Pure text response (no tool_calls):
result.content === <text string>. - Tool-use response (tool_calls present):
result.content === JSON.stringify(toolUseBlocks), wheretoolUseBlocksis the Anthropic-shape array. (Parity with claude.ts:213.)
4. Error class hierarchy
| Class | code | Trigger |
|---|---|---|
KimiConfigError |
'KIMI_CONFIG_ERROR' |
COLIBRI_KIMI_API_KEY absent and no options.apiKey. |
KimiApiError |
'KIMI_API_ERROR' |
Terminal HTTP error (non-retryable status), network failure. |
KimiApiError |
'KIMI_RETRIES_EXHAUSTED' |
All 3 retries exhausted on retryable status. |
Each subclass:
- Extends
Error. - Sets
this.nameto the class name. - Sets
this.codeas areadonlyliteral. - For
KimiApiError, setsthis.statusasnumber | undefined.
Shape parity with AnthropicConfigError / AnthropicApiError. Callers can write generic if (err instanceof Error && 'code' in err) checks.
5. Env-var contract
| Var | Type | Default | Validation |
|---|---|---|---|
COLIBRI_KIMI_API_KEY |
string | (none) | Call-time. Absent → KimiConfigError. |
COLIBRI_KIMI_BASE_URL |
string | 'https://api.moonshot.ai/v1' |
Call-time. Empty string treated as unset (falls back to default). |
The two vars are not declared in src/config.ts because:
- The Phase 0 boot-time schema is frozen for the 14-tool surface; new envs go through ADR-006 graduation.
- The Claude adapter precedent (
ANTHROPIC_API_KEY) readsprocess.envdirectly at call-time. - This slice is
library-only— no MCP tool reads these vars yet.
Direct process.env[...] access at call-time is the documented Phase 1.5 pattern.
6. Retry policy
attempt 1 → 0ms before request
on 429 or 5xx → sleep(100ms), attempt 2
on 429 or 5xx → sleep(200ms), attempt 3
on 429 or 5xx → sleep(400ms), attempt 4
on 429 or 5xx after attempt 4 → KimiApiError(code='KIMI_RETRIES_EXHAUSTED')
MAX_RETRIES = 3 means up to 4 attempts total (initial + 3 retries) — same constant semantics as the Claude adapter (where attempt >= MAX_RETRIES triggers exhaustion AFTER the 4th attempt has failed).
Network errors are NOT retried — they signal a config / DNS / firewall problem, not a transient server load issue.
7. Logging contract
On success (after parseResult):
[kimi] model=<resolved_model> prompt_tokens=<p> completion_tokens=<c> latency_ms=<n>
resolved_modelis themodelfield from the API response, or'unknown'if absent.latency_msisDate.now() - startMsmeasured around thefetchcall.- Default destination:
console.error(stderr-only, neverprocess.stdout). - Tests pass a
silent loggerthat captures lines into a string array.
8. Determinism
The adapter is deterministic with respect to inputs UP TO the wall-clock-derived latencyMs field. With a stable fetchFn mock and a stable delayFn, the same prompt/options always produces the same CompletionResult (modulo latencyMs).
Test pattern (matches claude.ts test fixtures):
makeMockFetch(responses)— deterministic response sequence.makeSilentLogger()— captures log lines.makeInstantDelay()— records sleep durations without actually sleeping.
9. Acceptance criteria (echoed from task prompt)
createKimiCompletion(prompt, options)returnsPromise<CompletionResult>.createKimiCompletionWithTools(prompt, tools, options)returnsPromise<CompletionResult>.- Reads
COLIBRI_KIMI_API_KEYenv. Missing →KimiConfigError. - Reads
COLIBRI_KIMI_BASE_URLenv. Defaulthttps://api.moonshot.ai/v1. - Tool-use response shape: Anthropic-SDK compatible (
{type:'tool_use', id, name, input}). - Injection seams:
fetchFn,logger,delayFn. KimiApiErrorshape parity withAnthropicApiError.- 5–10 parity tests pass.
- No MCP tool registration.
- No
src/domains/router/index.tsmutation (parallel-T3 race). npm run build && npm run lint && npm testgreen.
10. Exit criteria
- Public surface declared (§1).
- Behavioral invariants enumerated (§2).
- Tool-use mapping bound (§3).
- Error hierarchy specified (§4).
- Env vars contracted (§5).
- Retry semantics specified (§6).
- Logging contract specified (§7).
- Determinism specified (§8).
- Acceptance criteria listed (§9).
Next: Step 3 (packet — execution plan).