Files

160 lines
19 KiB
JavaScript

'use strict';
// AI 제공자 카탈로그, 모델 목록 조회, API 키 암호화 저장, 관리자 제공자 설정, 사용자별 연결(자기 키) 저장소.
// 모델 참조 형식: "<provider>:<model>" 관리자 키로 쓰는 공용 모델
// "user:<connectionId>:<model>" 사용자가 연결한 자기 모델
// provider: anthropic(Claude Code CLI) | openai | gemini | groq | openrouter | custom(OpenAI 호환 URL)
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const settings = require('./settings');
const codex = require('./engine-codex');
const DATA_ROOT = process.env.DATA_ROOT || path.join(__dirname, '..', 'data');
const CONN_FILE = path.join(DATA_ROOT, 'user-connections.json');
const PROVIDERS = {
anthropic: { label: 'Anthropic Claude', kind: 'claude-cli', keyHint: 'sk-ant-api03-… 또는 claude setup-token 으로 만든 OAuth 토큰', modelsUrl: null,
models: settings.MODELS.map((m) => ({ id: m.id, label: m.label, input: m.input, output: m.output })) },
openai: { label: 'OpenAI (ChatGPT)', kind: 'openai', baseUrl: 'https://api.openai.com/v1', keyHint: 'sk-…', modelFilter: /^(gpt-|o[1-9]|chatgpt-)/,
prices: { 'gpt-5.2': [1.75, 14], 'gpt-5.2-mini': [0.4, 1.6], 'gpt-5.1': [1.25, 10], 'gpt-5': [1.25, 10], 'gpt-5-mini': [0.25, 2], 'gpt-5-nano': [0.05, 0.4], 'gpt-4.1': [2, 8], 'gpt-4.1-mini': [0.4, 1.6], 'gpt-4o': [2.5, 10], 'gpt-4o-mini': [0.15, 0.6], 'o3': [2, 8], 'o4-mini': [1.1, 4.4] } },
gemini: { label: 'Google Gemini', kind: 'openai', baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', keyHint: 'AIza…', modelFilter: /^gemini-/,
prices: { 'gemini-2.5-pro': [1.25, 10], 'gemini-2.5-flash': [0.3, 2.5], 'gemini-2.5-flash-lite': [0.1, 0.4], 'gemini-3-pro-preview': [2, 12], 'gemini-3-flash-preview': [0.5, 3] } },
groq: { label: 'Groq', kind: 'openai', baseUrl: 'https://api.groq.com/openai/v1', keyHint: 'gsk_…', modelFilter: /^(llama|meta-llama|openai\/gpt-oss|qwen|moonshotai|deepseek|mixtral|gemma)/,
prices: { 'llama-3.3-70b-versatile': [0.59, 0.79], 'llama-3.1-8b-instant': [0.05, 0.08], 'openai/gpt-oss-120b': [0.15, 0.75], 'openai/gpt-oss-20b': [0.1, 0.5], 'moonshotai/kimi-k2-instruct': [1, 3], 'qwen/qwen3-32b': [0.29, 0.59] } },
openrouter: { label: 'OpenRouter', kind: 'openai', baseUrl: 'https://openrouter.ai/api/v1', keyHint: 'sk-or-v1-…', modelFilter: null, prices: {} },
custom: { label: '기타 OpenAI 호환 서버', kind: 'openai', baseUrl: '', keyHint: 'API 키(없으면 비움)', modelFilter: null, prices: {} },
};
const PROVIDER_IDS = Object.keys(PROVIDERS);
// ---------- 암호화 (data/session.secret 파생 키, AES-256-GCM) ----------
function secretKey() { const s = fs.readFileSync(path.join(DATA_ROOT, 'session.secret'), 'utf8').trim(); return crypto.createHash('sha256').update('provider-keys:' + s).digest(); }
function enc(text) { if (!text) return ''; const iv = crypto.randomBytes(12); const c = crypto.createCipheriv('aes-256-gcm', secretKey(), iv); const out = Buffer.concat([c.update(String(text), 'utf8'), c.final()]); return 'enc:' + Buffer.concat([iv, c.getAuthTag(), out]).toString('base64'); }
function dec(blob) { if (!blob) return ''; if (!String(blob).startsWith('enc:')) return String(blob); const b = Buffer.from(String(blob).slice(4), 'base64'); const d = crypto.createDecipheriv('aes-256-gcm', secretKey(), b.subarray(0, 12)); d.setAuthTag(b.subarray(12, 28)); return Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8'); }
const mask = (k) => (k ? `${String(k).slice(0, 7)}…${String(k).slice(-4)}` : '');
// ---------- 관리자 제공자 설정 (settings.json 의 providers 항목) ----------
function adminProviders() { const s = settings.load(); return s.providers || {}; }
function saveAdminProvider(id, patch) {
if (!PROVIDERS[id]) throw Object.assign(new Error('알 수 없는 제공자'), { status: 400 });
const s = settings.load(); s.providers = s.providers || {}; const p = s.providers[id] || { enabled: false, apiKey: '', baseUrl: '', models: [], prices: {} };
if (typeof patch.apiKey === 'string' && patch.apiKey !== '••••') p.apiKey = patch.apiKey.trim() ? enc(patch.apiKey.trim()) : '';
if (typeof patch.baseUrl === 'string') p.baseUrl = patch.baseUrl.trim();
if (patch.enabled !== undefined) p.enabled = !!patch.enabled;
if (id === 'openai' && ['api-key', 'server-login'].includes(patch.authMode)) p.authMode = patch.authMode;
if (Array.isArray(patch.models)) p.models = patch.models.map((m) => (typeof m === 'string' ? { id: m } : { id: m.id, label: m.label || m.id, input: +m.input || 0, output: +m.output || 0 })).filter((m) => m.id);
if (patch.prices && typeof patch.prices === 'object') p.prices = { ...(p.prices || {}), ...patch.prices };
s.providers[id] = p; settings.save({}); // updatedAt 갱신 및 저장
return publicAdminProvider(id);
}
function publicAdminProvider(id) { const p = adminProviders()[id] || {}; const key = p.apiKey ? dec(p.apiKey) : ''; const out = { id, label: PROVIDERS[id].label, kind: PROVIDERS[id].kind, keyHint: PROVIDERS[id].keyHint, enabled: !!p.enabled, hasKey: !!key, apiKeyMasked: mask(key), baseUrl: p.baseUrl || PROVIDERS[id].baseUrl || '', models: p.models || [], lastTest: p.lastTest || null, builtinModels: PROVIDERS[id].models || null, authMode: p.authMode || 'api-key' }; if (id === 'openai') out.codex = codex.codexInfo(); return out; }
/**
* v1.6.38 (요구 1): 이 제공자의 모델이 지금 "설정 탭 › 모델 등급" 목록에 들어가는지와 빠진 이유.
* 1.6.35 까지는 저장 응답이 "반영했습니다" 라고만 해서, 키 없이 저장한 제공자(모델 조회는 키 없이도 되는 OpenRouter 등)가 등급 목록에 안 보이는 이유를 알 수 없었다.
* @returns {{usable:boolean, reason:string|null, models:number}}
*/
function usability(pid) {
if (pid === 'anthropic') { const n = availableModels(null).filter((m) => m.provider === 'anthropic').length; return { usable: n > 0, reason: n ? null : 'Claude 인증(서버 로그인·OAuth 토큰·API 키)이 없습니다', models: n }; }
const p = adminProviders()[pid];
if (!p || !p.enabled) return { usable: false, reason: '제공자가 꺼져 있습니다 ("사용" 을 켜고 저장)', models: 0 };
const miss = unavailableProviders().find((x) => x.id === pid);
if (miss) return { usable: false, reason: miss.reason, models: 0 };
const n = availableModels(null).filter((m) => m.provider === pid).length;
return { usable: n > 0, reason: n ? null : '제공할 모델을 고르지 않았습니다', models: n };
}
function listAdminProviders() { return PROVIDER_IDS.map((pid) => ({ ...publicAdminProvider(pid), ...(() => { const u = usability(pid); return { usable: u.usable, unavailableReason: u.reason, usableModels: u.models }; })() })); }
function recordTest(id, result) { const s = settings.load(); s.providers = s.providers || {}; s.providers[id] = s.providers[id] || {}; s.providers[id].lastTest = { ...result, at: new Date().toISOString() }; settings.save({}); }
// ---------- 모델 목록 조회 (OpenAI 호환) ----------
async function fetchModels(providerId, apiKey, baseUrl) {
const P = PROVIDERS[providerId]; if (!P) throw new Error('알 수 없는 제공자');
if (P.kind === 'claude-cli') return P.models.map((m) => ({ id: m.id, label: m.label, input: m.input, output: m.output }));
if (providerId === 'openai' && (adminProviders().openai || {}).authMode === 'server-login' && !apiKey) { const info = codex.codexInfo(); if (!info.bin) throw new Error('서버에 codex CLI 가 없습니다 (npm i -g @openai/codex)'); if (!info.loggedIn) throw new Error('서버에서 codex 로그인이 되어 있지 않습니다 (서버에서 codex login --device-auth 실행)'); return await codex.serverLoginModels(true); }
const base = (baseUrl || P.baseUrl || '').replace(/\/$/, ''); if (!base) throw new Error('기본 URL 이 필요합니다');
const headers = { Authorization: `Bearer ${apiKey || ''}` }; if (providerId === 'openrouter') { headers['HTTP-Referer'] = 'https://webmaker.ibit.kr'; headers['X-Title'] = 'WebMaker'; }
const ac = new AbortController(); const tm = setTimeout(() => ac.abort(), 20000);
let res; try { res = await fetch(`${base}/models`, { headers, signal: ac.signal }); } catch (e) { clearTimeout(tm); throw new Error('연결 실패: ' + e.message); } clearTimeout(tm);
if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(`모델 목록 조회 실패 (HTTP ${res.status}): ${t.slice(0, 200)}`); }
const j = await res.json(); const arr = Array.isArray(j.data) ? j.data : (Array.isArray(j.models) ? j.models : []);
const out = [];
for (const m of arr) {
let id = String(m.id || m.name || ''); if (!id) continue; id = id.replace(/^models\//, '');
if (P.modelFilter && !P.modelFilter.test(id)) continue;
const rec = { id, label: m.name && m.name !== id && !/^models\//.test(m.name) ? `${m.name} (${id})` : id, input: 0, output: 0 };
if (providerId === 'openrouter' && m.pricing) { rec.input = Math.round(parseFloat(m.pricing.prompt || 0) * 1e6 * 1000) / 1000; rec.output = Math.round(parseFloat(m.pricing.completion || 0) * 1e6 * 1000) / 1000; rec.context = m.context_length; }
else if (P.prices && P.prices[id]) { [rec.input, rec.output] = P.prices[id]; }
out.push(rec);
}
return out.sort((a, b) => a.id.localeCompare(b.id));
}
// ---------- 사용자 연결 저장소 ----------
function loadConns() { try { return JSON.parse(fs.readFileSync(CONN_FILE, 'utf8')); } catch (_) { return {}; } }
function saveConns(c) { const tmp = CONN_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(c, null, 1), { mode: 0o600 }); fs.renameSync(tmp, CONN_FILE); try { fs.chmodSync(CONN_FILE, 0o600); } catch (_) {} }
function publicConn(c) { return { id: c.id, provider: c.provider, providerLabel: (PROVIDERS[c.provider] || {}).label, label: c.label, authType: c.authType, keyMasked: mask(dec(c.secret)), baseUrl: c.baseUrl || '', models: c.models || [], addedAt: c.addedAt, lastTestedAt: c.lastTestedAt || null, lastUsedAt: c.lastUsedAt || null }; }
function listUserConns(userId) { return (loadConns()[userId] || []).map(publicConn); }
function addUserConn(userId, input) {
const provider = input.provider; if (!PROVIDERS[provider]) throw Object.assign(new Error('알 수 없는 제공자'), { status: 400 });
const secret = String(input.secret || input.apiKey || '').trim(); if (!secret && provider !== 'custom') throw Object.assign(new Error('API 키(또는 토큰)를 입력하세요'), { status: 400 });
const authType = provider === 'anthropic' ? (/^sk-ant-/.test(secret) ? 'api-key' : 'oauth-token') : 'api-key';
const all = loadConns(); const list = all[userId] || [];
const c = { id: crypto.randomBytes(6).toString('hex'), provider, label: String(input.label || PROVIDERS[provider].label).slice(0, 60), authType, secret: enc(secret), baseUrl: provider === 'custom' ? String(input.baseUrl || '').trim() : '', models: Array.isArray(input.models) ? input.models.slice(0, 200) : [], addedAt: new Date().toISOString(), lastTestedAt: input.tested ? new Date().toISOString() : null };
list.push(c); all[userId] = list.slice(-20); saveConns(all); return publicConn(c);
}
function updateUserConn(userId, connId, patch) { const all = loadConns(); const c = (all[userId] || []).find((x) => x.id === connId); if (!c) throw Object.assign(new Error('연결이 없습니다'), { status: 404 }); if (Array.isArray(patch.models)) c.models = patch.models.slice(0, 200); if (typeof patch.label === 'string') c.label = patch.label.slice(0, 60); if (patch.lastTestedAt) c.lastTestedAt = patch.lastTestedAt; if (patch.lastUsedAt) c.lastUsedAt = patch.lastUsedAt; saveConns(all); return publicConn(c); }
function removeUserConn(userId, connId) { const all = loadConns(); all[userId] = (all[userId] || []).filter((x) => x.id !== connId); saveConns(all); return listUserConns(userId); }
function getUserConnSecret(userId, connId) { const c = (loadConns()[userId] || []).find((x) => x.id === connId); return c ? { ...c, secret: dec(c.secret) } : null; }
// ---------- 사용 가능한 모델 ----------
/** 관리자가 켠 제공자의 모델 + 사용자의 연결 모델. 각 항목 {ref, provider, model, label, source:'admin'|'user', input, output} */
function availableModels(userId) {
const out = [];
// Anthropic(Claude Code CLI) 는 서버 로그인/OAuth/API 키 중 하나가 있으면 사용 가능
const s = settings.load(); const claudeReady = s.auth.mode === 'server-login' || (s.auth.mode === 'oauth-token' && settings.authSecret('oauthToken')) || (s.auth.mode === 'api-key' && settings.authSecret('apiKey'));
// v1.4.14 (요구 6): providerLabel·auth 를 함께 돌려준다 — 관리자 콘솔의 등급 지정 상자가 제공자별로 묶어 보이고, 왜 빠졌는지 알 수 있게
// v1.5.16 (요구 3): AI 연결 탭에서 "사용" 으로 켠 Claude 모델만 (settings.claudeModels, 비어 있으면 전체)
if (claudeReady) for (const m of settings.claudeModels()) out.push({ ref: `anthropic:${m.id}`, provider: 'anthropic', providerLabel: PROVIDERS.anthropic.label, model: m.id, label: m.label, source: 'admin', input: m.input, output: m.output });
const ap = adminProviders();
for (const id of PROVIDER_IDS) { const p = ap[id]; if (!p || !p.enabled || id === 'anthropic') continue; const serverLogin = id === 'openai' && p.authMode === 'server-login'; if (serverLogin && !codex.codexInfo().loggedIn) continue; if (!p.apiKey && id !== 'custom' && !serverLogin) continue; for (const m of p.models || []) out.push({ ref: `${id}:${m.id}`, provider: id, providerLabel: PROVIDERS[id].label + (serverLogin ? ' (서버 로그인)' : ''), model: m.id, label: `${m.label || m.id} · ${PROVIDERS[id].label}`, source: 'admin', input: (p.prices && p.prices[m.id] ? p.prices[m.id][0] : m.input) || 0, output: (p.prices && p.prices[m.id] ? p.prices[m.id][1] : m.output) || 0 }); }
if (userId) for (const c of loadConns()[userId] || []) for (const m of c.models || []) out.push({ ref: `user:${c.id}:${m.id || m}`, provider: c.provider, model: m.id || m, label: `${m.label || m.id || m} · 내 연결(${c.label})`, source: 'user', connId: c.id, input: m.input || 0, output: m.output || 0 });
return out;
}
/**
* v1.4.14 (요구 6) 관리자가 켜 둔 제공자가 등급 지정 목록에 들어가지 못한 이유 (화면 안내).
* @returns {Array<{id,label,reason}>} 예: 모델을 하나도 고르지 않음 / API 키 없음 / 서버 로그인 안 됨
*/
function unavailableProviders() {
const out = []; const ap = adminProviders();
for (const id of PROVIDER_IDS) {
const p = ap[id]; if (!p || !p.enabled || id === 'anthropic') continue;
const serverLogin = id === 'openai' && p.authMode === 'server-login';
if (serverLogin && !codex.codexInfo().loggedIn) out.push({ id, label: PROVIDERS[id].label, reason: '서버에서 codex 로그인이 되어 있지 않습니다 (서버에서 codex login --device-auth 실행)' });
else if (!p.apiKey && id !== 'custom' && !serverLogin) out.push({ id, label: PROVIDERS[id].label, reason: 'API 키가 없습니다 (AI 연결 탭 › AI 제공자에서 키를 넣고 저장)' });
else if (!(p.models || []).length) out.push({ id, label: PROVIDERS[id].label, reason: '제공할 모델을 고르지 않았습니다 (AI 연결 탭 › AI 제공자 › 모델 조회 → 체크 → 저장)' });
}
return out;
}
/** 모델 참조를 실행 정보로 변환. {provider, kind, model, apiKey|oauthToken, baseUrl, source, price:{input,output}} */
function resolveModel(ref, userId) {
const s = settings.load();
// v1.5.16 (요구 2): 참조가 없는 호출(= 서비스 내부 호출)은 관리자가 정한 내부 호출 모델. 그 모델의 제공자가 꺼져 쓸 수 없으면 Claude 기본 모델로 대신한다
if (!ref) { const ir = settings.internalRef(); if (ir && ir !== `anthropic:${s.defaultModel}`) { try { return resolveModel(ir, userId); } catch (_) { /* 제공자 꺼짐·모델 없음 → 아래 Claude 기본값 */ } } ref = `anthropic:${s.defaultModel}`; }
if (ref.startsWith('user:')) {
const [, connId, ...rest] = ref.split(':'); const model = rest.join(':'); const c = getUserConnSecret(userId, connId);
if (!c) throw Object.assign(new Error('연결된 AI 모델을 찾을 수 없습니다. 내 AI 연결을 확인하세요.'), { status: 400 });
const P = PROVIDERS[c.provider]; const m = (c.models || []).find((x) => (x.id || x) === model) || {};
return { ref, provider: c.provider, kind: P.kind, model, source: 'user', connId, apiKey: c.authType === 'api-key' ? c.secret : '', oauthToken: c.authType === 'oauth-token' ? c.secret : '', baseUrl: c.baseUrl || P.baseUrl, price: { input: m.input || (P.prices && P.prices[model] ? P.prices[model][0] : 0), output: m.output || (P.prices && P.prices[model] ? P.prices[model][1] : 0) } };
}
const i = ref.indexOf(':'); const provider = i > 0 ? ref.slice(0, i) : 'anthropic'; const model = i > 0 ? ref.slice(i + 1) : ref;
const P = PROVIDERS[provider]; if (!P) throw Object.assign(new Error('알 수 없는 제공자: ' + provider), { status: 400 });
if (provider === 'anthropic') { const m = settings.MODELS.find((x) => x.id === model) || {}; return { ref, provider, kind: 'claude-cli', model, source: 'admin', price: { input: m.input || 0, output: m.output || 0 } }; }
const p = adminProviders()[provider]; if (!p || !p.enabled) throw Object.assign(new Error(`${P.label} 제공자가 활성화되어 있지 않습니다`), { status: 400 });
const m = (p.models || []).find((x) => x.id === model) || {};
if (provider === 'openai' && p.authMode === 'server-login') return { ref, provider, kind: 'codex-cli', model, source: 'admin', price: { input: (p.prices && p.prices[model] ? p.prices[model][0] : m.input) || 0, output: (p.prices && p.prices[model] ? p.prices[model][1] : m.output) || 0 } };
return { ref, provider, kind: 'openai', model, source: 'admin', apiKey: dec(p.apiKey), baseUrl: p.baseUrl || P.baseUrl, price: { input: (p.prices && p.prices[model] ? p.prices[model][0] : m.input) || 0, output: (p.prices && p.prices[model] ? p.prices[model][1] : m.output) || 0 } };
}
/** v1.5.16 실제로 쓰일 내부 호출 모델 참조 (제공자가 꺼져 있으면 Claude 기본값으로 대체된 값) */
function internalRef() { try { return resolveModel(null, null).ref; } catch (_) { return `anthropic:${settings.load().defaultModel}`; } }
function providerCatalog() { return PROVIDER_IDS.map((id) => ({ id, label: PROVIDERS[id].label, kind: PROVIDERS[id].kind, keyHint: PROVIDERS[id].keyHint, needsBaseUrl: id === 'custom' })); }
module.exports = { PROVIDERS, PROVIDER_IDS, providerCatalog, listAdminProviders, usability, saveAdminProvider, recordTest, fetchModels, listUserConns, addUserConn, updateUserConn, removeUserConn, getUserConnSecret, availableModels, unavailableProviders, resolveModel, internalRef, enc, dec };