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
|
- langfuse
|
||||||
restart: always
|
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:
|
langfuse-db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: langfuse-db
|
container_name: langfuse-db
|
||||||
@@ -112,3 +120,6 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
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
|
model: anthropic/claude-haiku-4-5-20251001
|
||||||
api_key: os.environ/ANTHROPIC_API_KEY
|
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 ─────────────────────────────────────────────────
|
# ── raw model exposure ─────────────────────────────────────────────────
|
||||||
- model_name: ollama/qwen3.5:4b
|
- model_name: ollama/qwen3.5:4b
|
||||||
litellm_params:
|
litellm_params:
|
||||||
|
|||||||
Reference in New Issue
Block a user