/** * hal/ai/providers.mjs — 모델 공급자 어댑터(0.3.0). 엔진(hal/ai/engine.mjs)은 공급자와 무관한 블록 형식으로 말하고, 여기서 공급자별 HTTP 형식으로 바꾼다. * * 공급자 * - anthropic : Messages API(POST {base}/messages, GET {base}/models). API 키는 `x-api-key`, OAuth 는 `Authorization: Bearer`. * - openai : OpenAI 호환 Chat Completions(POST {base}/chat/completions, GET {base}/models). base_url 을 바꾸면 호환 공급자도 쓴다. 키·토큰은 Bearer. * - local-cli : 서버에 설치된 Claude Code CLI 의 **로그인 세션**으로 `claude -p … --output-format json` 을 실행한다(Webmaker 의 lib/claude.js 와 같은 계열). * CLI 에는 HAL 도구를 넘기지 않는다(대화·분석 전용). 세션 파일·자격은 HAL 이 읽지도 전달하지도 않는다. 환경 변수는 PATH·HOME 등 최소만 넘긴다(Vault 키 등 HAL 환경 비노출). * * 공통 블록 형식 * messages: [{ role:'user'|'assistant', content:[ {type:'text',text} | {type:'tool_use',id,name,input} | {type:'tool_result',toolUseId,name,ok,output} ] }] * 응답: { blocks:[text|tool_use], usage:{inputTokens,outputTokens}, stopReason, model } * * 비밀값은 인자(secret)로만 받아 요청 헤더에만 넣는다. 오류 메시지에 헤더·키를 넣지 않는다. */ import { spawn, execFile } from 'node:child_process'; import { accessSync, constants } from 'node:fs'; export class ProviderError extends Error { constructor(code, message, { status = 502, detail } = {}) { super(message); this.code = code; this.status = status; this.detail = detail; } } export const PROVIDERS = { anthropic: { label: 'Anthropic(Claude)', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.anthropic.com/v1', tools: true }, openai: { label: 'OpenAI 호환', authKinds: ['api_key', 'oauth'], defaultBase: 'https://api.openai.com/v1', tools: true }, 'local-cli': { label: '로컬 CLI 로그인 세션(Claude Code)', authKinds: ['login'], defaultBase: null, tools: false }, }; const base = (conn) => String(conn.baseUrl || PROVIDERS[conn.provider].defaultBase || '').replace(/\/+$/, ''); const fetchOf = (d) => d.fetch || fetch; async function readJson(r) { try { return await r.json(); } catch (_) { return null; } } function authHeaders(conn, secret) { if (!secret) throw new ProviderError('no_credential', '연결에 자격(API 키 또는 OAuth 토큰)이 없습니다', { status: 409 }); if (conn.provider === 'anthropic') return secret.kind === 'bearer' ? { Authorization: `Bearer ${secret.value}`, 'anthropic-version': '2023-06-01' } : { 'x-api-key': secret.value, 'anthropic-version': '2023-06-01' }; return { Authorization: `Bearer ${secret.value}` }; } function providerFail(status, j) { const e = j && j.error ? (typeof j.error === 'string' ? j.error : `${j.error.type || j.error.code || ''}: ${j.error.message || ''}`) : ''; return new ProviderError(`provider_${status}`, `공급자 오류 ${status}${e ? ' ' + e : ''}`.trim(), { status: status === 401 || status === 403 ? 502 : 502 }); } /** 사용 가능한 모델 목록(실제 공급자 응답). local-cli 는 실행 파일 확인으로 대신한다. */ export async function listModels(d, conn, secret) { if (conn.provider === 'local-cli') { const info = await cliInfo(conn); return { models: [conn.model].filter(Boolean), cliVersion: info.version }; } const r = await fetchOf(d)(`${base(conn)}/models`, { method: 'GET', headers: { ...authHeaders(conn, secret), 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(20000) }); const j = await readJson(r); if (!r.ok) throw providerFail(r.status, j); const models = ((j && j.data) || []).map((m) => m.id).filter(Boolean); return { models }; } /** 대화 1회 호출. tools 는 공급자 형식으로 변환된 선언(엔진이 toolDefs(format) 으로 만든다). */ export async function chat(d, conn, secret, { system, messages, tools = [], maxTokens = 4096 }) { if (conn.provider === 'anthropic') return chatAnthropic(d, conn, secret, { system, messages, tools, maxTokens }); if (conn.provider === 'openai') return chatOpenai(d, conn, secret, { system, messages, tools, maxTokens }); if (conn.provider === 'local-cli') return chatCli(conn, { system, messages }); throw new ProviderError('bad_provider', `알 수 없는 공급자: ${conn.provider}`, { status: 400 }); } // ---------------------------------------------------------------- Anthropic function toAnthropic(messages) { const out = []; for (const m of messages) { const content = m.content.map((b) => b.type === 'text' ? { type: 'text', text: b.text } : b.type === 'tool_use' ? { type: 'tool_use', id: b.id, name: b.name, input: b.input || {} } : { type: 'tool_result', tool_use_id: b.toolUseId, content: String(b.output ?? ''), is_error: b.ok === false }).filter((b) => b.type !== 'text' || b.text); if (!content.length) continue; const role = m.content.some((b) => b.type === 'tool_result') ? 'user' : m.role; const last = out[out.length - 1]; if (last && last.role === role) last.content.push(...content); else out.push({ role, content }); // 같은 역할이 이어지면 합친다(교대 규칙) } if (out.length && out[0].role !== 'user') out.unshift({ role: 'user', content: [{ type: 'text', text: '(대화 시작)' }] }); return out; } async function chatAnthropic(d, conn, secret, { system, messages, tools, maxTokens }) { const body = { model: conn.model, max_tokens: maxTokens, system, messages: toAnthropic(messages) }; if (tools.length) body.tools = tools; const r = await fetchOf(d)(`${base(conn)}/messages`, { method: 'POST', headers: { ...authHeaders(conn, secret), 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(180000) }); const j = await readJson(r); if (!r.ok) throw providerFail(r.status, j); if (!j || !Array.isArray(j.content)) throw new ProviderError('bad_response', '공급자 응답 형식이 올바르지 않습니다'); const blocks = j.content.map((b) => b.type === 'text' ? { type: 'text', text: b.text || '' } : b.type === 'tool_use' ? { type: 'tool_use', id: b.id, name: b.name, input: b.input || {} } : null).filter(Boolean); return { blocks, usage: { inputTokens: j.usage?.input_tokens ?? null, outputTokens: j.usage?.output_tokens ?? null }, stopReason: j.stop_reason || null, model: j.model || conn.model }; } // ---------------------------------------------------------------- OpenAI 호환 function toOpenai(system, messages) { const out = [{ role: 'system', content: system }]; for (const m of messages) { const tr = m.content.filter((b) => b.type === 'tool_result'); for (const b of tr) out.push({ role: 'tool', tool_call_id: b.toolUseId, content: String(b.output ?? '') }); const text = m.content.filter((b) => b.type === 'text').map((b) => b.text).join('\n'); const calls = m.content.filter((b) => b.type === 'tool_use'); if (m.role === 'assistant') { if (text || calls.length) out.push({ role: 'assistant', content: text || null, ...(calls.length ? { tool_calls: calls.map((c) => ({ id: c.id, type: 'function', function: { name: c.name, arguments: JSON.stringify(c.input || {}) } })) } : {}) }); } else if (text) out.push({ role: 'user', content: text }); } return out; } async function chatOpenai(d, conn, secret, { system, messages, tools, maxTokens }) { const body = { model: conn.model, max_tokens: maxTokens, messages: toOpenai(system, messages) }; if (tools.length) body.tools = tools; const r = await fetchOf(d)(`${base(conn)}/chat/completions`, { method: 'POST', headers: { ...authHeaders(conn, secret), 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(180000) }); const j = await readJson(r); if (!r.ok) throw providerFail(r.status, j); const msg = j && j.choices && j.choices[0] && j.choices[0].message; if (!msg) throw new ProviderError('bad_response', '공급자 응답 형식이 올바르지 않습니다'); const blocks = []; if (msg.content) blocks.push({ type: 'text', text: String(msg.content) }); for (const c of msg.tool_calls || []) { let input = {}; try { input = c.function?.arguments ? JSON.parse(c.function.arguments) : {}; } catch (_) { input = { __parse_error: String(c.function?.arguments || '').slice(0, 200) }; } blocks.push({ type: 'tool_use', id: c.id, name: c.function?.name, input }); } return { blocks, usage: { inputTokens: j.usage?.prompt_tokens ?? null, outputTokens: j.usage?.completion_tokens ?? null }, stopReason: j.choices[0].finish_reason || null, model: j.model || conn.model }; } // ---------------------------------------------------------------- 로컬 CLI(로그인 세션) /** HAL 프로세스 환경을 CLI 에 넘기지 않는다(ARIWEB_VAULT_KEY 등). 로그인 세션은 CLI 가 HOME 아래 자기 설정에서 스스로 읽는다. */ const cliEnv = () => { const e = {}; for (const k of ['PATH', 'HOME', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR']) if (process.env[k]) e[k] = process.env[k]; return e; }; const cliBin = (conn) => (conn.cli && conn.cli.bin) || process.env.CLAUDE_BIN || 'claude'; export function cliInfo(conn) { const bin = cliBin(conn); return new Promise((resolve, reject) => { try { accessSync(bin, constants.X_OK); } catch (_) { if (bin.includes('/')) return reject(new ProviderError('cli_unavailable', `CLI 실행 파일을 찾지 못했거나 실행할 수 없습니다: ${bin}`)); } execFile(bin, ['--version'], { env: cliEnv(), timeout: 15000 }, (err, stdout) => { if (err) return reject(new ProviderError('cli_unavailable', `CLI 실행 파일을 실행할 수 없습니다(${bin}): ${err.code || err.message}`)); resolve({ bin, version: String(stdout || '').trim().split('\n')[0] }); }); }); } /** 대화 기록을 CLI 프롬프트 글로 편다(도구 없음). */ function transcriptText(messages) { return messages.map((m) => m.content.filter((b) => b.type === 'text').map((b) => `${m.role === 'assistant' ? '[나]' : '[운영자/참여자]'} ${b.text}`).join('\n')).filter(Boolean).join('\n\n'); } function chatCli(conn, { system, messages }) { const bin = cliBin(conn); const prompt = transcriptText(messages) + '\n\n위 대화의 마지막 발언에 답하라.'; const args = ['-p', prompt, '--output-format', 'json', '--model', conn.model || 'claude-opus-5-5', '--append-system-prompt', system, '--no-session-persistence', ...((conn.cli && Array.isArray(conn.cli.args)) ? conn.cli.args.filter((a) => typeof a === 'string' && a.length < 200) : [])]; return new Promise((resolve, reject) => { let child; try { child = spawn(bin, args, { env: cliEnv(), stdio: ['ignore', 'pipe', 'pipe'], detached: true }); } catch (e) { return reject(new ProviderError('cli_unavailable', `CLI 실행 실패: ${e.message}`)); } let out = ''; let err = ''; const timer = setTimeout(() => { try { process.kill(-child.pid, 'SIGTERM'); } catch (_) {} }, (conn.cli && conn.cli.timeoutMs) || 10 * 60 * 1000); child.stdout.on('data', (c) => { out += c.toString(); if (out.length > 4 * 1024 * 1024) out = out.slice(-4 * 1024 * 1024); }); child.stderr.on('data', (c) => { err += c.toString(); if (err.length > 20000) err = err.slice(-20000); }); child.on('error', (e) => { clearTimeout(timer); reject(new ProviderError('cli_unavailable', `CLI 실행 파일을 실행할 수 없습니다(${bin}): ${e.code || e.message}`)); }); child.on('close', (code) => { clearTimeout(timer); let j = null; const lines = out.trim().split('\n'); for (let i = lines.length - 1; i >= 0 && !j; i--) { try { const x = JSON.parse(lines[i]); if (x && (x.type === 'result' || x.result !== undefined)) j = x; } catch (_) {} } if (code !== 0 && !j) return reject(new ProviderError('cli_failed', `CLI 종료 코드 ${code}: ${err.trim().split('\n').pop() || '출력 없음'}`)); if (!j) return reject(new ProviderError('bad_response', 'CLI 가 JSON 결과를 내지 않았습니다')); resolve({ blocks: [{ type: 'text', text: String(j.result ?? j.text ?? '') }], usage: { inputTokens: j.usage?.input_tokens ?? null, outputTokens: j.usage?.output_tokens ?? null }, stopReason: j.is_error ? 'error' : 'end_turn', model: conn.model, sessionId: j.session_id || null }); }); }); }