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

@@ -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<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(
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<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
* 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,
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}]}',
]);
});
});

View File

@@ -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<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(
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,
};