diff --git a/server.js b/server.js index c9edb0e..a0f686e 100644 --- a/server.js +++ b/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'));