Files
smart-home-tablet/app/api/voice/chat/route.ts
Cosmo 70284c69cf
All checks were successful
Deploy / deploy (push) Successful in 1m28s
fix: fallback without tools on tool_use_failed error
2026-05-01 11:19:26 +00:00

210 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
import { systemPrompt } from '@/lib/voice-prompts'
import { TOOL_SCHEMAS, executeTool } from '@/lib/tools/_registry'
import { cleanForSpeech, stripFillers, isResetCommand } from '@/lib/voice-text'
import {
loadHistory, saveHistory, resetHistory,
HistoryMessage,
} from '@/lib/voice-history'
const MODEL = process.env.GROQ_MODEL || 'llama-3.3-70b-versatile'
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 rateBuckets = new Map<string, { count: number; resetAt: number }>()
function rateLimit(key: string): boolean {
const now = Date.now()
const b = rateBuckets.get(key)
if (!b || b.resetAt <= now) {
rateBuckets.set(key, { count: 1, resetAt: now + 60_000 })
return true
}
if (b.count >= RATE_LIMIT_PER_MINUTE) return false
b.count++
return true
}
let lastSweep = 0
function sweep() {
const now = Date.now()
if (now - lastSweep < 5 * 60_000) return
lastSweep = now
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
}
function emitVoice(event: string, agent: 'cosmo' | 'lusya', text?: string) {
voiceBus.emit('voice', {
event,
agent,
text,
timestamp: new Date().toISOString(),
})
}
type AgentId = 'cosmo' | 'lusya'
export async function POST(req: Request) {
const cookie = req.headers.get('cookie') || ''
const tokenMatch = cookie.match(/auth_token=([a-f0-9]{32,})/i)
const internal = req.headers.get('x-voice-internal') || ''
const fwd = req.headers.get('x-forwarded-for') || ''
const ratekey = tokenMatch?.[1] || (internal ? 'internal' : '') || fwd.split(',')[0].trim() || 'anon'
sweep()
if (!rateLimit(ratekey)) {
return NextResponse.json({ error: 'rate_limited' }, { status: 429 })
}
const body = await req.json().catch(() => null)
if (!body || typeof body.text !== 'string' || !body.text.trim()) {
return NextResponse.json({ error: 'text required' }, { status: 400 })
}
const userText: string = body.text.trim().slice(0, 4000)
const agent: AgentId = body.agent === 'lusya' ? 'lusya' : 'cosmo'
emitVoice('command', agent, userText)
if (isResetCommand(userText)) {
await resetHistory(agent)
const msg = 'Начинаю новую сессию.'
emitVoice('response', agent, msg)
return NextResponse.json({ text: msg, reset: true })
}
// Загружаем историю и строим messages для Groq (OpenAI-compatible format)
const history = await loadHistory(agent)
// Системный prompt + история + новый user message
const apiMessages: any[] = [
{ role: 'system', content: systemPrompt(agent) },
...history,
{ role: 'user', content: userText },
]
let finalText = ''
const historyStartLen = apiMessages.length // позиция после которой добавляем новые turns
// Защита от tool-cycling
let lastToolSig = ''
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
console.log(
`[voice/chat] ${agent} round ${round + 1} ${Date.now() - t0}ms · ` +
`stop=${choice.finish_reason} · in=${usage?.prompt_tokens} out=${usage?.completion_tokens}`
)
// Добавляем assistant message в messages
apiMessages.push(msg)
if (choice.finish_reason === 'tool_calls' && msg.tool_calls?.length) {
const toolCalls = msg.tool_calls
// 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
// Выполняем все 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,
content: JSON.stringify(result),
})
}
continue
}
// Финальный ответ
finalText = msg.content || ''
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 })
}
}
if (!finalText.trim()) {
const msg = 'Не получил ответ.'
emitVoice('error', agent, msg)
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)),
]
await saveHistory(agent, updatedHistory)
const cleaned = cleanForSpeech(stripFillers(finalText))
emitVoice('response', agent, cleaned)
return NextResponse.json({ text: cleaned })
}