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.
130 lines
3.8 KiB
JavaScript
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}`));
|