Clean up model structure: OpenCode Models, Chain, and Public Models

- Simplified to 3 clear sections:
  1. OpenCode Models: OpenCode dropdown + display name + order buttons
  2. OpenCode Chain: Fallback chain with add form + order buttons
  3. Public Models: Manual entry + order buttons (completely separate)
- New state variables: opencodeModels, opencodeChain, publicModels
- Clean API endpoints for chain operations
- Removed all confusing legacy code and naming
This commit is contained in:
southseact-3d
2026-02-18 15:11:12 +00:00
parent cc17079988
commit 3ba9fab6ab
3 changed files with 638 additions and 3417 deletions

View File

@@ -1524,10 +1524,10 @@ const MODELS_DEV_CACHE_TTL = 3600000; // 1 hour
const adminSessions = new Map();
let adminModels = [];
let adminModelIndex = new Map();
// New unified model chain structure
let providerModels = []; // Provider models from OpenCode [{id, name, label, icon, tier, supportsMedia, multiplier}]
// Clean model structure
let opencodeModels = []; // Models from OpenCode [{id, name, label, icon, tier, supportsMedia, multiplier}]
let opencodeChain = []; // OpenCode fallback chain [{provider, model}]
let publicModels = []; // Public-facing models (completely separate) [{id, name, label, icon, tier, supportsMedia, multiplier}]
let providerChain = []; // Unified fallback chain [{provider, model, primary}]
let openrouterSettings = {
primaryModel: OPENROUTER_MODEL_PRIMARY,
backupModel1: OPENROUTER_MODEL_BACKUP_1,
@@ -5757,21 +5757,21 @@ async function loadAdminModelStore() {
try {
await ensureStateFile();
await ensureAssetsDir();
const raw = await fs.readFile(ADMIN_MODELS_FILE, 'utf8').catch(() => '[]');
const parsed = JSON.parse(raw || '[]');
const raw = await fs.readFile(ADMIN_MODELS_FILE, 'utf8').catch(() => '{}');
const parsed = JSON.parse(raw || '{}');
// Check if using new unified structure or old structure
// Load clean structure
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
// New unified structure
providerModels = Array.isArray(parsed.providerModels) ? parsed.providerModels : [];
// New clean structure
opencodeModels = Array.isArray(parsed.opencodeModels) ? parsed.opencodeModels : [];
opencodeChain = Array.isArray(parsed.opencodeChain) ? parsed.opencodeChain : [];
publicModels = Array.isArray(parsed.publicModels) ? parsed.publicModels : [];
providerChain = Array.isArray(parsed.providerChain) ? parsed.providerChain : [];
adminModels = Array.isArray(parsed.adminModels) ? parsed.adminModels : [];
} else if (Array.isArray(parsed)) {
// Old array structure - migrate to new
// Old array structure - migrate
adminModels = parsed;
// Create provider models from admin models (these are from OpenCode)
providerModels = parsed.map((m) => ({
// Migrate old admin models to opencode models
opencodeModels = parsed.map((m) => ({
id: m.id || randomUUID(),
name: m.name,
label: m.label || m.name,
@@ -5780,35 +5780,33 @@ async function loadAdminModelStore() {
supportsMedia: m.supportsMedia ?? false,
multiplier: getTierMultiplier(m.tier),
})).filter((m) => !!m.name);
// Public models start empty (user can add these manually later)
publicModels = [];
// Create unified provider chain from all unique providers
// Create chain from providers
const allProviders = new Map();
parsed.forEach((m) => {
const providers = Array.isArray(m.providers) && m.providers.length
? m.providers
: [{ provider: 'opencode', model: m.name, primary: true }];
: [{ provider: 'opencode', model: m.name }];
providers.forEach((p) => {
const key = `${p.provider}:${p.model || m.name}`;
if (!allProviders.has(key)) {
allProviders.set(key, {
provider: normalizeProviderName(p.provider || 'opencode'),
model: (p.model || m.name || '').trim() || m.name,
primary: allProviders.size === 0,
});
}
});
});
providerChain = Array.from(allProviders.values());
opencodeChain = Array.from(allProviders.values());
} else {
providerModels = [];
opencodeModels = [];
opencodeChain = [];
publicModels = [];
providerChain = [];
adminModels = [];
}
// Ensure all provider models have required fields
providerModels = providerModels.map((m) => ({
// Validate opencode models
opencodeModels = opencodeModels.map((m) => ({
id: m.id || randomUUID(),
name: m.name,
label: m.label || m.name,
@@ -5818,7 +5816,7 @@ async function loadAdminModelStore() {
multiplier: m.multiplier || getTierMultiplier(m.tier),
})).filter((m) => !!m.name);
// Ensure all public models have required fields
// Validate public models
publicModels = publicModels.map((m) => ({
id: m.id || randomUUID(),
name: m.name,
@@ -5829,25 +5827,23 @@ async function loadAdminModelStore() {
multiplier: m.multiplier || getTierMultiplier(m.tier),
})).filter((m) => !!m.name);
// Ensure all provider chain entries have required fields
providerChain = providerChain.map((p, idx) => ({
// Validate chain
opencodeChain = opencodeChain.map((p) => ({
provider: normalizeProviderName(p.provider || 'opencode'),
model: (p.model || '').trim(),
primary: idx === 0,
})).filter((p) => !!p.model);
refreshAdminModelIndex();
log('Loaded admin model store', {
providerModels: providerModels.length,
publicModels: publicModels.length,
providerChain: providerChain.length,
legacyAdminModels: adminModels.length
opencodeModels: opencodeModels.length,
opencodeChain: opencodeChain.length,
publicModels: publicModels.length,
});
} catch (error) {
log('Failed to load admin models, starting empty', { error: String(error) });
providerModels = [];
opencodeModels = [];
opencodeChain = [];
publicModels = [];
providerChain = [];
adminModels = [];
refreshAdminModelIndex();
}
@@ -5856,13 +5852,13 @@ async function loadAdminModelStore() {
async function persistAdminModels() {
await ensureStateFile();
await ensureAssetsDir();
// Save new unified structure
// Save clean structure
const payload = JSON.stringify({
providerModels,
opencodeModels,
opencodeChain,
publicModels,
providerChain,
adminModels, // Keep legacy for backwards compatibility
version: 3, // Schema version - added providerModels
version: 4, // Clean structure
}, null, 2);
await safeWriteFile(ADMIN_MODELS_FILE, payload);
refreshAdminModelIndex();
@@ -10278,8 +10274,8 @@ function buildOpencodeAttemptChain(cli, preferredModel) {
const chain = [];
const seen = new Set();
// Build chain from unified providerChain with user's preferred model
providerChain.forEach((p, idx) => {
// Build chain from opencodeChain with user's preferred model
opencodeChain.forEach((p, idx) => {
const modelToUse = idx === 0 && preferredModel ? preferredModel : p.model;
const key = `${p.provider}:${modelToUse}`;
if (seen.has(key)) return;
@@ -10293,31 +10289,15 @@ function buildOpencodeAttemptChain(cli, preferredModel) {
});
});
// If no provider chain, fall back to old behavior
// If no chain configured, fall back to default
if (chain.length === 0) {
const addProviderOptions = (modelName) => {
const providers = resolveModelProviders(modelName);
providers.forEach((p, idx) => {
const key = `${p.provider}:${p.model || modelName}`;
if (seen.has(key)) return;
seen.add(key);
chain.push({
provider: p.provider,
model: p.model || modelName,
primary: typeof p.primary === 'boolean' ? p.primary : idx === 0,
cli: normalizeCli(p.cli || cli || 'opencode'),
sourceModel: modelName,
});
});
};
if (typeof preferredModel === 'string' && preferredModel.trim()) {
addProviderOptions(preferredModel);
}
getConfiguredModels(cli).forEach((m) => {
if (m.name && m.name !== preferredModel) addProviderOptions(m.name);
chain.push({
provider: 'opencode',
model: preferredModel || 'default',
primary: true,
cli: normalizeCli(cli || 'opencode'),
sourceModel: preferredModel || 'default',
});
addProviderOptions('default');
}
// Log the built chain for debugging
@@ -11358,20 +11338,20 @@ async function processMessage(sessionId, message) {
function getConfiguredModels(cliParam = 'opencode') {
const cli = normalizeCli(cliParam || 'opencode');
// Use new publicModels array - filter by CLI if needed (though public models don't have CLI field)
const mapped = publicModels.map((m) => ({
// Return opencode models for backwards compatibility
const mapped = opencodeModels.map((m) => ({
id: m.id,
name: m.name,
label: m.label || m.name,
icon: m.icon || '',
cli: cli, // All public models work with opencode CLI
providers: providerChain, // Use unified provider chain
primaryProvider: providerChain[0]?.provider || 'opencode',
cli: cli,
providers: opencodeChain,
primaryProvider: opencodeChain[0]?.provider || 'opencode',
tier: m.tier || 'free',
multiplier: getTierMultiplier(m.tier || 'free'),
supportsMedia: m.supportsMedia ?? false,
}));
return mapped.sort((a, b) => (a.label || '').localeCompare(b.label || ''));
return mapped;
}
async function handleModels(_req, res, cliParam = null) {
@@ -15874,11 +15854,11 @@ async function handleAdminListIcons(req, res) {
async function handleAdminModelsList(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
// Return new unified structure
// Return clean structure
sendJson(res, 200, {
providerModels,
opencodeModels,
opencodeChain,
publicModels,
providerChain,
// Legacy support
models: getConfiguredModels('opencode'),
});
@@ -15889,132 +15869,54 @@ async function handleAdminModelUpsert(req, res) {
if (!session) return;
try {
const body = await parseJsonBody(req);
const modelName = (body.name || body.model || '').trim();
const label = (body.label || body.displayName || modelName).trim();
const tier = normalizeTier(body.tier);
// Check if this is a provider model, public model, or provider chain update
if (body.type === 'providerModel') {
// Handle provider model update (from OpenCode)
const modelName = (body.name || body.model || '').trim();
const label = (body.label || body.displayName || modelName).trim();
const tier = normalizeTier(body.tier);
if (!modelName) return sendJson(res, 400, { error: 'Model name is required' });
const id = body.id || randomUUID();
const existingIndex = providerModels.findIndex((m) => m.id === id);
let icon = providerModels[existingIndex]?.icon || '';
if (typeof body.icon === 'string' && body.icon.trim()) {
icon = await normalizeIconPath(body.icon);
if (!icon) return sendJson(res, 400, { error: 'Icon must be stored in /assets' });
}
const supportsMedia = typeof body.supportsMedia === 'boolean' ? body.supportsMedia : false;
const payload = {
id,
name: modelName,
label: label || modelName,
icon,
tier,
supportsMedia,
multiplier: getTierMultiplier(tier),
};
if (existingIndex >= 0) providerModels[existingIndex] = { ...providerModels[existingIndex], ...payload };
else providerModels.push(payload);
if (!modelName) return sendJson(res, 400, { error: 'Model name is required' });
let icon = '';
if (typeof body.icon === 'string' && body.icon.trim()) {
icon = await normalizeIconPath(body.icon) || '';
}
const supportsMedia = typeof body.supportsMedia === 'boolean' ? body.supportsMedia : false;
const payload = {
id: body.id || randomUUID(),
name: modelName,
label: label || modelName,
icon,
tier,
supportsMedia,
multiplier: getTierMultiplier(tier),
};
if (body.type === 'opencode') {
// Add to opencode models
const existingIndex = body.id ? opencodeModels.findIndex((m) => m.id === body.id) : -1;
if (existingIndex >= 0) opencodeModels[existingIndex] = { ...opencodeModels[existingIndex], ...payload };
else opencodeModels.push(payload);
await persistAdminModels();
sendJson(res, 200, {
providerModel: payload,
providerModels,
opencodeModels,
opencodeChain,
publicModels,
providerChain,
models: getConfiguredModels('opencode'),
});
} else if (body.type === 'publicModel') {
// Handle public model update (completely separate from OpenCode)
const modelName = (body.name || body.model || '').trim();
const label = (body.label || body.displayName || modelName).trim();
const tier = normalizeTier(body.tier);
if (!modelName) return sendJson(res, 400, { error: 'Model name is required' });
const id = body.id || randomUUID();
const existingIndex = publicModels.findIndex((m) => m.id === id);
let icon = publicModels[existingIndex]?.icon || '';
if (typeof body.icon === 'string' && body.icon.trim()) {
icon = await normalizeIconPath(body.icon);
if (!icon) return sendJson(res, 400, { error: 'Icon must be stored in /assets' });
}
const supportsMedia = typeof body.supportsMedia === 'boolean' ? body.supportsMedia : false;
const payload = {
id,
name: modelName,
label: label || modelName,
icon,
tier,
supportsMedia,
multiplier: getTierMultiplier(tier),
};
} else if (body.type === 'public') {
// Add to public models
const existingIndex = body.id ? publicModels.findIndex((m) => m.id === body.id) : -1;
if (existingIndex >= 0) publicModels[existingIndex] = { ...publicModels[existingIndex], ...payload };
else publicModels.push(payload);
await persistAdminModels();
sendJson(res, 200, {
publicModel: payload,
providerModels,
opencodeModels,
opencodeChain,
publicModels,
providerChain,
models: getConfiguredModels('opencode'),
});
} else if (body.type === 'providerChain') {
// Handle provider chain update
if (!Array.isArray(body.chain)) {
return sendJson(res, 400, { error: 'Provider chain must be an array' });
}
providerChain = body.chain.map((p, idx) => ({
provider: normalizeProviderName(p.provider || 'opencode'),
model: (p.model || '').trim(),
primary: idx === 0,
})).filter((p) => !!p.model);
await persistAdminModels();
sendJson(res, 200, {
providerChain,
publicModels: publicModels.sort((a, b) => (a.label || '').localeCompare(b.label || '')),
models: getConfiguredModels('opencode'),
});
} else {
// Legacy support - treat as public model
const modelName = (body.name || body.model || '').trim();
if (!modelName) return sendJson(res, 400, { error: 'Model name is required' });
const id = body.id || randomUUID();
const label = (body.label || body.displayName || modelName).trim();
const tier = normalizeTier(body.tier);
let icon = '';
if (typeof body.icon === 'string' && body.icon.trim()) {
icon = await normalizeIconPath(body.icon) || '';
}
const supportsMedia = typeof body.supportsMedia === 'boolean' ? body.supportsMedia : false;
const payload = {
id,
name: modelName,
label: label || modelName,
icon,
tier,
supportsMedia,
multiplier: getTierMultiplier(tier),
};
const existingIndex = publicModels.findIndex((m) => m.id === id);
if (existingIndex >= 0) publicModels[existingIndex] = { ...publicModels[existingIndex], ...payload };
else publicModels.push(payload);
await persistAdminModels();
sendJson(res, 200, {
model: payload,
publicModels: publicModels,
providerChain,
models: getConfiguredModels('opencode'),
});
return sendJson(res, 400, { error: 'Invalid type. Use "opencode" or "public"' });
}
} catch (error) {
sendJson(res, 400, { error: error.message || 'Unable to save model' });
@@ -16030,11 +15932,11 @@ async function handleAdminModelsReorder(req, res) {
return sendJson(res, 400, { error: 'models array is required' });
}
const reorderType = body.type || 'publicModels';
const reorderType = body.type || 'public';
if (reorderType === 'providerModels') {
// Validate that all provided IDs exist in providerModels
const currentIds = new Set(providerModels.map(m => m.id));
if (reorderType === 'opencode') {
// Validate that all provided IDs exist in opencodeModels
const currentIds = new Set(opencodeModels.map(m => m.id));
const newIds = body.models.map(m => m.id);
const allExist = newIds.every(id => currentIds.has(id));
@@ -16042,14 +15944,14 @@ async function handleAdminModelsReorder(req, res) {
return sendJson(res, 400, { error: 'Invalid model IDs in reorder request' });
}
// Reorder providerModels based on the provided order
// Reorder opencodeModels based on the provided order
const reordered = [];
body.models.forEach(m => {
const model = providerModels.find(pm => pm.id === m.id);
const model = opencodeModels.find(pm => pm.id === m.id);
if (model) reordered.push(model);
});
providerModels = reordered;
opencodeModels = reordered;
} else {
// Validate that all provided IDs exist in publicModels
const currentIds = new Set(publicModels.map(m => m.id));
@@ -16074,10 +15976,9 @@ async function handleAdminModelsReorder(req, res) {
sendJson(res, 200, {
ok: true,
providerModels,
opencodeModels,
opencodeChain,
publicModels,
providerChain,
models: getConfiguredModels('opencode'),
});
} catch (error) {
sendJson(res, 400, { error: error.message || 'Unable to reorder models' });
@@ -16088,30 +15989,93 @@ async function handleAdminModelDelete(req, res, id) {
const session = requireAdminAuth(req, res);
if (!session) return;
// Try to delete from providerModels first
const beforeProvider = providerModels.length;
providerModels = providerModels.filter((m) => m.id !== id);
const deletedFromProvider = providerModels.length < beforeProvider;
const url = new URL(req.url, `http://${req.headers.host}`);
const deleteType = url.searchParams.get('type') || 'public';
// Try to delete from publicModels
const beforePublic = publicModels.length;
publicModels = publicModels.filter((m) => m.id !== id);
const deletedFromPublic = publicModels.length < beforePublic;
if (!deletedFromProvider && !deletedFromPublic) {
return sendJson(res, 404, { error: 'Model not found' });
if (deleteType === 'opencode') {
const before = opencodeModels.length;
opencodeModels = opencodeModels.filter((m) => m.id !== id);
if (opencodeModels.length === before) {
return sendJson(res, 404, { error: 'Model not found' });
}
} else {
const before = publicModels.length;
publicModels = publicModels.filter((m) => m.id !== id);
if (publicModels.length === before) {
return sendJson(res, 404, { error: 'Model not found' });
}
}
await persistAdminModels();
sendJson(res, 200, {
ok: true,
providerModels,
opencodeModels,
opencodeChain,
publicModels,
providerChain,
models: getConfiguredModels('opencode'),
});
}
// Chain handlers
async function handleAdminChainPost(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
try {
const body = await parseJsonBody(req);
if (!Array.isArray(body.chain)) {
return sendJson(res, 400, { error: 'chain must be an array' });
}
opencodeChain = body.chain.map((p) => ({
provider: normalizeProviderName(p.provider || 'opencode'),
model: (p.model || '').trim(),
})).filter((p) => !!p.model);
await persistAdminModels();
sendJson(res, 200, {
opencodeChain,
opencodeModels,
publicModels,
});
} catch (error) {
sendJson(res, 400, { error: error.message || 'Unable to update chain' });
}
}
async function handleAdminChainReorder(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
try {
const body = await parseJsonBody(req);
if (!Array.isArray(body.chain)) {
return sendJson(res, 400, { error: 'chain array is required' });
}
// Validate all entries
const currentIds = new Set(opencodeChain.map((_, i) => i));
const newIds = body.chain.map((_, i) => i);
const allExist = newIds.every((_, i) => currentIds.has(i));
if (!allExist || body.chain.length !== opencodeChain.length) {
return sendJson(res, 400, { error: 'Invalid chain order' });
}
// Reorder
opencodeChain = body.chain.map((p) => ({
provider: normalizeProviderName(p.provider || 'opencode'),
model: (p.model || '').trim(),
})).filter((p) => !!p.model);
await persistAdminModels();
sendJson(res, 200, {
opencodeChain,
opencodeModels,
publicModels,
});
} catch (error) {
sendJson(res, 400, { error: error.message || 'Unable to reorder chain' });
}
}
async function handleAdminOpenRouterSettingsGet(req, res) {
const session = requireAdminAuth(req, res);
if (!session) return;
@@ -19012,6 +18976,8 @@ async function routeInternal(req, res, url, pathname) {
if (req.method === 'GET' && pathname === '/api/admin/models') return handleAdminModelsList(req, res);
if (req.method === 'POST' && pathname === '/api/admin/models') return handleAdminModelUpsert(req, res);
if (req.method === 'POST' && pathname === '/api/admin/models/reorder') return handleAdminModelsReorder(req, res);
if (req.method === 'POST' && pathname === '/api/admin/chain') return handleAdminChainPost(req, res);
if (req.method === 'POST' && pathname === '/api/admin/chain/reorder') return handleAdminChainReorder(req, res);
if (req.method === 'GET' && pathname === '/api/admin/openrouter-settings') return handleAdminOpenRouterSettingsGet(req, res);
if (req.method === 'POST' && pathname === '/api/admin/openrouter-settings') return handleAdminOpenRouterSettingsPost(req, res);
if (req.method === 'GET' && pathname === '/api/admin/mistral-settings') return handleAdminMistralSettingsGet(req, res);