Files
AgapHost/openai/kimi-agent/server.js
Alvis 406083310f 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.
2026-07-04 15:20:35 +00:00

130 lines
3.8 KiB
JavaScript

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