feat: OpenAI-compatible /v1/chat/completions endpoint (drop-in for OpenRouter)

This commit is contained in:
aedes
2026-04-22 08:44:30 +03:00
parent 40d7c16af0
commit 266ed9acf8

107
server.js
View File

@@ -201,4 +201,111 @@ app.post('/api/chat', express.json({ limit: '50mb' }), async (req, res) => {
}
});
// ══════════════════════════════════════════════════════════════
// OPENAI-COMPATIBLE ENDPOINT (/v1/chat/completions)
// Drop-in replacement for OpenRouter. Map ext/ or any OpenAI
// client here and it'll route through our Claude Max account.
// ══════════════════════════════════════════════════════════════
const CC_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude.";
function mapModel(name) {
if (!name) return MODELS.sonnet;
const n = String(name).toLowerCase();
if (n.includes('haiku')) return MODELS.haiku;
if (n.includes('sonnet')) return MODELS.sonnet;
if (MODELS[n]) return MODELS[n];
return MODELS.sonnet;
}
function translateContent(content) {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return String(content ?? '');
return content.map(b => {
if (!b || typeof b !== 'object') return { type: 'text', text: String(b ?? '') };
if (b.type === 'text') return { type: 'text', text: b.text || '' };
if (b.type === 'image_url') {
const url = b.image_url?.url || '';
const m = url.match(/^data:([^;]+);base64,(.+)$/);
if (m) return { type: 'image', source: { type: 'base64', media_type: m[1], data: m[2] } };
return { type: 'text', text: '[image: remote URL, not supported]' };
}
if (b.type === 'image' && b.source) return b;
return { type: 'text', text: JSON.stringify(b).slice(0, 200) };
});
}
app.post('/v1/chat/completions', express.json({ limit: '50mb' }), async (req, res) => {
const { model, messages = [], max_tokens = 4096 } = req.body || {};
const systemParts = [CC_SYSTEM_PREFIX];
const anthMessages = [];
for (const m of messages) {
if (!m || !m.role) continue;
if (m.role === 'system') {
const s = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
if (s && s.trim()) systemParts.push(s.trim());
} else if (m.role === 'user' || m.role === 'assistant') {
anthMessages.push({ role: m.role, content: translateContent(m.content) });
}
}
if (anthMessages.length === 0) return res.status(400).json({ error: { message: 'messages required' } });
const accessToken = await ensureFreshToken();
if (!accessToken) return res.status(500).json({ error: { message: 'no credentials' } });
const modelId = mapModel(model);
try {
const upstream = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
dispatcher: anthropicAgent,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
'anthropic-version': '2023-06-01',
'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14',
'x-app': 'cli',
'User-Agent': 'claude-cli/2.1.114',
},
body: JSON.stringify({
model: modelId,
max_tokens,
stream: false,
system: systemParts.join('\n\n'),
messages: anthMessages,
}),
});
if (!upstream.ok) {
const err = await upstream.text();
return res.status(upstream.status).json({
error: { message: err.slice(0, 800), type: 'upstream_error', code: upstream.status },
});
}
const data = await upstream.json();
const text = (data.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
res.json({
id: data.id || ('chatcmpl-' + Date.now()),
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model: model || modelId,
choices: [{
index: 0,
message: { role: 'assistant', content: text },
finish_reason: data.stop_reason === 'end_turn' ? 'stop' : (data.stop_reason || 'stop'),
}],
usage: {
prompt_tokens: data.usage?.input_tokens || 0,
completion_tokens: data.usage?.output_tokens || 0,
total_tokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),
},
});
} catch (e) {
res.status(500).json({ error: { message: e.message, type: 'proxy_error' } });
}
});
app.listen(8108, () => console.log('sechenov :8108'));