From bf6a0bdee72c1741a7a97a2f68fdcafd5cda6227 Mon Sep 17 00:00:00 2001 From: Cosmo Date: Fri, 1 May 2026 12:11:42 +0000 Subject: [PATCH] feat: switch voice to direct Anthropic API via proxy --- app/api/voice/chat/route.ts | 224 +++++++++++++++++++++--------------- 1 file changed, 129 insertions(+), 95 deletions(-) diff --git a/app/api/voice/chat/route.ts b/app/api/voice/chat/route.ts index 9be54b1..a4a7ead 100644 --- a/app/api/voice/chat/route.ts +++ b/app/api/voice/chat/route.ts @@ -2,7 +2,6 @@ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' import { NextResponse } from 'next/server' -import Groq from 'groq-sdk' import { HttpsProxyAgent } from 'https-proxy-agent' import { voiceBus } from '@/lib/voice-bus' @@ -14,12 +13,15 @@ import { HistoryMessage, } from '@/lib/voice-history' -const MODEL = process.env.GROQ_MODEL || 'llama-3.3-70b-versatile' +const MODEL = process.env.ANTHROPIC_MODEL || 'claude-haiku-4-5' const MAX_TOKENS = parseInt(process.env.VOICE_MAX_TOKENS || '300', 10) const MAX_TOOL_ROUNDS = 4 const RATE_LIMIT_PER_MINUTE = parseInt(process.env.VOICE_RATE_LIMIT || '20', 10) -// In-memory rate-limit per IP / cookie (host один — Docker контейнер). +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || '' +const ANTHROPIC_PROXY = process.env.ANTHROPIC_PROXY || '' + +// Rate limit const rateBuckets = new Map() function rateLimit(key: string): boolean { const now = Date.now() @@ -40,28 +42,92 @@ function sweep() { for (const [k, v] of rateBuckets) if (v.resetAt <= now) rateBuckets.delete(k) } -let _client: Groq | null = null -function client(): Groq { - if (_client) return _client - const apiKey = process.env.GROQ_API_KEY - if (!apiKey) throw new Error('GROQ_API_KEY not set') - const proxyUrl = process.env.GROQ_PROXY - const httpAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined - _client = new Groq({ apiKey, httpAgent } as any) - return _client +// Convert OpenAI-style tool schemas to Anthropic format +function toAnthropicTools(tools: any[]): any[] { + return tools.map(t => ({ + name: t.function.name, + description: t.function.description || '', + input_schema: t.function.parameters || { type: 'object', properties: {} }, + })) } -function emitVoice(event: string, agent: 'cosmo' | 'lusya', text?: string) { - voiceBus.emit('voice', { - event, - agent, - text, - timestamp: new Date().toISOString(), - }) +// Convert history to Anthropic messages format +function historyToAnthropicMessages(history: HistoryMessage[]): any[] { + const result: any[] = [] + for (const msg of history) { + if ((msg.role as string) === 'system') continue + if (msg.role === 'user') { + result.push({ role: 'user', content: msg.content || '' }) + } else if (msg.role === 'assistant') { + if (msg.tool_calls && msg.tool_calls.length > 0) { + const content: any[] = [] + if (msg.content) content.push({ type: 'text', text: msg.content }) + for (const tc of msg.tool_calls) { + let input: any = {} + try { input = JSON.parse(tc.function.arguments) } catch {} + content.push({ type: 'tool_use', id: tc.id, name: tc.function.name, input }) + } + result.push({ role: 'assistant', content }) + } else { + result.push({ role: 'assistant', content: msg.content || '' }) + } + } else if (msg.role === 'tool') { + const last = result[result.length - 1] + const toolResultBlock = { + type: 'tool_result', + tool_use_id: msg.tool_call_id, + content: msg.content || '', + } + if (last && last.role === 'user' && Array.isArray(last.content)) { + last.content.push(toolResultBlock) + } else { + result.push({ role: 'user', content: [toolResultBlock] }) + } + } + } + return result +} + +async function claudeRequest(system: string, messages: any[], tools?: any[]): Promise { + const body: any = { + model: MODEL, + max_tokens: MAX_TOKENS, + system, + messages, + } + if (tools && tools.length > 0) { + body.tools = tools + } + + const fetchOptions: any = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': ANTHROPIC_API_KEY, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify(body), + } + + if (ANTHROPIC_PROXY) { + fetchOptions.agent = new HttpsProxyAgent(ANTHROPIC_PROXY) + } + + const res = await fetch('https://api.anthropic.com/v1/messages', fetchOptions) + + if (!res.ok) { + const err = await res.text() + throw new Error(`anthropic_${res.status}: ${err}`) + } + return res.json() } type AgentId = 'cosmo' | 'lusya' +function emitVoice(event: string, agent: AgentId, text?: string) { + voiceBus.emit('voice', { event, agent, text, timestamp: new Date().toISOString() }) +} + export async function POST(req: Request) { const cookie = req.headers.get('cookie') || '' const tokenMatch = cookie.match(/auth_token=([a-f0-9]{32,})/i) @@ -89,104 +155,77 @@ export async function POST(req: Request) { return NextResponse.json({ text: msg, reset: true }) } - // Загружаем историю и строим messages для Groq (OpenAI-compatible format) const history = await loadHistory(agent) + const sysPrompt = systemPrompt(agent) + const anthropicTools = toAnthropicTools(TOOL_SCHEMAS as any[]) - // Системный prompt + история + новый user message - const apiMessages: any[] = [ - { role: 'system', content: systemPrompt(agent) }, - ...history, + const messages: any[] = [ + ...historyToAnthropicMessages(history), { role: 'user', content: userText }, ] let finalText = '' - const historyStartLen = apiMessages.length // позиция после которой добавляем новые turns - - // Защита от tool-cycling - let lastToolSig = '' + const newTurns: HistoryMessage[] = [{ role: 'user', content: userText }] try { - const c = client() for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { const t0 = Date.now() - const resp = await c.chat.completions.create({ - model: MODEL, - max_tokens: MAX_TOKENS, - messages: apiMessages, - tools: TOOL_SCHEMAS as any, - tool_choice: 'auto', - }) - - const choice = resp.choices[0] - const msg = choice.message - const usage = resp.usage as any + const resp = await claudeRequest(sysPrompt, messages, anthropicTools) console.log( `[voice/chat] ${agent} round ${round + 1} ${Date.now() - t0}ms · ` + - `stop=${choice.finish_reason} · in=${usage?.prompt_tokens} out=${usage?.completion_tokens}` + `stop=${resp.stop_reason} · in=${resp.usage?.input_tokens} out=${resp.usage?.output_tokens}` ) - // Добавляем assistant message в messages - apiMessages.push(msg) + const content: any[] = resp.content || [] + const stopReason: string = resp.stop_reason || 'end_turn' - if (choice.finish_reason === 'tool_calls' && msg.tool_calls?.length) { - const toolCalls = msg.tool_calls + if (stopReason === 'tool_use') { + const toolUseBlocks = content.filter((b: any) => b.type === 'tool_use') + const textBlocks = content.filter((b: any) => b.type === 'text') + const partialText = textBlocks.map((b: any) => b.text).join('') - // Loop-guard: сигнатура текущего раунда - const sig = toolCalls - .map((tc: any) => `${tc.function.name}:${tc.function.arguments}`) - .sort() - .join('|') - if (sig === lastToolSig) { - console.warn('[voice/chat] tool cycle detected, breaking loop') - finalText += '\nНе получилось выполнить запрос.' - break - } - lastToolSig = sig + messages.push({ role: 'assistant', content }) + newTurns.push({ + role: 'assistant', + content: partialText || null, + tool_calls: toolUseBlocks.map((b: any) => ({ + id: b.id, + function: { name: b.name, arguments: JSON.stringify(b.input) }, + })), + }) - // Выполняем все tool calls и добавляем результаты - for (const tc of toolCalls) { - console.log(`[voice/chat] tool ${tc.function.name}(${tc.function.arguments.slice(0, 200)})`) - let args: any = {} - try { args = JSON.parse(tc.function.arguments) } catch (_) {} - const result = await executeTool(tc.function.name, args, agent) - apiMessages.push({ - role: 'tool', - tool_call_id: tc.id, + const toolResults: any[] = [] + for (const tb of toolUseBlocks) { + console.log(`[voice/chat] tool ${tb.name}(${JSON.stringify(tb.input).slice(0, 200)})`) + const result = await executeTool(tb.name, tb.input || {}, agent) + toolResults.push({ + type: 'tool_result', + tool_use_id: tb.id, content: JSON.stringify(result), }) + newTurns.push({ + role: 'tool', + content: JSON.stringify(result), + tool_call_id: tb.id, + }) } + + messages.push({ role: 'user', content: toolResults }) continue } - // Финальный ответ - finalText = msg.content || '' + const textBlocks = content.filter((b: any) => b.type === 'text') + finalText = textBlocks.map((b: any) => b.text).join('') + newTurns.push({ role: 'assistant', content: finalText }) break } } catch (e: any) { const errStr = String(e?.message || e) - console.error('[voice/chat] groq error:', errStr) - - // tool_use_failed: модель неправильно сформировала tool call — повторить без tools - if (errStr.includes('tool_use_failed') || errStr.includes('Failed to call a function')) { - try { - const c2 = client() - const fallback = await c2.chat.completions.create({ - model: MODEL, - max_tokens: MAX_TOKENS, - messages: apiMessages.slice(0, historyStartLen + 1), - }) - finalText = fallback.choices[0]?.message?.content || '' - console.log('[voice/chat] tool_use_failed fallback ok') - } catch (e2) { - console.error('[voice/chat] fallback failed:', e2) - finalText = 'Не удалось выполнить запрос.' - } - } else { - const msg = 'Что-то сломалось.' - emitVoice('error', agent, msg) - return NextResponse.json({ error: 'llm_failed', detail: errStr, text: msg }, { status: 502 }) - } + console.error('[voice/chat] claude error:', errStr) + const msg = 'Что-то сломалось.' + emitVoice('error', agent, msg) + return NextResponse.json({ error: 'llm_failed', detail: errStr, text: msg }, { status: 502 }) } if (!finalText.trim()) { @@ -195,12 +234,7 @@ export async function POST(req: Request) { return NextResponse.json({ text: msg }, { status: 200 }) } - // Сохраняем новые turns в историю (без системного prompt'а) - const newTurns = apiMessages.slice(historyStartLen) - const updatedHistory: HistoryMessage[] = [ - ...history, - ...newTurns.map((m: any) => ({ role: m.role, content: m.content ?? null, tool_calls: m.tool_calls, tool_call_id: m.tool_call_id } as HistoryMessage)), - ] + const updatedHistory: HistoryMessage[] = [...history, ...newTurns] await saveHistory(agent, updatedHistory) const cleaned = cleanForSpeech(stripFillers(finalText))