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.
This commit is contained in:
Alvis
2026-07-04 15:20:35 +00:00
parent 995d639b60
commit 406083310f
4 changed files with 158 additions and 0 deletions

View File

@@ -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:

View File

@@ -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"]

129
openai/kimi-agent/server.js Normal file
View File

@@ -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}`));

View File

@@ -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: