From 406083310f0c9424591e1cf177fc26620a4d607e Mon Sep 17 00:00:00 2001 From: Alvis Date: Sat, 4 Jul 2026 15:20:35 +0000 Subject: [PATCH] Add kimi-agent: kimi-code CLI wrapped as a LiteLLM model Runs the kimi-code coding agent in its own container, exposed as an OpenAI-compatible model ("kimi-agent") that LiteLLM/Open WebUI can call directly. Backed by the user's own Kimi/Moonshot subscription via `kimi login`, not the pay-per-token API. Mount is scoped to a dedicated ~/kimi-workspace directory rather than the full home dir. --- openai/docker-compose.yml | 11 +++ openai/kimi-agent/Dockerfile | 11 +++ openai/kimi-agent/server.js | 129 +++++++++++++++++++++++++++++++++++ openai/litellm-config.yaml | 7 ++ 4 files changed, 158 insertions(+) create mode 100644 openai/kimi-agent/Dockerfile create mode 100644 openai/kimi-agent/server.js diff --git a/openai/docker-compose.yml b/openai/docker-compose.yml index 609ed81..4b44325 100644 --- a/openai/docker-compose.yml +++ b/openai/docker-compose.yml @@ -32,6 +32,14 @@ services: - langfuse restart: always + kimi-agent: + build: ./kimi-agent + container_name: kimi-agent + volumes: + - /home/alvis/kimi-workspace:/workspace + - kimi-agent-home:/root/.kimi-code + restart: unless-stopped + langfuse-db: image: postgres:16-alpine container_name: langfuse-db @@ -112,3 +120,6 @@ services: extra_hosts: - "host.docker.internal:host-gateway" restart: unless-stopped + +volumes: + kimi-agent-home: diff --git a/openai/kimi-agent/Dockerfile b/openai/kimi-agent/Dockerfile new file mode 100644 index 0000000..60e621a --- /dev/null +++ b/openai/kimi-agent/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-slim + +RUN npm install -g @moonshot-ai/kimi-code + +WORKDIR /workspace + +COPY server.js /app/server.js + +EXPOSE 8000 + +ENTRYPOINT ["node", "/app/server.js"] diff --git a/openai/kimi-agent/server.js b/openai/kimi-agent/server.js new file mode 100644 index 0000000..c358d47 --- /dev/null +++ b/openai/kimi-agent/server.js @@ -0,0 +1,129 @@ +const http = require('http'); +const { spawn } = require('child_process'); + +const PORT = 8000; +const MODEL_ID = 'kimi-agent'; +const TIMEOUT_MS = 15 * 60 * 1000; + +function lastUserMessage(messages) { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'user') { + const c = messages[i].content; + return Array.isArray(c) ? c.map(p => p.text || '').join('\n') : String(c); + } + } + return ''; +} + +function runKimi(prompt) { + return new Promise((resolve, reject) => { + const child = spawn( + 'kimi', + ['-p', prompt, '--output-format', 'text'], + { cwd: '/workspace', timeout: TIMEOUT_MS } + ); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', d => { stdout += d; }); + child.stderr.on('data', d => { stderr += d; }); + + child.on('error', reject); + child.on('close', code => { + if (code !== 0 && !stdout.trim()) { + reject(new Error(`kimi exited ${code}: ${stderr.slice(0, 2000)}`)); + } else { + resolve(stdout.trim()); + } + }); + }); +} + +function chatCompletion(text) { + return { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: MODEL_ID, + choices: [{ + index: 0, + message: { role: 'assistant', content: text }, + finish_reason: 'stop', + }], + }; +} + +const server = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/v1/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + object: 'list', + data: [{ id: MODEL_ID, object: 'model', owned_by: 'moonshot' }], + })); + return; + } + + if (req.method === 'POST' && req.url === '/v1/chat/completions') { + let body = ''; + req.on('data', d => { body += d; }); + req.on('end', async () => { + let parsed; + try { + parsed = JSON.parse(body); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'invalid JSON body' })); + return; + } + + const prompt = lastUserMessage(parsed.messages || []); + if (!prompt) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'no user message found' })); + return; + } + + try { + const text = await runKimi(prompt); + + if (parsed.stream) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + const chunk = { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model: MODEL_ID, + choices: [{ index: 0, delta: { role: 'assistant', content: text }, finish_reason: null }], + }; + res.write(`data: ${JSON.stringify(chunk)}\n\n`); + const doneChunk = { + id: chunk.id, + object: 'chat.completion.chunk', + created: chunk.created, + model: MODEL_ID, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + }; + res.write(`data: ${JSON.stringify(doneChunk)}\n\n`); + res.write('data: [DONE]\n\n'); + res.end(); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(chatCompletion(text))); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: String(err.message || err) })); + } + }); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); +}); + +server.listen(PORT, () => console.log(`kimi-agent wrapper listening on :${PORT}`)); diff --git a/openai/litellm-config.yaml b/openai/litellm-config.yaml index d945576..17b5f31 100644 --- a/openai/litellm-config.yaml +++ b/openai/litellm-config.yaml @@ -15,6 +15,13 @@ model_list: model: anthropic/claude-haiku-4-5-20251001 api_key: os.environ/ANTHROPIC_API_KEY + # Kimi Code CLI agent (own container, own Moonshot/Kimi subscription via `kimi login`) + - model_name: kimi-agent + litellm_params: + model: openai/kimi-agent + api_base: http://kimi-agent:8000/v1 + api_key: dummy + # ── raw model exposure ───────────────────────────────────────────────── - model_name: ollama/qwen3.5:4b litellm_params: