feat: OpenAI-compatible /v1/chat/completions endpoint (drop-in for OpenRouter)
This commit is contained in:
107
server.js
107
server.js
@@ -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'));
|
app.listen(8108, () => console.log('sechenov :8108'));
|
||||||
|
|||||||
Reference in New Issue
Block a user