import axios from 'axios'; import { getLLMConfig } from './config.js'; import { line, error } from './log.js'; function isOpenAIGpt55(provider, model) { return provider === 'openai' && /^gpt-5\.5(?:-|$)/i.test(model || ''); } function chatEndpoint(baseURL, provider, model) { const base = baseURL.replace(/\/$/, ''); return isOpenAIGpt55(provider, model) ? `${base}/responses` : `${base}/chat/completions`; } function chatPayload(provider, model, systemPrompt, userContent) { if (isOpenAIGpt55(provider, model)) { return { model, instructions: systemPrompt, input: userContent, temperature: 0.2 }; } return { model, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }], temperature: 0.2 }; } function extractContent(provider, model, data) { if (!isOpenAIGpt55(provider, model)) return data.choices[0].message.content; if (typeof data.output_text === 'string') return data.output_text; const parts = data.output?.flatMap(item => item.content || []) || []; const text = parts .map(part => { if (typeof part.text === 'string') return part.text; if (typeof part.content === 'string') return part.content; return ''; }) .filter(Boolean) .join(''); if (text) return text; return data.choices?.[0]?.message?.content || ''; } function opencodeModelConfig(model) { const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model]; return { providerID, modelID }; } function applyOpenCodeAuth(headers) { const password = process.env.OPENCODE_SERVER_PASSWORD; if (!password) return; const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode'; headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; } function extractOpenCodeContent(data) { const parts = data.parts || data.data?.parts || data.info?.content || data.data?.info?.content || []; return parts .map(part => part.text || part.content || '') .filter(Boolean) .join(''); } async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) { const base = baseURL.replace(/\/$/, ''); const { providerID, modelID } = opencodeModelConfig(model); const session = await axios.post( `${base}/session`, { title: 'AI Code Review', model: { providerID, id: modelID } }, { headers } ); const sessionID = session.data.id || session.data.data?.id; if (!sessionID) throw new Error('OpenCode session 建立失敗:回應中沒有 session id'); const resp = await axios.post( `${base}/session/${sessionID}/message`, { model: { providerID, modelID }, system: systemPrompt, parts: [{ type: 'text', text: userContent }], }, { headers } ); return extractOpenCodeContent(resp.data); } export async function chat(systemPrompt, userContent) { const { provider, apiKeys, baseURL, model } = getLLMConfig(); if (!provider) throw new Error('未設定任何 LLM API Key'); line(`[LLM] provider=${provider} model=${model}`); const headers = { 'Content-Type': 'application/json' }; if (provider === 'claude') headers['anthropic-version'] = '2023-06-01'; const shuffled = [...apiKeys].sort(() => Math.random() - 0.5); for (let i = 0; i < shuffled.length; i++) { if (provider !== 'ollama' && provider !== 'opencode') headers['Authorization'] = `Bearer ${shuffled[i]}`; try { if (provider === 'opencode') { applyOpenCodeAuth(headers); return await chatOpenCode(baseURL, model, systemPrompt, userContent, headers); } const resp = await axios.post( chatEndpoint(baseURL, provider, model), chatPayload(provider, model, systemPrompt, userContent), { headers } ); return extractContent(provider, model, resp.data); } catch (e) { line(`[LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`); } } error('[LLM] 所有 API Key 均失敗,終止流程'); process.exit(1); } export async function chatJSON(systemPrompt, userContent) { const text = await chat(systemPrompt, userContent); try { return JSON.parse(extractJSONText(text)); } catch (e) { line(`[LLM] JSON 解析失敗: ${e.message}`); return []; } } function stripOuterFence(text) { return String(text) .trim() .replace(/^```[a-zA-Z0-9_-]*\n?/, '') .replace(/```$/, '') .trim(); } function extractBalancedJSON(text, startIndex) { const source = String(text); const open = source[startIndex]; const close = open === '{' ? '}' : ']'; let depth = 0; let inString = false; let escaped = false; for (let i = startIndex; i < source.length; i++) { const ch = source[i]; if (inString) { if (escaped) { escaped = false; } else if (ch === '\\') { escaped = true; } else if (ch === '"') { inString = false; } continue; } if (ch === '"') { inString = true; continue; } if (ch === open) depth += 1; else if (ch === close) { depth -= 1; if (depth === 0) return source.slice(startIndex, i + 1); } } return null; } function extractJSONText(text) { const stripped = stripOuterFence(text); try { JSON.parse(stripped); return stripped; } catch {} for (let i = 0; i < stripped.length; i++) { if (stripped[i] !== '[' && stripped[i] !== '{') continue; const candidate = extractBalancedJSON(stripped, i); if (!candidate) continue; try { JSON.parse(candidate); return candidate; } catch {} } return stripped; }