diff --git a/.env.example b/.env.example index 23e11f6..bf43e02 100644 --- a/.env.example +++ b/.env.example @@ -82,11 +82,13 @@ STATUS_CHANNEL_ID= # Discord channel ID for live status updat # The SDK arbiter (subscription-based, no extra cost) aggregates all perspectives. # MOA_ENABLED=true # MOA_REF_MODELS=kimi,glm # Comma-separated reference model names -# MOA_KIMI_MODEL=kimi-k2.5 # Kimi model +# MOA_KIMI_MODEL=kimi-k2.6 # Kimi model # MOA_KIMI_BASE_URL=https://api.kimi.com/coding # Kimi API endpoint +# MOA_KIMI_API_FORMAT=anthropic # Kimi Code API format # MOA_KIMI_API_KEY=sk-kimi-xxx # Kimi API key -# MOA_GLM_MODEL=glm-4-plus # GLM model -# MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 # GLM API endpoint +# MOA_GLM_MODEL=glm-5.1 # GLM model +# MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/anthropic # GLM API endpoint +# MOA_GLM_API_FORMAT=anthropic # GLM Anthropic-compatible API # MOA_GLM_API_KEY=xxx # GLM API key # --- Advanced --- diff --git a/apps/dashboard/src/MoaSettingsPanel.tsx b/apps/dashboard/src/MoaSettingsPanel.tsx new file mode 100644 index 0000000..9b473e0 --- /dev/null +++ b/apps/dashboard/src/MoaSettingsPanel.tsx @@ -0,0 +1,403 @@ +import { useEffect, useState } from 'react'; + +import { + type MoaModelSettingsSnapshot, + type MoaReferenceStatus, + type MoaSettingsSnapshot, + checkMoaModel, + fetchMoaSettings, + updateMoaSettings, +} from './api'; + +export function MoaSettingsPanel({ + onRestartStack, +}: { + onRestartStack: () => void; +}) { + const [config, setConfig] = useState(null); + const [draft, setDraft] = useState(null); + const [apiKeys, setApiKeys] = useState>({}); + const [busy, setBusy] = useState(false); + const [checking, setChecking] = useState(null); + const [error, setError] = useState(null); + const [savedAt, setSavedAt] = useState(null); + + useEffect(() => { + let cancelled = false; + setBusy(true); + fetchMoaSettings() + .then((next) => { + if (cancelled) return; + setConfig(next); + setDraft(next); + setError(null); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + }) + .finally(() => { + if (!cancelled) setBusy(false); + }); + return () => { + cancelled = true; + }; + }, []); + + function setModel(name: string, patch: Partial) { + setDraft((prev) => + prev + ? { + ...prev, + models: prev.models.map((model) => + model.name === name ? { ...model, ...patch } : model, + ), + } + : prev, + ); + } + + function updateStatus(name: string, status: MoaReferenceStatus) { + const update = (prev: MoaSettingsSnapshot | null) => + prev + ? { + ...prev, + models: prev.models.map((model) => + model.name === name ? { ...model, lastStatus: status } : model, + ), + } + : prev; + setConfig(update); + setDraft(update); + } + + async function save() { + if (!draft) return; + setBusy(true); + setError(null); + try { + const next = await updateMoaSettings({ + enabled: draft.enabled, + models: draft.models.map((model) => ({ + name: model.name, + enabled: model.enabled, + model: model.model, + baseUrl: model.baseUrl, + apiFormat: model.apiFormat, + apiKey: apiKeys[model.name]?.trim() || undefined, + })), + }); + setConfig(next); + setDraft(next); + setApiKeys({}); + setSavedAt(Date.now()); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + async function testModel(name: string) { + setChecking(name); + setError(null); + try { + updateStatus(name, await checkMoaModel(name)); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setChecking(null); + } + } + + const dirty = + draft !== null && + config !== null && + (JSON.stringify(draft) !== JSON.stringify(config) || + Object.values(apiKeys).some((value) => value.trim())); + + return ( +
+

MoA 참조 모델

+ {error ?

{error}

: null} + {!draft ? ( +

+ {busy ? '불러오는 중…' : 'MoA 설정 없음'} +

+ ) : ( + + setApiKeys((prev) => ({ ...prev, [name]: value })) + } + onModelChange={setModel} + onRestartStack={onRestartStack} + onSave={save} + onTest={testModel} + onToggle={() => + setDraft((prev) => + prev ? { ...prev, enabled: !prev.enabled } : prev, + ) + } + savedAt={savedAt} + /> + )} +
+ ); +} + +function MoaSettingsContent({ + apiKeys, + busy, + checking, + dirty, + draft, + onApiKeyChange, + onModelChange, + onRestartStack, + onSave, + onTest, + onToggle, + savedAt, +}: { + apiKeys: Record; + busy: boolean; + checking: string | null; + dirty: boolean; + draft: MoaSettingsSnapshot; + onApiKeyChange: (name: string, value: string) => void; + onModelChange: ( + name: string, + patch: Partial, + ) => void; + onRestartStack: () => void; + onSave: () => Promise; + onTest: (name: string) => Promise; + onToggle: () => void; + savedAt: number | null; +}) { + return ( + <> + +
    + {draft.models.map((model) => ( + + ))} +
+ + + ); +} + +function formatMoaStatus(status: MoaReferenceStatus | null): string { + if (!status) return '연결 테스트 전'; + const at = new Date(status.checkedAt); + const checkedAt = Number.isNaN(at.getTime()) + ? status.checkedAt + : at.toLocaleString('ko-KR'); + if (status.ok) return `정상 · ${checkedAt}`; + return `실패 · ${checkedAt} · ${status.error ?? 'unknown error'}`; +} + +function MoaMasterToggle({ + busy, + enabled, + onToggle, +}: { + busy: boolean; + enabled: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +function MoaSettingsActions({ + busy, + checking, + dirty, + onRestartStack, + onSave, + savedAt, +}: { + busy: boolean; + checking: string | null; + dirty: boolean; + onRestartStack: () => void; + onSave: () => Promise; + savedAt: number | null; +}) { + return ( +
+ + {savedAt && !dirty ? ( + + 저장됨. 적용하려면 스택 재시작 필요. + + ) : null} + +
+ ); +} + +function MoaModelRow({ + apiKeyValue, + busy, + checking, + dirty, + model, + onApiKeyChange, + onModelChange, + onTest, +}: { + apiKeyValue: string; + busy: boolean; + checking: string | null; + dirty: boolean; + model: MoaModelSettingsSnapshot; + onApiKeyChange: (name: string, value: string) => void; + onModelChange: ( + name: string, + patch: Partial, + ) => void; + onTest: (name: string) => Promise; +}) { + return ( +
  • +
    + + onModelChange(model.name, { model: e.target.value })} + placeholder="model" + type="text" + value={model.model} + /> + + onModelChange(model.name, { baseUrl: e.target.value }) + } + placeholder="base URL" + type="text" + value={model.baseUrl} + /> + + onApiKeyChange(model.name, e.target.value)} + placeholder={model.apiKeyConfigured ? 'API key set' : 'new API key'} + type="password" + value={apiKeyValue} + /> + + {formatMoaStatus(model.lastStatus)} + +
    +
    + +
    +
  • + ); +} diff --git a/apps/dashboard/src/SettingsPanel.test.ts b/apps/dashboard/src/SettingsPanel.test.ts index 90be85e..ec7aa2f 100644 --- a/apps/dashboard/src/SettingsPanel.test.ts +++ b/apps/dashboard/src/SettingsPanel.test.ts @@ -28,10 +28,11 @@ describe('SettingsPanel', () => { expect(html).toContain('English'); }); - it('renders model, fast mode, and account controls', () => { + it('renders model, MoA, fast mode, and account controls', () => { const html = renderToStaticMarkup(createElement(SettingsPanel, baseProps)); expect(html).toContain('모델'); + expect(html).toContain('MoA 참조 모델'); expect(html).toContain('패스트 모드'); expect(html).toContain('불러오는 중'); expect(html).toContain('Claude'); diff --git a/apps/dashboard/src/SettingsPanel.tsx b/apps/dashboard/src/SettingsPanel.tsx index ec4c377..978039c 100644 --- a/apps/dashboard/src/SettingsPanel.tsx +++ b/apps/dashboard/src/SettingsPanel.tsx @@ -18,6 +18,7 @@ import { updateModels, } from './api'; import { LOCALES, languageNames, type Locale, type Messages } from './i18n'; +import { MoaSettingsPanel } from './MoaSettingsPanel'; type AccountProvider = 'claude' | 'codex'; @@ -77,6 +78,8 @@ export function SettingsPanel({ + + diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index b119a57..b317573 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -388,6 +388,30 @@ export interface FastModeSnapshot { claude: boolean; } +export interface MoaReferenceStatus { + model: string; + checkedAt: string; + ok: boolean; + error: string | null; + responseLength?: number; +} + +export interface MoaModelSettingsSnapshot { + name: string; + enabled: boolean; + model: string; + baseUrl: string; + apiFormat: 'openai' | 'anthropic'; + apiKeyConfigured: boolean; + lastStatus: MoaReferenceStatus | null; +} + +export interface MoaSettingsSnapshot { + enabled: boolean; + referenceModels: string[]; + models: MoaModelSettingsSnapshot[]; +} + export async function fetchAccounts(): Promise<{ claude: ClaudeAccountSummary[]; codex: CodexAccountSummary[]; @@ -514,6 +538,67 @@ export async function updateFastMode( return (await response.json()) as FastModeSnapshot; } +export async function fetchMoaSettings(): Promise { + return fetchJson('/api/settings/moa'); +} + +export async function updateMoaSettings(input: { + enabled?: boolean; + models?: Array<{ + name: string; + enabled?: boolean; + model?: string; + baseUrl?: string; + apiFormat?: 'openai' | 'anthropic'; + apiKey?: string; + }>; +}): Promise { + const response = await fetch('/api/settings/moa', { + method: 'PUT', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify(input), + }); + if (!response.ok) { + let msg = `update MoA settings failed: ${response.status}`; + try { + const payload = (await response.json()) as { error?: string }; + if (payload.error) msg = payload.error; + } catch { + /* ignore */ + } + throw new Error(msg); + } + return (await response.json()) as MoaSettingsSnapshot; +} + +export async function checkMoaModel(name: string): Promise { + const response = await fetch('/api/settings/moa/check', { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify({ name }), + }); + if (!response.ok) { + let msg = `check MoA model failed: ${response.status}`; + try { + const payload = (await response.json()) as { error?: string }; + if (payload.error) msg = payload.error; + } catch { + /* ignore */ + } + throw new Error(msg); + } + const payload = (await response.json()) as { + status: MoaReferenceStatus; + }; + return payload.status; +} + export async function deleteAccount( provider: 'claude' | 'codex', index: number, diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 11ab659..27b18ef 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -862,6 +862,46 @@ button:disabled { min-width: 0; } +.settings-moa-row { + grid-template-columns: minmax(0, 1fr) auto; +} + +.settings-moa-grid { + display: grid; + grid-template-columns: + 82px minmax(110px, 1fr) minmax(170px, 1.3fr) 104px + minmax(120px, 1fr) minmax(120px, 1fr); + gap: 8px; + align-items: center; + min-width: 0; +} + +.settings-moa-grid input, +.settings-moa-grid select { + min-height: 32px; + min-width: 0; + padding: 0 10px; + border: 1px solid var(--panel-border); + border-radius: 7px; + background: var(--bg-card); + color: var(--bg-ink); + font: inherit; + font-size: 12px; +} + +.settings-moa-name { + display: flex; + gap: 6px; + align-items: center; + min-width: 0; +} + +.settings-moa-grid .settings-account-badge { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .settings-account-tag { font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 11px; diff --git a/docs/configuration.md b/docs/configuration.md index e4c6ae4..3547316 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -93,19 +93,21 @@ GROQ_API_KEY= MOA_ENABLED=true MOA_REF_MODELS=kimi,glm -MOA_KIMI_MODEL=kimi-k2.5 +MOA_KIMI_MODEL=kimi-k2.6 MOA_KIMI_BASE_URL=https://api.kimi.com/coding MOA_KIMI_API_KEY=sk-kimi-xxx MOA_KIMI_API_FORMAT=anthropic -MOA_GLM_MODEL=glm-4-plus -MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 +MOA_GLM_MODEL=glm-5.1 +MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/anthropic MOA_GLM_API_KEY=xxx MOA_GLM_API_FORMAT=anthropic ``` MoA는 arbiter 판정 전에 외부 모델 의견을 수집해 prompt에 주입합니다. -`API_FORMAT` 같은 항목은 `.env.example`의 최소 예시에 없는 운영 확장 키입니다. +대시보드 설정 화면에서 `MOA_ENABLED`, `MOA_REF_MODELS`, 모델명, base URL, +API format, API key 교체와 연결 테스트를 관리할 수 있습니다. 저장 후에는 +스택 재시작이 필요합니다. ## 운영 / 배포 관련 설정 @@ -122,18 +124,18 @@ MAX_CONCURRENT_AGENTS=5 ## 디버깅 경로 -| 항목 | 경로 / 명령 | -| --- | --- | -| DB | `store/messages.db` | -| 서비스 로그 | `journalctl --user -u ejclaw -f` | -| room 로그 | `groups/{folder}/logs/` | -| owner/reviewer 세션 | `data/sessions/{folder}*` | -| owner worktree | `data/workspaces/{folder}/owner` | -| Claude 플랫폼 프롬프트 | `prompts/claude-platform.md` | -| reviewer 프롬프트 | `prompts/claude-paired-room.md` | -| arbiter 프롬프트 | `prompts/arbiter-paired-room.md` | -| Codex 플랫폼 프롬프트 | `prompts/codex-platform.md` | -| 글로벌 메모리 | `groups/global/CLAUDE.md` | +| 항목 | 경로 / 명령 | +| ---------------------- | -------------------------------- | +| DB | `store/messages.db` | +| 서비스 로그 | `journalctl --user -u ejclaw -f` | +| room 로그 | `groups/{folder}/logs/` | +| owner/reviewer 세션 | `data/sessions/{folder}*` | +| owner worktree | `data/workspaces/{folder}/owner` | +| Claude 플랫폼 프롬프트 | `prompts/claude-platform.md` | +| reviewer 프롬프트 | `prompts/claude-paired-room.md` | +| arbiter 프롬프트 | `prompts/arbiter-paired-room.md` | +| Codex 플랫폼 프롬프트 | `prompts/codex-platform.md` | +| 글로벌 메모리 | `groups/global/CLAUDE.md` | ## 문서와 실제 코드의 우선순위 diff --git a/src/moa.ts b/src/moa.ts index efd20f4..e946caf 100644 --- a/src/moa.ts +++ b/src/moa.ts @@ -30,6 +30,35 @@ export interface MoaReferenceResult { error?: string; } +export interface MoaReferenceStatus { + model: string; + checkedAt: string; + ok: boolean; + error: string | null; + responseLength?: number; +} + +const referenceStatuses = new Map(); + +function normalizeError(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function recordReferenceStatus( + status: Omit, +): MoaReferenceStatus { + const next: MoaReferenceStatus = { + ...status, + checkedAt: new Date().toISOString(), + }; + referenceStatuses.set(status.model, next); + return next; +} + +export function getMoaReferenceStatuses(): MoaReferenceStatus[] { + return [...referenceStatuses.values()]; +} + async function queryModel( model: MoaModelConfig, systemPrompt: string, @@ -138,21 +167,50 @@ export async function collectMoaReferences(args: { return results.map((result, i) => { const model = config.referenceModels[i].name; if (result.status === 'fulfilled') { + recordReferenceStatus({ + model, + ok: true, + error: null, + responseLength: result.value.length, + }); logger.info( { model, responseLen: result.value.length }, 'MoA: reference model responded', ); return { model, response: result.value }; } - const error = - result.reason instanceof Error - ? result.reason.message - : String(result.reason); + const error = normalizeError(result.reason); + recordReferenceStatus({ model, ok: false, error }); logger.warn({ model, error }, 'MoA: reference model failed'); return { model, response: '', error }; }); } +export async function probeMoaReferenceModel( + model: MoaModelConfig, +): Promise { + try { + const response = await queryModel( + model, + 'You are a configuration health check. Reply with a short plain-text OK.', + 'Reply exactly: OK', + 20_000, + ); + return recordReferenceStatus({ + model: model.name, + ok: true, + error: null, + responseLength: response.length, + }); + } catch (err) { + return recordReferenceStatus({ + model: model.name, + ok: false, + error: normalizeError(err), + }); + } +} + /** * Format reference opinions into a section that gets appended * to the arbiter's prompt. diff --git a/src/settings-store-moa.test.ts b/src/settings-store-moa.test.ts new file mode 100644 index 0000000..7c63ed5 --- /dev/null +++ b/src/settings-store-moa.test.ts @@ -0,0 +1,106 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { getMoaSettings, updateMoaSettings } from './settings-store-moa.js'; + +const originalCwd = process.cwd(); +let tempDir: string | null = null; + +function writeTempEnv(content: string): string { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-moa-settings-')); + fs.writeFileSync(path.join(tempDir, '.env'), content, { mode: 0o600 }); + process.chdir(tempDir); + return path.join(tempDir, '.env'); +} + +afterEach(() => { + process.chdir(originalCwd); + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; +}); + +describe('MoA settings store', () => { + it('reads MoA config without exposing API keys', () => { + writeTempEnv( + [ + 'MOA_ENABLED=true', + 'MOA_REF_MODELS=kimi,glm', + 'MOA_KIMI_MODEL=kimi-k2.6', + 'MOA_KIMI_BASE_URL=https://api.kimi.com/coding', + 'MOA_KIMI_API_FORMAT=anthropic', + 'MOA_KIMI_API_KEY=sk-kimi-secret', + 'MOA_GLM_MODEL=glm-5.1', + 'MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/anthropic', + 'MOA_GLM_API_FORMAT=anthropic', + 'MOA_GLM_API_KEY=glm-secret', + '', + ].join('\n'), + ); + + expect(getMoaSettings()).toMatchObject({ + enabled: true, + referenceModels: ['kimi', 'glm'], + models: [ + { + name: 'kimi', + enabled: true, + model: 'kimi-k2.6', + apiFormat: 'anthropic', + apiKeyConfigured: true, + }, + { + name: 'glm', + enabled: true, + model: 'glm-5.1', + apiFormat: 'anthropic', + apiKeyConfigured: true, + }, + ], + }); + expect(JSON.stringify(getMoaSettings())).not.toContain('sk-kimi-secret'); + }); + + it('updates the master toggle, active models, and replacement API keys', () => { + const envFile = writeTempEnv( + [ + 'MOA_ENABLED=true', + 'MOA_REF_MODELS=kimi,glm', + 'MOA_KIMI_MODEL=kimi-k2.6', + 'MOA_KIMI_BASE_URL=https://api.kimi.com/coding', + 'MOA_KIMI_API_FORMAT=anthropic', + 'MOA_KIMI_API_KEY=old-secret', + 'MOA_GLM_MODEL=glm-5.1', + 'MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/anthropic', + 'MOA_GLM_API_FORMAT=anthropic', + 'MOA_GLM_API_KEY=glm-secret', + '', + ].join('\n'), + ); + + const updated = updateMoaSettings({ + enabled: false, + models: [ + { + name: 'kimi', + enabled: false, + model: 'kimi-k2.7', + baseUrl: 'https://api.kimi.com/coding', + apiFormat: 'anthropic', + apiKey: 'new-secret', + }, + { name: 'glm', enabled: true }, + ], + }); + + expect(updated.enabled).toBe(false); + expect(updated.referenceModels).toEqual(['glm']); + const content = fs.readFileSync(envFile, 'utf-8'); + expect(content).toContain('MOA_ENABLED=false'); + expect(content).toContain('MOA_REF_MODELS=glm'); + expect(content).toContain('MOA_KIMI_MODEL=kimi-k2.7'); + expect(content).toContain('MOA_KIMI_API_KEY=new-secret'); + }); +}); diff --git a/src/settings-store-moa.ts b/src/settings-store-moa.ts new file mode 100644 index 0000000..0fd0150 --- /dev/null +++ b/src/settings-store-moa.ts @@ -0,0 +1,277 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { + getMoaReferenceStatuses, + probeMoaReferenceModel, + type MoaModelConfig, + type MoaReferenceStatus, +} from './moa.js'; + +export interface MoaModelSettingsSnapshot { + name: string; + enabled: boolean; + model: string; + baseUrl: string; + apiFormat: 'openai' | 'anthropic'; + apiKeyConfigured: boolean; + lastStatus: MoaReferenceStatus | null; +} + +export interface MoaSettingsSnapshot { + enabled: boolean; + referenceModels: string[]; + models: MoaModelSettingsSnapshot[]; +} + +export interface MoaModelSettingsUpdate { + name: string; + enabled?: boolean; + model?: string; + baseUrl?: string; + apiFormat?: 'openai' | 'anthropic'; + apiKey?: string; +} + +export interface MoaSettingsUpdateInput { + enabled?: boolean; + models?: MoaModelSettingsUpdate[]; +} + +const DEFAULT_MOA_MODEL_NAMES = ['kimi', 'glm'] as const; + +function envFilePath(): string { + return path.join(process.cwd(), '.env'); +} + +function pickEnvValue(content: string, key: string): string | undefined { + const re = new RegExp(`^${key}=(.*)$`, 'm'); + const match = content.match(re); + if (!match) return undefined; + return match[1].trim().replace(/^['"]|['"]$/g, ''); +} + +function readEnvFile(): string { + const file = envFilePath(); + if (!fs.existsSync(file)) return ''; + return fs.readFileSync(file, 'utf-8'); +} + +function readEnvOrProcess(key: string): string | undefined { + const fromFile = pickEnvValue(readEnvFile(), key); + if (fromFile !== undefined) return fromFile; + return process.env[key]; +} + +function listSettingsEnvKeys(): string[] { + const keys = new Set(); + for (const line of readEnvFile().split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx > 0) keys.add(trimmed.slice(0, eqIdx).trim()); + } + for (const key of Object.keys(process.env)) keys.add(key); + return [...keys].sort(); +} + +function parseCommaList(value: string | undefined): string[] { + return (value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function normalizeMoaModelName(name: string): string { + const normalized = name.trim().toLowerCase(); + if (!/^[a-z0-9_]+$/.test(normalized)) { + throw new Error(`invalid MoA model name: ${name}`); + } + return normalized; +} + +function moaPrefix(name: string): string { + return `MOA_${normalizeMoaModelName(name).toUpperCase()}`; +} + +function sanitizeEnvValue(value: string): string { + if (/[\r\n]/.test(value)) { + throw new Error('env values must not contain newlines'); + } + return value.trim(); +} + +function setOrInsertEnvLine( + content: string, + key: string, + value: string, +): string { + const re = new RegExp(`^${key}=.*$`, 'm'); + if (re.test(content)) return content.replace(re, `${key}=${value}`); + const trimmed = content.replace(/\s*$/, ''); + return `${trimmed}\n${key}=${value}\n`; +} + +function configuredMoaModelNames(): string[] { + const names = new Set(DEFAULT_MOA_MODEL_NAMES); + for (const name of parseCommaList(readEnvOrProcess('MOA_REF_MODELS'))) { + names.add(normalizeMoaModelName(name)); + } + for (const key of listSettingsEnvKeys()) { + const match = key.match( + /^MOA_([A-Z0-9_]+)_(MODEL|BASE_URL|API_KEY|API_FORMAT)$/, + ); + if (match) names.add(match[1].toLowerCase()); + } + return [...names].sort((a, b) => { + const defaultA = DEFAULT_MOA_MODEL_NAMES.indexOf( + a as (typeof DEFAULT_MOA_MODEL_NAMES)[number], + ); + const defaultB = DEFAULT_MOA_MODEL_NAMES.indexOf( + b as (typeof DEFAULT_MOA_MODEL_NAMES)[number], + ); + if (defaultA !== -1 || defaultB !== -1) { + if (defaultA === -1) return 1; + if (defaultB === -1) return -1; + return defaultA - defaultB; + } + return a.localeCompare(b); + }); +} + +function readMoaModelConfig(name: string): MoaModelConfig | null { + const normalized = normalizeMoaModelName(name); + const prefix = moaPrefix(normalized); + const model = readEnvOrProcess(`${prefix}_MODEL`) ?? ''; + const baseUrl = readEnvOrProcess(`${prefix}_BASE_URL`) ?? ''; + const apiKey = readEnvOrProcess(`${prefix}_API_KEY`) ?? ''; + const rawFormat = readEnvOrProcess(`${prefix}_API_FORMAT`) ?? ''; + const apiFormat: 'openai' | 'anthropic' = + rawFormat === 'anthropic' ? 'anthropic' : 'openai'; + if (!model || !baseUrl || !apiKey) return null; + return { name: normalized, model, baseUrl, apiKey, apiFormat }; +} + +export function getMoaSettings(): MoaSettingsSnapshot { + const referenceModels = parseCommaList( + readEnvOrProcess('MOA_REF_MODELS'), + ).map(normalizeMoaModelName); + const active = new Set(referenceModels); + const statuses = new Map( + getMoaReferenceStatuses().map((status) => [status.model, status]), + ); + return { + enabled: readEnvOrProcess('MOA_ENABLED') === 'true', + referenceModels, + models: configuredMoaModelNames().map((name) => { + const prefix = moaPrefix(name); + const rawFormat = readEnvOrProcess(`${prefix}_API_FORMAT`) ?? ''; + const apiFormat: 'openai' | 'anthropic' = + rawFormat === 'anthropic' ? 'anthropic' : 'openai'; + return { + name, + enabled: active.has(name), + model: readEnvOrProcess(`${prefix}_MODEL`) ?? '', + baseUrl: readEnvOrProcess(`${prefix}_BASE_URL`) ?? '', + apiFormat, + apiKeyConfigured: Boolean(readEnvOrProcess(`${prefix}_API_KEY`)), + lastStatus: statuses.get(name) ?? null, + }; + }), + }; +} + +export function updateMoaSettings( + input: MoaSettingsUpdateInput, +): MoaSettingsSnapshot { + const file = envFilePath(); + let content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : ''; + + if (typeof input.enabled === 'boolean') { + content = setOrInsertEnvLine( + content, + 'MOA_ENABLED', + input.enabled ? 'true' : 'false', + ); + } + + if (Array.isArray(input.models)) { + const byName = new Map(); + for (const update of input.models) { + byName.set(normalizeMoaModelName(update.name), update); + } + content = applyMoaModelUpdates(content, byName); + } + + const tempPath = `${file}.tmp`; + fs.writeFileSync(tempPath, content, { mode: 0o600 }); + fs.renameSync(tempPath, file); + + return getMoaSettings(); +} + +function applyMoaModelUpdates( + content: string, + byName: Map, +): string { + let next = content; + const allNames = new Set(configuredMoaModelNames()); + for (const name of byName.keys()) allNames.add(name); + + const enabledNames: string[] = []; + for (const name of allNames) { + const update = byName.get(name); + const currentlyEnabled = parseCommaList(readEnvOrProcess('MOA_REF_MODELS')) + .map(normalizeMoaModelName) + .includes(name); + if (update?.enabled ?? currentlyEnabled) enabledNames.push(name); + if (update) next = applyMoaModelUpdate(next, name, update); + } + + return setOrInsertEnvLine(next, 'MOA_REF_MODELS', enabledNames.join(',')); +} + +function applyMoaModelUpdate( + content: string, + name: string, + update: MoaModelSettingsUpdate, +): string { + let next = content; + const prefix = moaPrefix(name); + if (update.model !== undefined) { + next = setOrInsertEnvLine( + next, + `${prefix}_MODEL`, + sanitizeEnvValue(update.model), + ); + } + if (update.baseUrl !== undefined) { + next = setOrInsertEnvLine( + next, + `${prefix}_BASE_URL`, + sanitizeEnvValue(update.baseUrl), + ); + } + if (update.apiFormat !== undefined) { + if (update.apiFormat !== 'openai' && update.apiFormat !== 'anthropic') { + throw new Error(`invalid MoA API format for ${name}`); + } + next = setOrInsertEnvLine(next, `${prefix}_API_FORMAT`, update.apiFormat); + } + if (update.apiKey !== undefined && update.apiKey.trim() !== '') { + next = setOrInsertEnvLine( + next, + `${prefix}_API_KEY`, + sanitizeEnvValue(update.apiKey), + ); + } + return next; +} + +export async function checkMoaModel(name: string): Promise { + const config = readMoaModelConfig(name); + if (!config) { + throw new Error(`MoA model ${name} is missing model/baseUrl/apiKey`); + } + return probeMoaReferenceModel(config); +} diff --git a/src/web-dashboard-settings-routes.test.ts b/src/web-dashboard-settings-routes.test.ts index 04e671c..3b29fc2 100644 --- a/src/web-dashboard-settings-routes.test.ts +++ b/src/web-dashboard-settings-routes.test.ts @@ -10,6 +10,7 @@ import type { FastModeSnapshot, ModelConfigSnapshot, } from './settings-store.js'; +import type { MoaSettingsSnapshot } from './settings-store-moa.js'; function jsonResponse(value: unknown, init?: ResponseInit): Response { return new Response(JSON.stringify(value), { @@ -42,6 +43,36 @@ const modelConfig: ModelConfigSnapshot = { const fastMode: FastModeSnapshot = { codex: true, claude: false }; +const moaSettings: MoaSettingsSnapshot = { + enabled: true, + referenceModels: ['kimi', 'glm'], + models: [ + { + name: 'kimi', + enabled: true, + model: 'kimi-k2.6', + baseUrl: 'https://api.kimi.com/coding', + apiFormat: 'anthropic', + apiKeyConfigured: true, + lastStatus: { + model: 'kimi', + checkedAt: '2026-04-30T00:00:00.000Z', + ok: false, + error: '402 Payment Required', + }, + }, + { + name: 'glm', + enabled: true, + model: 'glm-5.1', + baseUrl: 'https://open.bigmodel.cn/api/anthropic', + apiFormat: 'anthropic', + apiKeyConfigured: true, + lastStatus: null, + }, + ], +}; + function makeClaudeAccount(): ClaudeAccountSummary { return { index: 0, @@ -68,9 +99,17 @@ function makeDeps( ): SettingsRouteDependencies { return { addClaudeAccountFromToken: () => ({ index: 2, accountId: null }), + checkMoaModel: async (name) => ({ + model: name, + checkedAt: '2026-04-30T00:01:00.000Z', + ok: true, + error: null, + responseLength: 2, + }), getActiveCodexSettingsIndex: () => 1, getFastMode: () => fastMode, getModelConfig: () => modelConfig, + getMoaSettings: () => moaSettings, listClaudeAccounts: () => [makeClaudeAccount()], listCodexAccounts: () => [makeCodexAccount()], refreshAllCodexAccounts: async () => ({ refreshed: [1], failed: [] }), @@ -79,6 +118,7 @@ function makeDeps( setActiveCodexSettingsIndex: () => undefined, updateFastMode: () => fastMode, updateModelConfig: () => modelConfig, + updateMoaSettings: () => moaSettings, ...overrides, }; } @@ -115,6 +155,17 @@ describe('web dashboard settings routes', () => { expect(mode?.status).toBe(200); await expect(mode?.json()).resolves.toEqual(fastMode); + const moa = await route('/api/settings/moa'); + expect(moa?.status).toBe(200); + const moaJson = (await moa?.json()) as MoaSettingsSnapshot; + expect(moaJson.enabled).toBe(true); + expect(moaJson.models.find((model) => model.name === 'kimi')).toMatchObject( + { + enabled: true, + lastStatus: { ok: false, error: '402 Payment Required' }, + }, + ); + const unmatched = await route('/api/overview'); expect(unmatched).toBeNull(); }); @@ -136,6 +187,10 @@ describe('web dashboard settings routes', () => { calls.push(`models:${JSON.stringify(input)}`); return modelConfig; }, + updateMoaSettings: (input) => { + calls.push(`moa:${JSON.stringify(input)}`); + return moaSettings; + }, }); const modelUpdate = await route( @@ -174,11 +229,33 @@ describe('web dashboard settings routes', () => { ok: true, codexCurrentIndex: 1, }); + + const moa = await route( + '/api/settings/moa', + 'PATCH', + { enabled: false, models: [{ name: 'kimi', enabled: false }] }, + deps, + ); + expect(moa?.status).toBe(200); + + const check = await route( + '/api/settings/moa/check', + 'POST', + { name: 'glm' }, + deps, + ); + expect(check?.status).toBe(200); + await expect(check?.json()).resolves.toMatchObject({ + ok: true, + status: { model: 'glm', ok: true }, + }); + expect(calls).toEqual([ 'models:{"owner":{"model":"gpt-5.5"}}', 'add:claude-token', 'delete:codex:4', 'current:4', + 'moa:{"enabled":false,"models":[{"name":"kimi","enabled":false}]}', ]); }); }); diff --git a/src/web-dashboard-settings-routes.ts b/src/web-dashboard-settings-routes.ts index f5a49d5..b0f268a 100644 --- a/src/web-dashboard-settings-routes.ts +++ b/src/web-dashboard-settings-routes.ts @@ -16,6 +16,12 @@ import { type FastModeSnapshot, type ModelConfigSnapshot, } from './settings-store.js'; +import { + checkMoaModel, + getMoaSettings, + updateMoaSettings, + type MoaSettingsSnapshot, +} from './settings-store-moa.js'; type JsonResponse = ( value: unknown, @@ -25,9 +31,11 @@ type JsonResponse = ( export interface SettingsRouteDependencies { addClaudeAccountFromToken: typeof addClaudeAccountFromToken; + checkMoaModel: typeof checkMoaModel; getActiveCodexSettingsIndex: typeof getActiveCodexSettingsIndex; getFastMode: typeof getFastMode; getModelConfig: typeof getModelConfig; + getMoaSettings: typeof getMoaSettings; listClaudeAccounts: typeof listClaudeAccounts; listCodexAccounts: typeof listCodexAccounts; refreshAllCodexAccounts: typeof refreshAllCodexAccounts; @@ -36,6 +44,7 @@ export interface SettingsRouteDependencies { setActiveCodexSettingsIndex: typeof setActiveCodexSettingsIndex; updateFastMode: typeof updateFastMode; updateModelConfig: typeof updateModelConfig; + updateMoaSettings: typeof updateMoaSettings; } interface SettingsRouteContext { @@ -47,9 +56,11 @@ interface SettingsRouteContext { const defaultSettingsRouteDependencies: SettingsRouteDependencies = { addClaudeAccountFromToken, + checkMoaModel, getActiveCodexSettingsIndex, getFastMode, getModelConfig, + getMoaSettings, listClaudeAccounts, listCodexAccounts, refreshAllCodexAccounts, @@ -58,6 +69,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = { setActiveCodexSettingsIndex, updateFastMode, updateModelConfig, + updateMoaSettings, }; function readMethod(method: string): boolean { @@ -121,6 +133,43 @@ async function handleFastModeRoute( } } +async function handleMoaSettingsRoute( + request: Request, + jsonResponse: JsonResponse, + deps: SettingsRouteDependencies, +): Promise { + if (readMethod(request.method)) return jsonResponse(deps.getMoaSettings()); + if (request.method !== 'PUT' && request.method !== 'PATCH') return null; + + const body = await readJsonObject(request, jsonResponse); + if (body instanceof Response) return body; + try { + return jsonResponse(deps.updateMoaSettings(body)); + } catch (err) { + return jsonResponse({ error: errorMessage(err) }, { status: 500 }); + } +} + +async function handleMoaCheckRoute( + request: Request, + jsonResponse: JsonResponse, + deps: SettingsRouteDependencies, +): Promise { + if (request.method !== 'POST') return null; + + const body = await readJsonObject(request, jsonResponse); + if (body instanceof Response) return body; + const name = typeof body.name === 'string' ? body.name.trim() : ''; + if (!name) + return jsonResponse({ error: 'name is required' }, { status: 400 }); + + try { + return jsonResponse({ ok: true, status: await deps.checkMoaModel(name) }); + } catch (err) { + return jsonResponse({ error: errorMessage(err) }, { status: 400 }); + } +} + function handleAccountsRoute( request: Request, jsonResponse: JsonResponse, @@ -250,6 +299,12 @@ export async function handleSettingsRoute({ if (url.pathname === '/api/settings/fast-mode') { return handleFastModeRoute(request, jsonResponse, deps); } + if (url.pathname === '/api/settings/moa') { + return handleMoaSettingsRoute(request, jsonResponse, deps); + } + if (url.pathname === '/api/settings/moa/check') { + return handleMoaCheckRoute(request, jsonResponse, deps); + } if (url.pathname === '/api/settings/accounts/claude') { return handleClaudeAccountAddRoute(request, jsonResponse, deps); } @@ -282,4 +337,5 @@ export type { CodexAccountSummary, FastModeSnapshot, ModelConfigSnapshot, + MoaSettingsSnapshot, };