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:
@@ -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:
|
||||
|
||||
11
openai/kimi-agent/Dockerfile
Normal file
11
openai/kimi-agent/Dockerfile
Normal 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
129
openai/kimi-agent/server.js
Normal 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}`));
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user