184 lines
21 KiB
JavaScript
184 lines
21 KiB
JavaScript
/**
|
|
* 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 : 서버에 설치된 CLI 의 **로그인 세션** — Claude Code(`claude -p … --output-format json`) 또는 OpenAI Codex(`codex exec --json -s read-only …`). [0.3.1] 설치 위치를 자동 탐색한다(아래).
|
|
* 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, statSync, realpathSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
import { join, basename } from 'node:path';
|
|
import { homedir, tmpdir } from 'node:os';
|
|
|
|
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·Codex)', 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); if (info.loggedIn === false) throw new ProviderError('cli_not_logged_in', `${info.label}(${info.bin})에 로그인되어 있지 않습니다 — 서버(HAL 실행 계정)에서 '${info.loginCommand}' 을 실행하세요. HAL 이 대신 로그인할 수 없습니다`, { status: 502 }); return { models: info.models.length ? info.models : [conn.model].filter(Boolean), cliVersion: info.version, loggedIn: info.loggedIn, cli: info }; }
|
|
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(로그인 세션): claude(Claude Code) · codex(OpenAI Codex CLI)
|
|
/**
|
|
* [0.3.1] 자동 탐색. HAL 서비스(systemd)의 PATH 에는 사용자 설치 위치(~/.local/bin 등)가 없어 0.3.0 은 bear 에 설치된 CLI 를 찾지 못했다.
|
|
* 탐색 순서: 환경 변수(CLAUDE_BIN·CODEX_BIN) → HAL_CLI_DIRS(있으면 이것만) → HOME 의 알려진 설치 위치(~/.local/bin, ~/.npm-global/bin, ~/.claude/local, ~/.codex/packages/standalone/current/bin, ~/node_modules/.bin)
|
|
* → /usr/local/bin·/usr/bin → PATH. HOME 은 HAL 실행 계정(HAL_CLI_HOME 으로 바꿀 수 있다 — 테스트용). 심볼릭 링크(~/.local/bin/claude → ~/.local/share/claude/versions/…)도 실행 가능하면 인정.
|
|
* 로그인 상태: claude 는 `claude auth status`(JSON loggedIn), codex 는 `codex login status`("Logged in …"). 로그인 안 됨은 HAL 이 대신할 수 없다 — 서버에서 그 계정으로 `claude login` / `codex login`.
|
|
*/
|
|
export const CLI_TOOLS = {
|
|
claude: { label: 'Claude Code CLI', defaultModel: 'claude-opus-5-5', loginCommand: 'claude login', envBin: 'CLAUDE_BIN' },
|
|
codex: { label: 'OpenAI Codex CLI', defaultModel: 'gpt-5.5', loginCommand: 'codex login', envBin: 'CODEX_BIN' },
|
|
};
|
|
const cliHome = () => process.env.HAL_CLI_HOME || homedir();
|
|
/** HAL 프로세스 환경을 CLI 에 넘기지 않는다(ARIWEB_VAULT_KEY 등). 로그인 세션은 CLI 가 HOME 아래 자기 설정에서 스스로 읽는다. PATH 에는 탐색 위치를 더해 CLI 가 자기 보조 실행 파일(node 등)을 찾게 한다. */
|
|
const cliEnv = () => { const e = {}; for (const k of ['LANG', 'LC_ALL', 'TERM', 'TMPDIR']) if (process.env[k]) e[k] = process.env[k]; e.HOME = cliHome(); e.PATH = [...new Set([...(process.env.PATH || '').split(':'), ...cliSearchDirs()])].filter(Boolean).join(':'); return e; };
|
|
const isExec = (p) => { try { accessSync(p, constants.X_OK); return statSync(p).isFile(); } catch (_) { return false; } };
|
|
export function cliSearchDirs() {
|
|
const h = cliHome(); const forced = (process.env.HAL_CLI_DIRS || '').split(':').filter(Boolean); if (forced.length) return forced;
|
|
return [...new Set([join(h, '.local', 'bin'), join(h, '.npm-global', 'bin'), join(h, '.claude', 'local'), join(h, '.codex', 'packages', 'standalone', 'current', 'bin'), join(h, 'node_modules', '.bin'), '/usr/local/bin', '/usr/bin', ...(process.env.PATH || '').split(':').filter(Boolean)])];
|
|
}
|
|
export function findCliBin(tool) {
|
|
const t = CLI_TOOLS[tool]; if (!t) return null; const env = process.env[t.envBin]; if (env && isExec(env)) return env;
|
|
for (const d of cliSearchDirs()) { const p = join(d, tool); if (isExec(p)) return p; } return null;
|
|
}
|
|
const runCli = (bin, args, timeout = 20000) => new Promise((resolve) => execFile(bin, args, { env: cliEnv(), timeout, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') })));
|
|
/** 로그인 상태. loggedIn: true/false/null(알 수 없음 — 막지 않는다) */
|
|
export async function cliLoginStatus(tool, bin) {
|
|
if (tool === 'claude') { const r = await runCli(bin, ['auth', 'status']); try { const j = JSON.parse(r.stdout.trim()); return { loggedIn: 'loggedIn' in j ? !!j.loggedIn : null, method: j.authMethod || null }; } catch (_) { return { loggedIn: null, method: null }; } }
|
|
if (tool === 'codex') { const r = await runCli(bin, ['login', 'status']); const t = r.stdout + '\n' + r.stderr; if (/not logged in/i.test(t)) return { loggedIn: false, method: null }; if (/logged in/i.test(t)) return { loggedIn: true, method: /chatgpt/i.test(t) ? 'chatgpt' : /api key/i.test(t) ? 'api_key' : null }; return { loggedIn: null, method: null }; }
|
|
return { loggedIn: null, method: null };
|
|
}
|
|
/** codex 가 로그인 상태로 실행될 때 백엔드 모델 목록을 ~/.codex/models_cache.json 에 둔다(Webmaker engine-codex 와 같은 해석). 숨김(visibility=hide)은 뺀다. */
|
|
export function codexModels() { try { const j = JSON.parse(readFileSync(join(cliHome(), '.codex', 'models_cache.json'), 'utf8')); const ms = Array.isArray(j) ? j : (j.models || []); return ms.filter((m) => m && m.slug && m.visibility !== 'hide').map((m) => m.slug); } catch (_) { return []; } }
|
|
export function codexDefaultModel() { try { const m = readFileSync(join(cliHome(), '.codex', 'config.toml'), 'utf8').match(/^model\s*=\s*"([^"]+)"/m); return m ? m[1] : null; } catch (_) { return null; } }
|
|
export function cliDefaultModel(tool) { if (tool === 'codex') return codexDefaultModel() || codexModels()[0] || CLI_TOOLS.codex.defaultModel; return CLI_TOOLS.claude.defaultModel; }
|
|
/** 연결 설정의 CLI 종류: cli.tool → 실행 파일 이름 → claude */
|
|
export function cliToolOf(conn) { const c = conn.cli || {}; if (c.tool && CLI_TOOLS[c.tool]) return c.tool; if (c.bin) { const b = basename(c.bin); if (CLI_TOOLS[b]) return b; } return 'claude'; }
|
|
export const cliBin = (conn) => (conn.cli && conn.cli.bin) || findCliBin(cliToolOf(conn)) || null;
|
|
/** 서버에서 찾은 CLI 전부(버전·로그인·기본 모델·모델 목록). AI 연동 화면과 /ops/api/ai/cli 가 쓴다. */
|
|
export async function discoverClis() {
|
|
const clis = [];
|
|
for (const tool of Object.keys(CLI_TOOLS)) {
|
|
const bin = findCliBin(tool); if (!bin) continue; const t = CLI_TOOLS[tool];
|
|
const v = await runCli(bin, ['--version'], 15000); const login = await cliLoginStatus(tool, bin); let realpath = bin; try { realpath = realpathSync(bin); } catch (_) {}
|
|
clis.push({ tool, label: t.label, bin, realpath, version: v.err ? null : v.stdout.trim().split('\n')[0], versionError: v.err ? (v.err.code || v.err.message) : null, loggedIn: login.loggedIn, loginMethod: login.method, defaultModel: cliDefaultModel(tool), models: tool === 'codex' ? codexModels() : [], loginHint: login.loggedIn === false ? `서버(HAL 실행 계정)에서 '${t.loginCommand}' 을 실행해 로그인하세요 — HAL 이 대신 로그인할 수 없습니다` : null });
|
|
}
|
|
return { clis, searched: cliSearchDirs(), home: cliHome() };
|
|
}
|
|
export async function cliInfo(conn) {
|
|
const tool = cliToolOf(conn); const t = CLI_TOOLS[tool]; const bin = cliBin(conn);
|
|
if (!bin || !isExec(bin)) throw new ProviderError('cli_unavailable', `${t.label} 실행 파일을 찾지 못했습니다(탐색 위치: ${cliSearchDirs().slice(0, 7).join(', ')}). 서버에 설치하거나 연결 설정에 절대 경로를 적으세요.`);
|
|
const v = await runCli(bin, ['--version'], 15000); if (v.err) throw new ProviderError('cli_unavailable', `CLI 실행 파일을 실행할 수 없습니다(${bin}): ${v.err.code || v.err.message}`);
|
|
const login = await cliLoginStatus(tool, bin);
|
|
return { tool, label: t.label, bin, version: v.stdout.trim().split('\n')[0], loggedIn: login.loggedIn, loginMethod: login.method, models: tool === 'codex' ? codexModels() : [], defaultModel: cliDefaultModel(tool), loginCommand: t.loginCommand };
|
|
}
|
|
/** 대화 기록을 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 spawnCollect(bin, args, { cwd, timeoutMs }) {
|
|
return new Promise((resolve, reject) => {
|
|
let child; try { child = spawn(bin, args, { cwd, 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 (_) {} }, timeoutMs);
|
|
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); resolve({ code, out, err }); });
|
|
});
|
|
}
|
|
function chatCli(conn, { system, messages }) {
|
|
const tool = cliToolOf(conn); const bin = cliBin(conn); if (!bin) return Promise.reject(new ProviderError('cli_unavailable', `${CLI_TOOLS[tool].label} 실행 파일을 찾지 못했습니다 — AI 연동 화면에서 "확인"으로 탐색 결과를 보세요`));
|
|
return tool === 'codex' ? chatCodex(conn, bin, { system, messages }) : chatClaude(conn, bin, { system, messages });
|
|
}
|
|
async function chatClaude(conn, bin, { system, messages }) {
|
|
const prompt = transcriptText(messages) + '\n\n위 대화의 마지막 발언에 답하라.';
|
|
const args = ['-p', prompt, '--output-format', 'json', '--model', conn.model || CLI_TOOLS.claude.defaultModel, '--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) : [])];
|
|
const { code, out, err } = await spawnCollect(bin, args, { cwd: undefined, timeoutMs: (conn.cli && conn.cli.timeoutMs) || 10 * 60 * 1000 });
|
|
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) throw new ProviderError('cli_failed', `CLI 종료 코드 ${code}: ${err.trim().split('\n').pop() || '출력 없음'}`);
|
|
if (!j) throw new ProviderError('bad_response', 'CLI 가 JSON 결과를 내지 않았습니다');
|
|
if (j.is_error) { const msg = String(j.result || '오류'); throw new ProviderError(/authenticate|login|OAuth/i.test(msg) ? 'cli_not_logged_in' : 'cli_failed', `Claude Code CLI 오류: ${msg}${/authenticate|login|OAuth/i.test(msg) ? ` — 서버에서 '${CLI_TOOLS.claude.loginCommand}' 로 다시 로그인하세요` : ''}`); }
|
|
return { blocks: [{ type: 'text', text: String(j.result ?? j.text ?? '') }], usage: { inputTokens: j.usage?.input_tokens ?? null, outputTokens: j.usage?.output_tokens ?? null }, stopReason: 'end_turn', model: conn.model, sessionId: j.session_id || null };
|
|
}
|
|
/** codex exec --json: 읽기 전용 샌드박스·임시 작업 폴더·세션 비보존. JSONL 의 item.completed(agent_message) 가 답, turn.completed.usage 가 사용량. */
|
|
async function chatCodex(conn, bin, { system, messages }) {
|
|
const prompt = `${system}\n\n---\n${transcriptText(messages)}\n\n위 대화의 마지막 발언에 답하라.`; const dir = mkdtempSync(join(tmpdir(), 'hal-codex-'));
|
|
const args = ['exec', '--json', '-s', 'read-only', '--skip-git-repo-check', '--ephemeral', '-C', dir, '-m', conn.model || cliDefaultModel('codex'), prompt, ...((conn.cli && Array.isArray(conn.cli.args)) ? conn.cli.args.filter((a) => typeof a === 'string' && a.length < 200) : [])];
|
|
let res; try { res = await spawnCollect(bin, args, { cwd: dir, timeoutMs: (conn.cli && conn.cli.timeoutMs) || 10 * 60 * 1000 }); } finally { try { rmSync(dir, { recursive: true, force: true }); } catch (_) {} }
|
|
let text = ''; let usage = { inputTokens: null, outputTokens: null }; let errText = '';
|
|
for (const line of res.out.split('\n')) { let ev; try { ev = JSON.parse(line); } catch (_) { continue; } if (!ev || typeof ev !== 'object') continue; if (ev.type === 'item.completed' && ev.item && ev.item.type === 'agent_message' && ev.item.text) text = String(ev.item.text).trim(); else if (ev.type === 'turn.completed' && ev.usage) usage = { inputTokens: ev.usage.input_tokens ?? null, outputTokens: ev.usage.output_tokens ?? null }; else if (ev.type === 'error') errText = String(ev.message || ev.error || JSON.stringify(ev)).slice(0, 300); }
|
|
if (!text) { const tail = errText || res.err.trim().split('\n').pop() || `종료 코드 ${res.code}`; throw new ProviderError(/login|auth|unauthorized|401/i.test(tail) ? 'cli_not_logged_in' : 'cli_failed', `Codex CLI 오류: ${tail}${/login|auth|unauthorized|401/i.test(tail) ? ` — 서버에서 '${CLI_TOOLS.codex.loginCommand}' 을 실행하세요` : ''}`); }
|
|
return { blocks: [{ type: 'text', text }], usage, stopReason: 'end_turn', model: conn.model };
|
|
}
|