add dashboard MoA settings controls

This commit is contained in:
ejclaw
2026-04-30 01:31:05 +09:00
parent d2ccb40cac
commit 731984207c
12 changed files with 1134 additions and 24 deletions

View File

@@ -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. # The SDK arbiter (subscription-based, no extra cost) aggregates all perspectives.
# MOA_ENABLED=true # MOA_ENABLED=true
# MOA_REF_MODELS=kimi,glm # Comma-separated reference model names # 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_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_KIMI_API_KEY=sk-kimi-xxx # Kimi API key
# MOA_GLM_MODEL=glm-4-plus # GLM model # MOA_GLM_MODEL=glm-5.1 # GLM model
# MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 # GLM API endpoint # 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 # MOA_GLM_API_KEY=xxx # GLM API key
# --- Advanced --- # --- Advanced ---

View File

@@ -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<MoaSettingsSnapshot | null>(null);
const [draft, setDraft] = useState<MoaSettingsSnapshot | null>(null);
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [checking, setChecking] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<number | null>(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<MoaModelSettingsSnapshot>) {
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 (
<section className="settings-section">
<h3>MoA </h3>
{error ? <p className="settings-error">{error}</p> : null}
{!draft ? (
<p className="settings-hint">
{busy ? '불러오는 중…' : 'MoA 설정 없음'}
</p>
) : (
<MoaSettingsContent
apiKeys={apiKeys}
busy={busy}
checking={checking}
dirty={dirty}
draft={draft}
onApiKeyChange={(name, value) =>
setApiKeys((prev) => ({ ...prev, [name]: value }))
}
onModelChange={setModel}
onRestartStack={onRestartStack}
onSave={save}
onTest={testModel}
onToggle={() =>
setDraft((prev) =>
prev ? { ...prev, enabled: !prev.enabled } : prev,
)
}
savedAt={savedAt}
/>
)}
</section>
);
}
function MoaSettingsContent({
apiKeys,
busy,
checking,
dirty,
draft,
onApiKeyChange,
onModelChange,
onRestartStack,
onSave,
onTest,
onToggle,
savedAt,
}: {
apiKeys: Record<string, string>;
busy: boolean;
checking: string | null;
dirty: boolean;
draft: MoaSettingsSnapshot;
onApiKeyChange: (name: string, value: string) => void;
onModelChange: (
name: string,
patch: Partial<MoaModelSettingsSnapshot>,
) => void;
onRestartStack: () => void;
onSave: () => Promise<void>;
onTest: (name: string) => Promise<void>;
onToggle: () => void;
savedAt: number | null;
}) {
return (
<>
<MoaMasterToggle
busy={busy}
enabled={draft.enabled}
onToggle={onToggle}
/>
<ul className="settings-account-list">
{draft.models.map((model) => (
<MoaModelRow
apiKeyValue={apiKeys[model.name] ?? ''}
busy={busy}
checking={checking}
dirty={dirty}
key={model.name}
model={model}
onApiKeyChange={onApiKeyChange}
onModelChange={onModelChange}
onTest={onTest}
/>
))}
</ul>
<MoaSettingsActions
busy={busy}
checking={checking}
dirty={dirty}
onRestartStack={onRestartStack}
onSave={onSave}
savedAt={savedAt}
/>
</>
);
}
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 (
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">MoA </span>
<small className="settings-hint">
Arbiter .
.
</small>
</span>
<input
checked={enabled}
disabled={busy}
onChange={onToggle}
type="checkbox"
/>
</label>
);
}
function MoaSettingsActions({
busy,
checking,
dirty,
onRestartStack,
onSave,
savedAt,
}: {
busy: boolean;
checking: string | null;
dirty: boolean;
onRestartStack: () => void;
onSave: () => Promise<void>;
savedAt: number | null;
}) {
return (
<div className="settings-actions">
<button
className="settings-save"
disabled={!dirty || busy || checking !== null}
onClick={() => void onSave()}
type="button"
>
{busy ? '저장 중…' : '저장'}
</button>
{savedAt && !dirty ? (
<small className="settings-hint">
. .
</small>
) : null}
<button
className="settings-restart"
disabled={busy || checking !== null}
onClick={() => {
if (
window.confirm(
'스택을 재시작하면 진행 중인 모든 에이전트 작업이 중단됩니다. 진행할까요?',
)
) {
onRestartStack();
}
}}
type="button"
>
</button>
</div>
);
}
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<MoaModelSettingsSnapshot>,
) => void;
onTest: (name: string) => Promise<void>;
}) {
return (
<li className="settings-account-row settings-moa-row">
<div className="settings-moa-grid">
<label className="settings-moa-name">
<input
checked={model.enabled}
disabled={busy}
onChange={() =>
onModelChange(model.name, { enabled: !model.enabled })
}
type="checkbox"
/>
<span className="settings-account-tag">{model.name}</span>
</label>
<input
aria-label={`${model.name} MoA model`}
onChange={(e) => onModelChange(model.name, { model: e.target.value })}
placeholder="model"
type="text"
value={model.model}
/>
<input
aria-label={`${model.name} MoA base URL`}
onChange={(e) =>
onModelChange(model.name, { baseUrl: e.target.value })
}
placeholder="base URL"
type="text"
value={model.baseUrl}
/>
<select
aria-label={`${model.name} MoA API format`}
onChange={(e) =>
onModelChange(model.name, {
apiFormat: e.target.value as 'openai' | 'anthropic',
})
}
value={model.apiFormat}
>
<option value="anthropic">anthropic</option>
<option value="openai">openai</option>
</select>
<input
aria-label={`${model.name} MoA API key`}
onChange={(e) => onApiKeyChange(model.name, e.target.value)}
placeholder={model.apiKeyConfigured ? 'API key set' : 'new API key'}
type="password"
value={apiKeyValue}
/>
<span
className={`settings-account-badge ${
model.lastStatus?.ok === false ? 'is-expired' : 'is-active'
}`}
title={model.lastStatus?.error ?? undefined}
>
{formatMoaStatus(model.lastStatus)}
</span>
</div>
<div className="settings-account-actions">
<button
className="settings-secondary"
disabled={
busy || checking !== null || dirty || !model.apiKeyConfigured
}
onClick={() => void onTest(model.name)}
type="button"
>
{checking === model.name
? '테스트중…'
: dirty
? '저장 후 테스트'
: '연결 테스트'}
</button>
</div>
</li>
);
}

View File

@@ -28,10 +28,11 @@ describe('SettingsPanel', () => {
expect(html).toContain('English'); 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)); const html = renderToStaticMarkup(createElement(SettingsPanel, baseProps));
expect(html).toContain('모델'); expect(html).toContain('모델');
expect(html).toContain('MoA 참조 모델');
expect(html).toContain('패스트 모드'); expect(html).toContain('패스트 모드');
expect(html).toContain('불러오는 중'); expect(html).toContain('불러오는 중');
expect(html).toContain('Claude'); expect(html).toContain('Claude');

View File

@@ -18,6 +18,7 @@ import {
updateModels, updateModels,
} from './api'; } from './api';
import { LOCALES, languageNames, type Locale, type Messages } from './i18n'; import { LOCALES, languageNames, type Locale, type Messages } from './i18n';
import { MoaSettingsPanel } from './MoaSettingsPanel';
type AccountProvider = 'claude' | 'codex'; type AccountProvider = 'claude' | 'codex';
@@ -77,6 +78,8 @@ export function SettingsPanel({
<ModelSettings onRestartStack={onRestartStack} /> <ModelSettings onRestartStack={onRestartStack} />
<MoaSettingsPanel onRestartStack={onRestartStack} />
<FastModeSettings /> <FastModeSettings />
<AccountSettings onRestartStack={onRestartStack} /> <AccountSettings onRestartStack={onRestartStack} />

View File

@@ -388,6 +388,30 @@ export interface FastModeSnapshot {
claude: boolean; 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<{ export async function fetchAccounts(): Promise<{
claude: ClaudeAccountSummary[]; claude: ClaudeAccountSummary[];
codex: CodexAccountSummary[]; codex: CodexAccountSummary[];
@@ -514,6 +538,67 @@ export async function updateFastMode(
return (await response.json()) as FastModeSnapshot; return (await response.json()) as FastModeSnapshot;
} }
export async function fetchMoaSettings(): Promise<MoaSettingsSnapshot> {
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<MoaSettingsSnapshot> {
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<MoaReferenceStatus> {
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( export async function deleteAccount(
provider: 'claude' | 'codex', provider: 'claude' | 'codex',
index: number, index: number,

View File

@@ -862,6 +862,46 @@ button:disabled {
min-width: 0; 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 { .settings-account-tag {
font-family: 'JetBrains Mono', ui-monospace, monospace; font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px; font-size: 11px;

View File

@@ -93,19 +93,21 @@ GROQ_API_KEY=
MOA_ENABLED=true MOA_ENABLED=true
MOA_REF_MODELS=kimi,glm 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_BASE_URL=https://api.kimi.com/coding
MOA_KIMI_API_KEY=sk-kimi-xxx MOA_KIMI_API_KEY=sk-kimi-xxx
MOA_KIMI_API_FORMAT=anthropic MOA_KIMI_API_FORMAT=anthropic
MOA_GLM_MODEL=glm-4-plus MOA_GLM_MODEL=glm-5.1
MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/anthropic
MOA_GLM_API_KEY=xxx MOA_GLM_API_KEY=xxx
MOA_GLM_API_FORMAT=anthropic MOA_GLM_API_FORMAT=anthropic
``` ```
MoA는 arbiter 판정 전에 외부 모델 의견을 수집해 prompt에 주입합니다. 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` | | DB | `store/messages.db` |
| 서비스 로그 | `journalctl --user -u ejclaw -f` | | 서비스 로그 | `journalctl --user -u ejclaw -f` |
| room 로그 | `groups/{folder}/logs/` | | room 로그 | `groups/{folder}/logs/` |
| owner/reviewer 세션 | `data/sessions/{folder}*` | | owner/reviewer 세션 | `data/sessions/{folder}*` |
| owner worktree | `data/workspaces/{folder}/owner` | | owner worktree | `data/workspaces/{folder}/owner` |
| Claude 플랫폼 프롬프트 | `prompts/claude-platform.md` | | Claude 플랫폼 프롬프트 | `prompts/claude-platform.md` |
| reviewer 프롬프트 | `prompts/claude-paired-room.md` | | reviewer 프롬프트 | `prompts/claude-paired-room.md` |
| arbiter 프롬프트 | `prompts/arbiter-paired-room.md` | | arbiter 프롬프트 | `prompts/arbiter-paired-room.md` |
| Codex 플랫폼 프롬프트 | `prompts/codex-platform.md` | | Codex 플랫폼 프롬프트 | `prompts/codex-platform.md` |
| 글로벌 메모리 | `groups/global/CLAUDE.md` | | 글로벌 메모리 | `groups/global/CLAUDE.md` |
## 문서와 실제 코드의 우선순위 ## 문서와 실제 코드의 우선순위

View File

@@ -30,6 +30,35 @@ export interface MoaReferenceResult {
error?: string; error?: string;
} }
export interface MoaReferenceStatus {
model: string;
checkedAt: string;
ok: boolean;
error: string | null;
responseLength?: number;
}
const referenceStatuses = new Map<string, MoaReferenceStatus>();
function normalizeError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function recordReferenceStatus(
status: Omit<MoaReferenceStatus, 'checkedAt'>,
): 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( async function queryModel(
model: MoaModelConfig, model: MoaModelConfig,
systemPrompt: string, systemPrompt: string,
@@ -138,21 +167,50 @@ export async function collectMoaReferences(args: {
return results.map((result, i) => { return results.map((result, i) => {
const model = config.referenceModels[i].name; const model = config.referenceModels[i].name;
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
recordReferenceStatus({
model,
ok: true,
error: null,
responseLength: result.value.length,
});
logger.info( logger.info(
{ model, responseLen: result.value.length }, { model, responseLen: result.value.length },
'MoA: reference model responded', 'MoA: reference model responded',
); );
return { model, response: result.value }; return { model, response: result.value };
} }
const error = const error = normalizeError(result.reason);
result.reason instanceof Error recordReferenceStatus({ model, ok: false, error });
? result.reason.message
: String(result.reason);
logger.warn({ model, error }, 'MoA: reference model failed'); logger.warn({ model, error }, 'MoA: reference model failed');
return { model, response: '', error }; return { model, response: '', error };
}); });
} }
export async function probeMoaReferenceModel(
model: MoaModelConfig,
): Promise<MoaReferenceStatus> {
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 * Format reference opinions into a section that gets appended
* to the arbiter's prompt. * to the arbiter's prompt.

View File

@@ -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');
});
});

277
src/settings-store-moa.ts Normal file
View File

@@ -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<string>();
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<string>(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<string, MoaModelSettingsUpdate>();
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, MoaModelSettingsUpdate>,
): 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<MoaReferenceStatus> {
const config = readMoaModelConfig(name);
if (!config) {
throw new Error(`MoA model ${name} is missing model/baseUrl/apiKey`);
}
return probeMoaReferenceModel(config);
}

View File

@@ -10,6 +10,7 @@ import type {
FastModeSnapshot, FastModeSnapshot,
ModelConfigSnapshot, ModelConfigSnapshot,
} from './settings-store.js'; } from './settings-store.js';
import type { MoaSettingsSnapshot } from './settings-store-moa.js';
function jsonResponse(value: unknown, init?: ResponseInit): Response { function jsonResponse(value: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(value), { return new Response(JSON.stringify(value), {
@@ -42,6 +43,36 @@ const modelConfig: ModelConfigSnapshot = {
const fastMode: FastModeSnapshot = { codex: true, claude: false }; 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 { function makeClaudeAccount(): ClaudeAccountSummary {
return { return {
index: 0, index: 0,
@@ -68,9 +99,17 @@ function makeDeps(
): SettingsRouteDependencies { ): SettingsRouteDependencies {
return { return {
addClaudeAccountFromToken: () => ({ index: 2, accountId: null }), 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, getActiveCodexSettingsIndex: () => 1,
getFastMode: () => fastMode, getFastMode: () => fastMode,
getModelConfig: () => modelConfig, getModelConfig: () => modelConfig,
getMoaSettings: () => moaSettings,
listClaudeAccounts: () => [makeClaudeAccount()], listClaudeAccounts: () => [makeClaudeAccount()],
listCodexAccounts: () => [makeCodexAccount()], listCodexAccounts: () => [makeCodexAccount()],
refreshAllCodexAccounts: async () => ({ refreshed: [1], failed: [] }), refreshAllCodexAccounts: async () => ({ refreshed: [1], failed: [] }),
@@ -79,6 +118,7 @@ function makeDeps(
setActiveCodexSettingsIndex: () => undefined, setActiveCodexSettingsIndex: () => undefined,
updateFastMode: () => fastMode, updateFastMode: () => fastMode,
updateModelConfig: () => modelConfig, updateModelConfig: () => modelConfig,
updateMoaSettings: () => moaSettings,
...overrides, ...overrides,
}; };
} }
@@ -115,6 +155,17 @@ describe('web dashboard settings routes', () => {
expect(mode?.status).toBe(200); expect(mode?.status).toBe(200);
await expect(mode?.json()).resolves.toEqual(fastMode); 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'); const unmatched = await route('/api/overview');
expect(unmatched).toBeNull(); expect(unmatched).toBeNull();
}); });
@@ -136,6 +187,10 @@ describe('web dashboard settings routes', () => {
calls.push(`models:${JSON.stringify(input)}`); calls.push(`models:${JSON.stringify(input)}`);
return modelConfig; return modelConfig;
}, },
updateMoaSettings: (input) => {
calls.push(`moa:${JSON.stringify(input)}`);
return moaSettings;
},
}); });
const modelUpdate = await route( const modelUpdate = await route(
@@ -174,11 +229,33 @@ describe('web dashboard settings routes', () => {
ok: true, ok: true,
codexCurrentIndex: 1, 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([ expect(calls).toEqual([
'models:{"owner":{"model":"gpt-5.5"}}', 'models:{"owner":{"model":"gpt-5.5"}}',
'add:claude-token', 'add:claude-token',
'delete:codex:4', 'delete:codex:4',
'current:4', 'current:4',
'moa:{"enabled":false,"models":[{"name":"kimi","enabled":false}]}',
]); ]);
}); });
}); });

View File

@@ -16,6 +16,12 @@ import {
type FastModeSnapshot, type FastModeSnapshot,
type ModelConfigSnapshot, type ModelConfigSnapshot,
} from './settings-store.js'; } from './settings-store.js';
import {
checkMoaModel,
getMoaSettings,
updateMoaSettings,
type MoaSettingsSnapshot,
} from './settings-store-moa.js';
type JsonResponse = ( type JsonResponse = (
value: unknown, value: unknown,
@@ -25,9 +31,11 @@ type JsonResponse = (
export interface SettingsRouteDependencies { export interface SettingsRouteDependencies {
addClaudeAccountFromToken: typeof addClaudeAccountFromToken; addClaudeAccountFromToken: typeof addClaudeAccountFromToken;
checkMoaModel: typeof checkMoaModel;
getActiveCodexSettingsIndex: typeof getActiveCodexSettingsIndex; getActiveCodexSettingsIndex: typeof getActiveCodexSettingsIndex;
getFastMode: typeof getFastMode; getFastMode: typeof getFastMode;
getModelConfig: typeof getModelConfig; getModelConfig: typeof getModelConfig;
getMoaSettings: typeof getMoaSettings;
listClaudeAccounts: typeof listClaudeAccounts; listClaudeAccounts: typeof listClaudeAccounts;
listCodexAccounts: typeof listCodexAccounts; listCodexAccounts: typeof listCodexAccounts;
refreshAllCodexAccounts: typeof refreshAllCodexAccounts; refreshAllCodexAccounts: typeof refreshAllCodexAccounts;
@@ -36,6 +44,7 @@ export interface SettingsRouteDependencies {
setActiveCodexSettingsIndex: typeof setActiveCodexSettingsIndex; setActiveCodexSettingsIndex: typeof setActiveCodexSettingsIndex;
updateFastMode: typeof updateFastMode; updateFastMode: typeof updateFastMode;
updateModelConfig: typeof updateModelConfig; updateModelConfig: typeof updateModelConfig;
updateMoaSettings: typeof updateMoaSettings;
} }
interface SettingsRouteContext { interface SettingsRouteContext {
@@ -47,9 +56,11 @@ interface SettingsRouteContext {
const defaultSettingsRouteDependencies: SettingsRouteDependencies = { const defaultSettingsRouteDependencies: SettingsRouteDependencies = {
addClaudeAccountFromToken, addClaudeAccountFromToken,
checkMoaModel,
getActiveCodexSettingsIndex, getActiveCodexSettingsIndex,
getFastMode, getFastMode,
getModelConfig, getModelConfig,
getMoaSettings,
listClaudeAccounts, listClaudeAccounts,
listCodexAccounts, listCodexAccounts,
refreshAllCodexAccounts, refreshAllCodexAccounts,
@@ -58,6 +69,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = {
setActiveCodexSettingsIndex, setActiveCodexSettingsIndex,
updateFastMode, updateFastMode,
updateModelConfig, updateModelConfig,
updateMoaSettings,
}; };
function readMethod(method: string): boolean { function readMethod(method: string): boolean {
@@ -121,6 +133,43 @@ async function handleFastModeRoute(
} }
} }
async function handleMoaSettingsRoute(
request: Request,
jsonResponse: JsonResponse,
deps: SettingsRouteDependencies,
): Promise<Response | null> {
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<Response | null> {
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( function handleAccountsRoute(
request: Request, request: Request,
jsonResponse: JsonResponse, jsonResponse: JsonResponse,
@@ -250,6 +299,12 @@ export async function handleSettingsRoute({
if (url.pathname === '/api/settings/fast-mode') { if (url.pathname === '/api/settings/fast-mode') {
return handleFastModeRoute(request, jsonResponse, deps); 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') { if (url.pathname === '/api/settings/accounts/claude') {
return handleClaudeAccountAddRoute(request, jsonResponse, deps); return handleClaudeAccountAddRoute(request, jsonResponse, deps);
} }
@@ -282,4 +337,5 @@ export type {
CodexAccountSummary, CodexAccountSummary,
FastModeSnapshot, FastModeSnapshot,
ModelConfigSnapshot, ModelConfigSnapshot,
MoaSettingsSnapshot,
}; };