Add unified model chain system with public models and provider chain
- Add publicModels and providerChain data structures for unified fallback - Add two separate model adding sections in admin panel (public-facing and provider models) - Add up/down buttons to reorder provider chain order - Update server to use unified chain for all model fallbacks - Auto-migrate legacy data on first load - Update admin.js to handle new model structure and forms
This commit is contained in:
@@ -68,9 +68,97 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-grid">
|
<div class="admin-grid">
|
||||||
|
<!-- Public Facing Models Section -->
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
<header>
|
<header>
|
||||||
<h3>Add / Update Model</h3>
|
<h3>Add Public-Facing Model</h3>
|
||||||
|
<div class="pill">Public</div>
|
||||||
|
</header>
|
||||||
|
<p style="margin-top:0; color: var(--muted);">These models are displayed to users in the builder dropdown. They all share the same unified provider fallback chain.</p>
|
||||||
|
<form id="public-model-form" class="admin-form">
|
||||||
|
<label>
|
||||||
|
Model ID (e.g., claude-3-5-sonnet, gpt-4o)
|
||||||
|
<input id="public-model-name" type="text" placeholder="Model ID from OpenCode" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Display name shown to users
|
||||||
|
<input id="public-model-label" type="text" placeholder="Friendly label (e.g., Claude 3.5 Sonnet)" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Model tier (for plan limits)
|
||||||
|
<select id="public-model-tier">
|
||||||
|
<option value="free">Free (1x multiplier)</option>
|
||||||
|
<option value="plus">Plus (2x multiplier)</option>
|
||||||
|
<option value="pro">Pro (3x multiplier)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Icon (files in /assets)
|
||||||
|
<select id="public-model-icon">
|
||||||
|
<option value="">No icon</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; gap: 8px; margin-top: 8px;">
|
||||||
|
<input id="public-model-media" type="checkbox" style="width: auto;" />
|
||||||
|
<span>Supports image uploads</span>
|
||||||
|
</label>
|
||||||
|
<div class="admin-actions">
|
||||||
|
<button type="submit" class="primary">Add Public Model</button>
|
||||||
|
</div>
|
||||||
|
<div class="status-line" id="public-model-status"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Provider Model Chain Section -->
|
||||||
|
<div class="admin-card">
|
||||||
|
<header>
|
||||||
|
<h3>Provider Model Chain</h3>
|
||||||
|
<div class="pill">Backend</div>
|
||||||
|
</header>
|
||||||
|
<p style="margin-top:0; color: var(--muted);">This is the unified fallback chain used for ALL models. When rate limits are reached or errors occur, the system automatically falls back to the next provider in this chain.</p>
|
||||||
|
<form id="provider-chain-form" class="admin-form">
|
||||||
|
<label>
|
||||||
|
Provider
|
||||||
|
<select id="chain-provider">
|
||||||
|
<option value="openrouter">OpenRouter</option>
|
||||||
|
<option value="mistral">Mistral</option>
|
||||||
|
<option value="google">Google</option>
|
||||||
|
<option value="groq">Groq</option>
|
||||||
|
<option value="nvidia">NVIDIA</option>
|
||||||
|
<option value="chutes">Chutes</option>
|
||||||
|
<option value="cerebras">Cerebras</option>
|
||||||
|
<option value="ollama">Ollama</option>
|
||||||
|
<option value="opencode">OpenCode</option>
|
||||||
|
<option value="cohere">Cohere</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Model Name
|
||||||
|
<input id="chain-model" type="text" placeholder="e.g., anthropic/claude-3.5-sonnet" required />
|
||||||
|
</label>
|
||||||
|
<div class="admin-actions">
|
||||||
|
<button type="submit" class="primary">Add to Chain</button>
|
||||||
|
</div>
|
||||||
|
<div class="status-line" id="provider-chain-status"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Provider Chain List with Ordering -->
|
||||||
|
<div class="admin-card" style="margin-top: 16px;">
|
||||||
|
<header>
|
||||||
|
<h3>Unified Provider Chain Order</h3>
|
||||||
|
<div class="pill" id="provider-chain-count">0</div>
|
||||||
|
</header>
|
||||||
|
<p class="muted" style="margin-top:0;">Arrange the order of providers below. The system will try each provider in order when rate limits are reached or errors occur. The first provider is the primary.</p>
|
||||||
|
<div id="provider-chain-list" class="admin-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-grid" style="margin-top: 16px;">
|
||||||
|
<!-- Legacy Model Form (hidden but kept for compatibility) -->
|
||||||
|
<div class="admin-card" style="display: none;">
|
||||||
|
<header>
|
||||||
|
<h3>Add / Update Model (Legacy)</h3>
|
||||||
<div class="pill">Step 1</div>
|
<div class="pill">Step 1</div>
|
||||||
</header>
|
</header>
|
||||||
<form id="model-form" class="admin-form">
|
<form id="model-form" class="admin-form">
|
||||||
@@ -242,12 +330,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Public Models List -->
|
||||||
<div class="admin-card" style="margin-top: 16px;">
|
<div class="admin-card" style="margin-top: 16px;">
|
||||||
<header>
|
<header>
|
||||||
<h3>Models available to users</h3>
|
<h3>Public-Facing Models</h3>
|
||||||
<div class="pill" id="configured-count">0</div>
|
<div class="pill" id="configured-count">0</div>
|
||||||
</header>
|
</header>
|
||||||
<p class="muted" style="margin-top:0;">One row per model. Arrange provider order to control automatic fallback when a provider errors or hits a rate limit.</p>
|
<p class="muted" style="margin-top:0;">These models are displayed to users in the builder. All models use the unified provider chain above for fallback.</p>
|
||||||
<div id="configured-list" class="admin-list"></div>
|
<div id="configured-list" class="admin-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
const state = {
|
const state = {
|
||||||
available: [],
|
available: [],
|
||||||
configured: [],
|
configured: [],
|
||||||
|
publicModels: [], // New unified structure - public facing models
|
||||||
|
providerChain: [], // New unified structure - provider fallback chain
|
||||||
icons: [],
|
icons: [],
|
||||||
accounts: [],
|
accounts: [],
|
||||||
affiliates: [],
|
affiliates: [],
|
||||||
@@ -20,6 +22,21 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const el = {
|
const el = {
|
||||||
|
// New unified model elements
|
||||||
|
publicModelForm: document.getElementById('public-model-form'),
|
||||||
|
publicModelName: document.getElementById('public-model-name'),
|
||||||
|
publicModelLabel: document.getElementById('public-model-label'),
|
||||||
|
publicModelTier: document.getElementById('public-model-tier'),
|
||||||
|
publicModelIcon: document.getElementById('public-model-icon'),
|
||||||
|
publicModelMedia: document.getElementById('public-model-media'),
|
||||||
|
publicModelStatus: document.getElementById('public-model-status'),
|
||||||
|
providerChainForm: document.getElementById('provider-chain-form'),
|
||||||
|
chainProvider: document.getElementById('chain-provider'),
|
||||||
|
chainModel: document.getElementById('chain-model'),
|
||||||
|
providerChainStatus: document.getElementById('provider-chain-status'),
|
||||||
|
providerChainList: document.getElementById('provider-chain-list'),
|
||||||
|
providerChainCount: document.getElementById('provider-chain-count'),
|
||||||
|
// Legacy elements
|
||||||
availableModels: document.getElementById('available-models'),
|
availableModels: document.getElementById('available-models'),
|
||||||
displayLabel: document.getElementById('display-label'),
|
displayLabel: document.getElementById('display-label'),
|
||||||
modelTier: document.getElementById('model-tier'),
|
modelTier: document.getElementById('model-tier'),
|
||||||
@@ -181,6 +198,18 @@
|
|||||||
el.autoModelStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
el.autoModelStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setPublicModelStatus(msg, isError = false) {
|
||||||
|
if (!el.publicModelStatus) return;
|
||||||
|
el.publicModelStatus.textContent = msg || '';
|
||||||
|
el.publicModelStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProviderChainStatus(msg, isError = false) {
|
||||||
|
if (!el.providerChainStatus) return;
|
||||||
|
el.providerChainStatus.textContent = msg || '';
|
||||||
|
el.providerChainStatus.style.color = isError ? 'var(--danger)' : 'inherit';
|
||||||
|
}
|
||||||
|
|
||||||
function setOpencodeBackupStatus(msg, isError = false) {
|
function setOpencodeBackupStatus(msg, isError = false) {
|
||||||
if (!el.opencodeBackupStatus) return;
|
if (!el.opencodeBackupStatus) return;
|
||||||
el.opencodeBackupStatus.textContent = msg || '';
|
el.opencodeBackupStatus.textContent = msg || '';
|
||||||
@@ -502,6 +531,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderIcons() {
|
function renderIcons() {
|
||||||
|
// Populate legacy icon select
|
||||||
if (el.iconSelect) {
|
if (el.iconSelect) {
|
||||||
el.iconSelect.innerHTML = '';
|
el.iconSelect.innerHTML = '';
|
||||||
const none = document.createElement('option');
|
const none = document.createElement('option');
|
||||||
@@ -516,6 +546,21 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate new public model icon select
|
||||||
|
if (el.publicModelIcon) {
|
||||||
|
el.publicModelIcon.innerHTML = '';
|
||||||
|
const none = document.createElement('option');
|
||||||
|
none.value = '';
|
||||||
|
none.textContent = 'No icon';
|
||||||
|
el.publicModelIcon.appendChild(none);
|
||||||
|
state.icons.forEach((iconPath) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = iconPath;
|
||||||
|
opt.textContent = iconPath.replace('/assets/', '');
|
||||||
|
el.publicModelIcon.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (el.iconList) {
|
if (el.iconList) {
|
||||||
el.iconList.innerHTML = '';
|
el.iconList.innerHTML = '';
|
||||||
if (!state.icons.length) {
|
if (!state.icons.length) {
|
||||||
@@ -543,116 +588,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simplified renderConfigured for new publicModels structure
|
||||||
function renderConfigured() {
|
function renderConfigured() {
|
||||||
if (!el.configuredList) return;
|
if (!el.configuredList) return;
|
||||||
el.configuredList.innerHTML = '';
|
el.configuredList.innerHTML = '';
|
||||||
if (el.configuredCount) el.configuredCount.textContent = state.configured.length.toString();
|
|
||||||
if (!state.configured.length) {
|
// Use publicModels if available, otherwise fall back to configured
|
||||||
|
const modelsToRender = state.publicModels.length > 0 ? state.publicModels : state.configured;
|
||||||
|
|
||||||
|
if (el.configuredCount) el.configuredCount.textContent = modelsToRender.length.toString();
|
||||||
|
if (!modelsToRender.length) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'muted';
|
empty.className = 'muted';
|
||||||
empty.textContent = 'No models published to users yet.';
|
empty.textContent = 'No public-facing models configured yet.';
|
||||||
el.configuredList.appendChild(empty);
|
el.configuredList.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
function normalizeProviders(model) {
|
|
||||||
const providers = Array.isArray(model.providers) ? model.providers : [];
|
|
||||||
if (!providers.length) return [{ provider: 'opencode', model: model.name, primary: true }];
|
|
||||||
return providers.map((p, idx) => ({
|
|
||||||
provider: p.provider || 'opencode',
|
|
||||||
model: p.model || model.name,
|
|
||||||
primary: idx === 0 ? true : !!p.primary,
|
|
||||||
})).map((p, idx) => ({ ...p, primary: idx === 0 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function reorderProviders(list, from, to) {
|
modelsToRender.forEach((m) => {
|
||||||
const next = [...list];
|
|
||||||
const [item] = next.splice(from, 1);
|
|
||||||
next.splice(to, 0, item);
|
|
||||||
return next.map((p, idx) => ({ ...p, primary: idx === 0 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function persistProviderChanges(model, nextProviders, nextIcon, nextSupportsMedia, nextTier) {
|
|
||||||
setStatus('Saving provider order...');
|
|
||||||
const currentModel = state.configured.find((m) => m.id === model.id) || model;
|
|
||||||
const payload = {
|
|
||||||
id: model.id,
|
|
||||||
name: currentModel.name,
|
|
||||||
label: currentModel.label || currentModel.name,
|
|
||||||
icon: nextIcon !== undefined ? nextIcon : (currentModel.icon || ''),
|
|
||||||
cli: currentModel.cli || 'opencode',
|
|
||||||
providers: nextProviders.map((p, idx) => ({
|
|
||||||
provider: p.provider || 'opencode',
|
|
||||||
model: p.model || currentModel.name,
|
|
||||||
primary: idx === 0,
|
|
||||||
})),
|
|
||||||
tier: nextTier !== undefined ? nextTier : (currentModel.tier || 'free'),
|
|
||||||
supportsMedia: nextSupportsMedia !== undefined ? nextSupportsMedia : (currentModel.supportsMedia ?? false),
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const data = await api('/api/admin/models', { method: 'POST', body: JSON.stringify(payload) });
|
|
||||||
const updated = data.model || payload;
|
|
||||||
const idx = state.configured.findIndex((cm) => cm.id === model.id);
|
|
||||||
if (idx >= 0) state.configured[idx] = { ...state.configured[idx], ...updated };
|
|
||||||
else state.configured.push(updated);
|
|
||||||
renderConfigured();
|
|
||||||
setStatus('Saved');
|
|
||||||
setTimeout(() => setStatus(''), 1500);
|
|
||||||
} catch (err) {
|
|
||||||
setStatus(err.message, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLimitSummary(provider, modelName) {
|
|
||||||
const cfg = state.providerLimits && state.providerLimits[provider];
|
|
||||||
if (!cfg) return 'No limits set';
|
|
||||||
const isPerModelScope = cfg.scope === 'model';
|
|
||||||
const hasModelSpecificLimit = isPerModelScope && modelName && cfg.perModel && cfg.perModel[modelName];
|
|
||||||
const target = hasModelSpecificLimit ? cfg.perModel[modelName] : cfg;
|
|
||||||
const parts = [];
|
|
||||||
if (target.requestsPerMinute) parts.push(`${target.requestsPerMinute}/m`);
|
|
||||||
if (target.tokensPerMinute) parts.push(`${target.tokensPerMinute} tpm`);
|
|
||||||
if (target.requestsPerDay) parts.push(`${target.requestsPerDay}/day`);
|
|
||||||
if (target.tokensPerDay) parts.push(`${target.tokensPerDay} tpd`);
|
|
||||||
|
|
||||||
if (!parts.length) {
|
|
||||||
if (isPerModelScope && !hasModelSpecificLimit) {
|
|
||||||
return 'No limit for this model';
|
|
||||||
}
|
|
||||||
return isPerModelScope ? 'Provider limits apply' : 'Unlimited';
|
|
||||||
}
|
|
||||||
|
|
||||||
const limitStr = parts.join(' · ');
|
|
||||||
return hasModelSpecificLimit ? `${limitStr} (per-model)` : limitStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.configured.forEach((m) => {
|
|
||||||
const providers = normalizeProviders(m);
|
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'provider-row slim';
|
row.className = 'provider-row slim';
|
||||||
|
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'provider-row-header';
|
header.className = 'provider-row-header';
|
||||||
|
|
||||||
const info = document.createElement('div');
|
const info = document.createElement('div');
|
||||||
info.className = 'model-chip';
|
info.className = 'model-chip';
|
||||||
|
|
||||||
if (m.icon) {
|
if (m.icon) {
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.src = m.icon;
|
img.src = m.icon;
|
||||||
img.alt = '';
|
img.alt = '';
|
||||||
info.appendChild(img);
|
info.appendChild(img);
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
label.textContent = m.label || m.name;
|
label.textContent = m.label || m.name;
|
||||||
info.appendChild(label);
|
info.appendChild(label);
|
||||||
|
|
||||||
const namePill = document.createElement('span');
|
const namePill = document.createElement('span');
|
||||||
namePill.className = 'pill';
|
namePill.className = 'pill';
|
||||||
namePill.textContent = m.name;
|
namePill.textContent = m.name;
|
||||||
info.appendChild(namePill);
|
info.appendChild(namePill);
|
||||||
|
|
||||||
const tierMeta = document.createElement('span');
|
const tierMeta = document.createElement('span');
|
||||||
tierMeta.className = 'pill';
|
tierMeta.className = 'pill';
|
||||||
const tierName = (m.tier || 'free').toUpperCase();
|
const tierName = (m.tier || 'free').toUpperCase();
|
||||||
const multiplier = m.tier === 'pro' ? 3 : (m.tier === 'plus' ? 2 : 1);
|
const multiplier = m.tier === 'pro' ? 3 : (m.tier === 'plus' ? 2 : 1);
|
||||||
tierMeta.textContent = `${tierName} (${multiplier}x)`;
|
tierMeta.textContent = `${tierName} (${multiplier}x)`;
|
||||||
info.appendChild(tierMeta);
|
info.appendChild(tierMeta);
|
||||||
|
|
||||||
if (m.supportsMedia) {
|
if (m.supportsMedia) {
|
||||||
const mediaBadge = document.createElement('span');
|
const mediaBadge = document.createElement('span');
|
||||||
mediaBadge.className = 'pill';
|
mediaBadge.className = 'pill';
|
||||||
@@ -660,14 +645,19 @@
|
|||||||
mediaBadge.textContent = 'Media';
|
mediaBadge.textContent = 'Media';
|
||||||
info.appendChild(mediaBadge);
|
info.appendChild(mediaBadge);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show chain info badge
|
||||||
|
const chainBadge = document.createElement('span');
|
||||||
|
chainBadge.className = 'pill';
|
||||||
|
chainBadge.style.background = 'var(--primary)';
|
||||||
|
chainBadge.textContent = `Uses unified chain (${state.providerChain.length} providers)`;
|
||||||
|
info.appendChild(chainBadge);
|
||||||
|
|
||||||
header.appendChild(info);
|
header.appendChild(info);
|
||||||
|
|
||||||
const headerActions = document.createElement('div');
|
const headerActions = document.createElement('div');
|
||||||
headerActions.className = 'provider-row-actions';
|
headerActions.className = 'provider-row-actions';
|
||||||
const fallbackBadge = document.createElement('div');
|
|
||||||
fallbackBadge.className = 'pill';
|
|
||||||
fallbackBadge.textContent = 'Auto fallback on error/rate limit';
|
|
||||||
headerActions.appendChild(fallbackBadge);
|
|
||||||
const delBtn = document.createElement('button');
|
const delBtn = document.createElement('button');
|
||||||
delBtn.className = 'ghost';
|
delBtn.className = 'ghost';
|
||||||
delBtn.textContent = 'Delete';
|
delBtn.textContent = 'Delete';
|
||||||
@@ -688,7 +678,6 @@
|
|||||||
editIconBtn.className = 'ghost';
|
editIconBtn.className = 'ghost';
|
||||||
editIconBtn.textContent = 'Edit icon';
|
editIconBtn.textContent = 'Edit icon';
|
||||||
editIconBtn.addEventListener('click', () => {
|
editIconBtn.addEventListener('click', () => {
|
||||||
// Toggle editor
|
|
||||||
let editor = header.querySelector('.icon-editor');
|
let editor = header.querySelector('.icon-editor');
|
||||||
if (editor) return editor.remove();
|
if (editor) return editor.remove();
|
||||||
editor = document.createElement('div');
|
editor = document.createElement('div');
|
||||||
@@ -714,7 +703,7 @@
|
|||||||
saveBtn.addEventListener('click', async () => {
|
saveBtn.addEventListener('click', async () => {
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
await persistProviderChanges(m, providers, sel.value, undefined);
|
await persistPublicModelChanges(m.id, { icon: sel.value });
|
||||||
} catch (err) { setStatus(err.message, true); }
|
} catch (err) { setStatus(err.message, true); }
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
});
|
});
|
||||||
@@ -742,7 +731,7 @@
|
|||||||
mediaCheckbox.addEventListener('change', async () => {
|
mediaCheckbox.addEventListener('change', async () => {
|
||||||
mediaCheckbox.disabled = true;
|
mediaCheckbox.disabled = true;
|
||||||
try {
|
try {
|
||||||
await persistProviderChanges(m, providers, undefined, mediaCheckbox.checked);
|
await persistPublicModelChanges(m.id, { supportsMedia: mediaCheckbox.checked });
|
||||||
} catch (err) { setStatus(err.message, true); }
|
} catch (err) { setStatus(err.message, true); }
|
||||||
mediaCheckbox.disabled = false;
|
mediaCheckbox.disabled = false;
|
||||||
});
|
});
|
||||||
@@ -785,7 +774,7 @@
|
|||||||
saveBtn.addEventListener('click', async () => {
|
saveBtn.addEventListener('click', async () => {
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
await persistProviderChanges(m, providers, undefined, undefined, sel.value);
|
await persistPublicModelChanges(m.id, { tier: sel.value });
|
||||||
} catch (err) { setStatus(err.message, true); }
|
} catch (err) { setStatus(err.message, true); }
|
||||||
saveBtn.disabled = false;
|
saveBtn.disabled = false;
|
||||||
});
|
});
|
||||||
@@ -803,170 +792,154 @@
|
|||||||
|
|
||||||
header.appendChild(headerActions);
|
header.appendChild(headerActions);
|
||||||
row.appendChild(header);
|
row.appendChild(header);
|
||||||
|
|
||||||
const providerList = document.createElement('div');
|
|
||||||
providerList.className = 'provider-pill-row';
|
|
||||||
|
|
||||||
providers.forEach((p, idx) => {
|
|
||||||
const card = document.createElement('div');
|
|
||||||
card.className = 'provider-card compact';
|
|
||||||
card.draggable = true;
|
|
||||||
card.dataset.index = idx.toString();
|
|
||||||
|
|
||||||
const stack = document.createElement('div');
|
|
||||||
stack.className = 'model-chip';
|
|
||||||
|
|
||||||
const order = document.createElement('span');
|
|
||||||
order.className = 'pill';
|
|
||||||
order.textContent = `#${idx + 1}`;
|
|
||||||
stack.appendChild(order);
|
|
||||||
|
|
||||||
const providerPill = document.createElement('span');
|
|
||||||
providerPill.textContent = p.provider;
|
|
||||||
stack.appendChild(providerPill);
|
|
||||||
|
|
||||||
const modelPill = document.createElement('span');
|
|
||||||
modelPill.className = 'pill';
|
|
||||||
modelPill.textContent = p.model || m.name;
|
|
||||||
stack.appendChild(modelPill);
|
|
||||||
|
|
||||||
const limitPill = document.createElement('span');
|
|
||||||
limitPill.className = 'pill';
|
|
||||||
limitPill.textContent = formatLimitSummary(p.provider, p.model || m.name);
|
|
||||||
stack.appendChild(limitPill);
|
|
||||||
|
|
||||||
card.appendChild(stack);
|
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
|
||||||
actions.className = 'provider-row-actions';
|
|
||||||
|
|
||||||
const upBtn = document.createElement('button');
|
|
||||||
upBtn.className = 'ghost';
|
|
||||||
upBtn.textContent = '↑';
|
|
||||||
upBtn.title = 'Move up';
|
|
||||||
upBtn.disabled = idx === 0;
|
|
||||||
upBtn.addEventListener('click', async () => {
|
|
||||||
const next = reorderProviders(providers, idx, Math.max(0, idx - 1));
|
|
||||||
await persistProviderChanges(m, next, undefined);
|
|
||||||
});
|
|
||||||
actions.appendChild(upBtn);
|
|
||||||
|
|
||||||
const downBtn = document.createElement('button');
|
|
||||||
downBtn.className = 'ghost';
|
|
||||||
downBtn.textContent = '↓';
|
|
||||||
downBtn.title = 'Move down';
|
|
||||||
downBtn.disabled = idx === providers.length - 1;
|
|
||||||
downBtn.addEventListener('click', async () => {
|
|
||||||
const next = reorderProviders(providers, idx, Math.min(providers.length - 1, idx + 1));
|
|
||||||
await persistProviderChanges(m, next, undefined);
|
|
||||||
});
|
|
||||||
actions.appendChild(downBtn);
|
|
||||||
|
|
||||||
const removeBtn = document.createElement('button');
|
|
||||||
removeBtn.className = 'ghost';
|
|
||||||
removeBtn.textContent = 'Remove';
|
|
||||||
removeBtn.addEventListener('click', async () => {
|
|
||||||
if (providers.length <= 1) {
|
|
||||||
const ok = window.confirm('Deleting the last provider will fall back to the default `opencode` provider. Continue?');
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
const next = providers.filter((_, i) => i !== idx);
|
|
||||||
await persistProviderChanges(m, next, undefined);
|
|
||||||
});
|
|
||||||
actions.appendChild(removeBtn);
|
|
||||||
|
|
||||||
// Drag & drop support for build page only
|
|
||||||
if (pageType === 'build') {
|
|
||||||
card.addEventListener('dragstart', (ev) => {
|
|
||||||
card.classList.add('dragging');
|
|
||||||
ev.dataTransfer.effectAllowed = 'move';
|
|
||||||
ev.dataTransfer.setData('text/plain', JSON.stringify({ modelId: m.id, index: idx }));
|
|
||||||
});
|
|
||||||
card.addEventListener('dragend', () => card.classList.remove('dragging'));
|
|
||||||
}
|
|
||||||
|
|
||||||
card.appendChild(actions);
|
|
||||||
providerList.appendChild(card);
|
|
||||||
});
|
|
||||||
|
|
||||||
row.appendChild(providerList);
|
|
||||||
|
|
||||||
// Enable dropping into the provider list (build page only)
|
|
||||||
if (pageType === 'build') {
|
|
||||||
providerList.addEventListener('dragover', (ev) => { ev.preventDefault(); providerList.classList.add('drag-over'); });
|
|
||||||
providerList.addEventListener('dragleave', () => providerList.classList.remove('drag-over'));
|
|
||||||
providerList.addEventListener('drop', async (ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
providerList.classList.remove('drag-over');
|
|
||||||
const raw = ev.dataTransfer.getData('text/plain');
|
|
||||||
let payload = null;
|
|
||||||
try { payload = raw ? JSON.parse(raw) : null; } catch (_) { }
|
|
||||||
if (!payload || payload.modelId !== m.id || typeof payload.index !== 'number') return;
|
|
||||||
const cards = Array.from(providerList.querySelectorAll('.provider-card'));
|
|
||||||
const destEl = ev.target.closest('.provider-card');
|
|
||||||
let destIndex = cards.length - 1;
|
|
||||||
if (destEl) destIndex = cards.indexOf(destEl);
|
|
||||||
const next = reorderProviders(providers, payload.index, destIndex);
|
|
||||||
await persistProviderChanges(m, next, undefined);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const addRow = document.createElement('div');
|
|
||||||
addRow.className = 'provider-add-row';
|
|
||||||
|
|
||||||
const providerSelect = document.createElement('select');
|
|
||||||
DEFAULT_PROVIDERS.forEach((provider) => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = provider;
|
|
||||||
opt.textContent = provider;
|
|
||||||
providerSelect.appendChild(opt);
|
|
||||||
});
|
|
||||||
providerSelect.value = providers[0]?.provider && DEFAULT_PROVIDERS.includes(providers[0].provider)
|
|
||||||
? providers[0].provider
|
|
||||||
: 'openrouter';
|
|
||||||
addRow.appendChild(providerSelect);
|
|
||||||
|
|
||||||
const modelInput = document.createElement('input');
|
|
||||||
modelInput.type = 'text';
|
|
||||||
modelInput.placeholder = 'Model name (use discovered list)';
|
|
||||||
modelInput.value = m.name;
|
|
||||||
modelInput.setAttribute('list', ensureAvailableDatalist().id);
|
|
||||||
addRow.appendChild(modelInput);
|
|
||||||
|
|
||||||
// Inline icon selector shown when adding a second provider (i.e. initial add)
|
|
||||||
const iconInlineSelect = document.createElement('select');
|
|
||||||
iconInlineSelect.className = 'icon-select-inline';
|
|
||||||
const noneOpt = document.createElement('option');
|
|
||||||
noneOpt.value = '';
|
|
||||||
noneOpt.textContent = 'No icon';
|
|
||||||
iconInlineSelect.appendChild(noneOpt);
|
|
||||||
(state.icons || []).forEach((iconPath) => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = iconPath;
|
|
||||||
opt.textContent = iconPath.replace('/assets/', '');
|
|
||||||
iconInlineSelect.appendChild(opt);
|
|
||||||
});
|
|
||||||
iconInlineSelect.value = m.icon || '';
|
|
||||||
// Only show when this is the initial add (adding a second provider)
|
|
||||||
if (providers.length <= 1) addRow.appendChild(iconInlineSelect);
|
|
||||||
|
|
||||||
const addBtn = document.createElement('button');
|
|
||||||
addBtn.className = 'ghost';
|
|
||||||
addBtn.textContent = 'Add provider';
|
|
||||||
addBtn.addEventListener('click', async () => {
|
|
||||||
const providerVal = providerSelect.value.trim() || 'opencode';
|
|
||||||
const modelVal = modelInput.value.trim() || m.name;
|
|
||||||
const nextProviders = [...providers, { provider: providerVal, model: modelVal, primary: false }];
|
|
||||||
const iconVal = iconInlineSelect ? iconInlineSelect.value : undefined;
|
|
||||||
await persistProviderChanges(m, nextProviders, iconVal, undefined);
|
|
||||||
});
|
|
||||||
addRow.appendChild(addBtn);
|
|
||||||
row.appendChild(addRow);
|
|
||||||
|
|
||||||
el.configuredList.appendChild(row);
|
el.configuredList.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function persistPublicModelChanges(modelId, changes) {
|
||||||
|
setStatus('Saving...');
|
||||||
|
const model = state.publicModels.find((m) => m.id === modelId);
|
||||||
|
if (!model) {
|
||||||
|
setStatus('Model not found', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
type: 'publicModel',
|
||||||
|
id: modelId,
|
||||||
|
name: model.name,
|
||||||
|
label: model.label || model.name,
|
||||||
|
icon: changes.icon !== undefined ? changes.icon : model.icon,
|
||||||
|
tier: changes.tier !== undefined ? changes.tier : model.tier,
|
||||||
|
supportsMedia: changes.supportsMedia !== undefined ? changes.supportsMedia : model.supportsMedia,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await api('/api/admin/models', { method: 'POST', body: JSON.stringify(payload) });
|
||||||
|
const idx = state.publicModels.findIndex((m) => m.id === modelId);
|
||||||
|
if (idx >= 0) state.publicModels[idx] = { ...state.publicModels[idx], ...data.publicModel };
|
||||||
|
renderConfigured();
|
||||||
|
setStatus('Saved');
|
||||||
|
setTimeout(() => setStatus(''), 1500);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the unified provider chain with up/down controls
|
||||||
|
function renderProviderChain() {
|
||||||
|
if (!el.providerChainList) return;
|
||||||
|
el.providerChainList.innerHTML = '';
|
||||||
|
if (el.providerChainCount) el.providerChainCount.textContent = state.providerChain.length.toString();
|
||||||
|
|
||||||
|
if (!state.providerChain.length) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'muted';
|
||||||
|
empty.textContent = 'No provider chain configured. Add providers to enable automatic fallback.';
|
||||||
|
el.providerChainList.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.providerChain.forEach((entry, idx) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'provider-row slim';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'provider-row-header';
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'model-chip';
|
||||||
|
|
||||||
|
// Priority badge
|
||||||
|
const order = document.createElement('span');
|
||||||
|
order.className = 'pill';
|
||||||
|
order.textContent = idx === 0 ? 'Primary' : `#${idx + 1}`;
|
||||||
|
if (idx === 0) order.style.background = 'var(--shopify-green)';
|
||||||
|
info.appendChild(order);
|
||||||
|
|
||||||
|
// Provider badge
|
||||||
|
const providerPill = document.createElement('span');
|
||||||
|
providerPill.className = 'pill';
|
||||||
|
providerPill.textContent = entry.provider;
|
||||||
|
providerPill.style.background = 'var(--primary)';
|
||||||
|
info.appendChild(providerPill);
|
||||||
|
|
||||||
|
// Model name
|
||||||
|
const modelPill = document.createElement('span');
|
||||||
|
modelPill.textContent = entry.model;
|
||||||
|
info.appendChild(modelPill);
|
||||||
|
|
||||||
|
// Limit summary
|
||||||
|
const limitPill = document.createElement('span');
|
||||||
|
limitPill.className = 'pill';
|
||||||
|
limitPill.textContent = formatLimitSummary(entry.provider, entry.model);
|
||||||
|
info.appendChild(limitPill);
|
||||||
|
|
||||||
|
header.appendChild(info);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'provider-row-actions';
|
||||||
|
|
||||||
|
const upBtn = document.createElement('button');
|
||||||
|
upBtn.className = 'ghost';
|
||||||
|
upBtn.textContent = '↑';
|
||||||
|
upBtn.title = 'Move up';
|
||||||
|
upBtn.disabled = idx === 0;
|
||||||
|
upBtn.addEventListener('click', async () => {
|
||||||
|
const next = [...state.providerChain];
|
||||||
|
const [item] = next.splice(idx, 1);
|
||||||
|
next.splice(Math.max(0, idx - 1), 0, item);
|
||||||
|
await persistProviderChainOrder(next);
|
||||||
|
});
|
||||||
|
actions.appendChild(upBtn);
|
||||||
|
|
||||||
|
const downBtn = document.createElement('button');
|
||||||
|
downBtn.className = 'ghost';
|
||||||
|
downBtn.textContent = '↓';
|
||||||
|
downBtn.title = 'Move down';
|
||||||
|
downBtn.disabled = idx === state.providerChain.length - 1;
|
||||||
|
downBtn.addEventListener('click', async () => {
|
||||||
|
const next = [...state.providerChain];
|
||||||
|
const [item] = next.splice(idx, 1);
|
||||||
|
next.splice(Math.min(state.providerChain.length, idx + 1), 0, item);
|
||||||
|
await persistProviderChainOrder(next);
|
||||||
|
});
|
||||||
|
actions.appendChild(downBtn);
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'ghost';
|
||||||
|
removeBtn.textContent = 'Remove';
|
||||||
|
removeBtn.addEventListener('click', async () => {
|
||||||
|
if (state.providerChain.length <= 1) {
|
||||||
|
alert('Cannot remove the last provider. Add another provider first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = state.providerChain.filter((_, i) => i !== idx);
|
||||||
|
await persistProviderChainOrder(next);
|
||||||
|
});
|
||||||
|
actions.appendChild(removeBtn);
|
||||||
|
|
||||||
|
header.appendChild(actions);
|
||||||
|
row.appendChild(header);
|
||||||
|
el.providerChainList.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistProviderChainOrder(nextChain) {
|
||||||
|
setProviderChainStatus('Saving order...');
|
||||||
|
try {
|
||||||
|
const payload = { type: 'providerChain', chain: nextChain };
|
||||||
|
const res = await api('/api/admin/models', { method: 'POST', body: JSON.stringify(payload) });
|
||||||
|
state.providerChain = res.providerChain || nextChain;
|
||||||
|
renderProviderChain();
|
||||||
|
setProviderChainStatus('Saved');
|
||||||
|
setTimeout(() => setProviderChainStatus(''), 1500);
|
||||||
|
} catch (err) {
|
||||||
|
setProviderChainStatus(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePlanChainLocal(chain) {
|
function normalizePlanChainLocal(chain) {
|
||||||
if (!Array.isArray(chain)) return [];
|
if (!Array.isArray(chain)) return [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
@@ -1300,8 +1273,12 @@
|
|||||||
|
|
||||||
async function loadConfigured() {
|
async function loadConfigured() {
|
||||||
const data = await api('/api/admin/models');
|
const data = await api('/api/admin/models');
|
||||||
state.configured = data.models || [];
|
// Handle new unified structure
|
||||||
|
state.publicModels = data.publicModels || [];
|
||||||
|
state.providerChain = data.providerChain || [];
|
||||||
|
state.configured = data.models || []; // Legacy support
|
||||||
renderConfigured();
|
renderConfigured();
|
||||||
|
renderProviderChain();
|
||||||
const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string'
|
const selectedAutoModel = state.planSettings && typeof state.planSettings.freePlanModel === 'string'
|
||||||
? state.planSettings.freePlanModel
|
? state.planSettings.freePlanModel
|
||||||
: (el.autoModelSelect ? el.autoModelSelect.value : '');
|
: (el.autoModelSelect ? el.autoModelSelect.value : '');
|
||||||
@@ -1852,17 +1829,57 @@
|
|||||||
async function setTokens(acct) {
|
async function setTokens(acct) {
|
||||||
const tokenUsage = acct.tokenUsage || {};
|
const tokenUsage = acct.tokenUsage || {};
|
||||||
const currentLimit = tokenUsage.limit || 0;
|
const currentLimit = tokenUsage.limit || 0;
|
||||||
|
const currentUsed = tokenUsage.used || 0;
|
||||||
|
const currentRemaining = tokenUsage.remaining || 0;
|
||||||
const hasOverride = tokenUsage.tokenOverride !== null && tokenUsage.tokenOverride !== undefined;
|
const hasOverride = tokenUsage.tokenOverride !== null && tokenUsage.tokenOverride !== undefined;
|
||||||
const currentOverride = hasOverride ? tokenUsage.tokenOverride : '';
|
const currentOverride = hasOverride ? tokenUsage.tokenOverride : '';
|
||||||
|
|
||||||
const promptMessage = `Set token limit for ${acct.email}\n\n` +
|
const modeMessage = `Manage tokens for ${acct.email}\n\n` +
|
||||||
`Current plan: ${acct.plan || 'starter'}\n` +
|
`Current Status:\n` +
|
||||||
`Current limit: ${currentLimit.toLocaleString()} tokens\n` +
|
` Plan: ${acct.plan || 'starter'}\n` +
|
||||||
`${hasOverride ? `Current override: ${currentOverride.toLocaleString()} tokens\n` : ''}\n` +
|
` Limit: ${currentLimit.toLocaleString()} tokens\n` +
|
||||||
`Enter new token limit (0 to remove override, or a number to set manual limit):`;
|
` Used: ${currentUsed.toLocaleString()} tokens\n` +
|
||||||
|
` Remaining: ${currentRemaining.toLocaleString()} tokens\n` +
|
||||||
|
`${hasOverride ? ` Override: ${currentOverride.toLocaleString()} tokens\n` : ''}\n` +
|
||||||
|
`Choose mode:\n` +
|
||||||
|
` 1 = Set LIMIT (override plan limit)\n` +
|
||||||
|
` 2 = Set USED (directly set tokens consumed)\n` +
|
||||||
|
` 3 = Set REMAINING (set tokens left to use)`;
|
||||||
|
|
||||||
const tokenInput = prompt(promptMessage, currentOverride);
|
const modeInput = prompt(modeMessage, '1');
|
||||||
|
if (modeInput === null) return;
|
||||||
|
|
||||||
|
const mode = parseInt(modeInput.trim(), 10);
|
||||||
|
if (![1, 2, 3].includes(mode)) {
|
||||||
|
alert('Invalid mode. Please enter 1, 2, or 3.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let promptText = '';
|
||||||
|
let defaultValue = '';
|
||||||
|
let confirmText = '';
|
||||||
|
|
||||||
|
if (mode === 1) {
|
||||||
|
promptText = `Set token LIMIT for ${acct.email}\n\n` +
|
||||||
|
`Current limit: ${currentLimit.toLocaleString()} tokens\n` +
|
||||||
|
`${hasOverride ? `Current override: ${currentOverride.toLocaleString()}\n` : ''}\n` +
|
||||||
|
`Enter new limit (0 to remove override):`;
|
||||||
|
defaultValue = currentOverride;
|
||||||
|
} else if (mode === 2) {
|
||||||
|
promptText = `Set tokens USED for ${acct.email}\n\n` +
|
||||||
|
`Current used: ${currentUsed.toLocaleString()} tokens\n` +
|
||||||
|
`Limit: ${currentLimit.toLocaleString()} tokens\n\n` +
|
||||||
|
`Enter new usage amount:`;
|
||||||
|
defaultValue = currentUsed;
|
||||||
|
} else {
|
||||||
|
promptText = `Set tokens REMAINING for ${acct.email}\n\n` +
|
||||||
|
`Current remaining: ${currentRemaining.toLocaleString()} tokens\n` +
|
||||||
|
`Limit: ${currentLimit.toLocaleString()} tokens\n\n` +
|
||||||
|
`Enter remaining tokens (will calculate usage):`;
|
||||||
|
defaultValue = currentRemaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenInput = prompt(promptText, String(defaultValue));
|
||||||
if (tokenInput === null) return;
|
if (tokenInput === null) return;
|
||||||
|
|
||||||
const tokens = parseInt(tokenInput.trim(), 10);
|
const tokens = parseInt(tokenInput.trim(), 10);
|
||||||
@@ -1871,17 +1888,26 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to set ${acct.email}'s token limit to ${tokens.toLocaleString()}? This will override their plan-based limit for this month.`)) {
|
if (mode === 1) {
|
||||||
|
confirmText = `Set ${acct.email}'s token LIMIT to ${tokens.toLocaleString()}?`;
|
||||||
|
} else if (mode === 2) {
|
||||||
|
confirmText = `Set ${acct.email}'s tokens USED to ${tokens.toLocaleString()}?`;
|
||||||
|
} else {
|
||||||
|
confirmText = `Set ${acct.email}'s tokens REMAINING to ${tokens.toLocaleString()}?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(confirmText)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(`Updating token limit for ${acct.email}...`);
|
const modeNames = { 1: 'limit', 2: 'usage', 3: 'remaining' };
|
||||||
|
setStatus(`Updating ${modeNames[mode]} for ${acct.email}...`);
|
||||||
try {
|
try {
|
||||||
await api('/api/admin/accounts/tokens', {
|
await api('/api/admin/accounts/tokens', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ userId: acct.id, tokens: tokens })
|
body: JSON.stringify({ userId: acct.id, tokens: tokens, mode: modeNames[mode] })
|
||||||
});
|
});
|
||||||
setStatus('Token limit updated successfully');
|
setStatus(`Token ${modeNames[mode]} updated successfully`);
|
||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
setTimeout(() => setStatus(''), 3000);
|
setTimeout(() => setStatus(''), 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2199,6 +2225,79 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New public model form handler
|
||||||
|
if (el.publicModelForm) {
|
||||||
|
el.publicModelForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = el.publicModelName.value.trim();
|
||||||
|
const label = el.publicModelLabel.value.trim();
|
||||||
|
const icon = el.publicModelIcon ? el.publicModelIcon.value : '';
|
||||||
|
const tier = el.publicModelTier ? el.publicModelTier.value : 'free';
|
||||||
|
const supportsMedia = el.publicModelMedia ? el.publicModelMedia.checked : false;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
setPublicModelStatus('Model ID is required.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!label) {
|
||||||
|
setPublicModelStatus('Display name is required.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPublicModelStatus('Saving...');
|
||||||
|
try {
|
||||||
|
await api('/api/admin/models', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'publicModel',
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
tier,
|
||||||
|
supportsMedia
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setPublicModelStatus('Saved');
|
||||||
|
el.publicModelName.value = '';
|
||||||
|
el.publicModelLabel.value = '';
|
||||||
|
await loadConfigured();
|
||||||
|
} catch (err) {
|
||||||
|
setPublicModelStatus(err.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// New provider chain form handler
|
||||||
|
if (el.providerChainForm) {
|
||||||
|
el.providerChainForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const provider = el.chainProvider.value;
|
||||||
|
const model = el.chainModel.value.trim();
|
||||||
|
|
||||||
|
if (!model) {
|
||||||
|
setProviderChainStatus('Model name is required.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProviderChainStatus('Adding to chain...');
|
||||||
|
try {
|
||||||
|
const newChain = [...state.providerChain, { provider, model }];
|
||||||
|
await api('/api/admin/models', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: 'providerChain',
|
||||||
|
chain: newChain
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
setProviderChainStatus('Added to chain');
|
||||||
|
el.chainModel.value = '';
|
||||||
|
await loadConfigured();
|
||||||
|
} catch (err) {
|
||||||
|
setProviderChainStatus(err.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (el.modelForm) {
|
if (el.modelForm) {
|
||||||
el.modelForm.addEventListener('submit', async (e) => {
|
el.modelForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
389
chat/server.js
389
chat/server.js
@@ -1524,6 +1524,9 @@ const MODELS_DEV_CACHE_TTL = 3600000; // 1 hour
|
|||||||
const adminSessions = new Map();
|
const adminSessions = new Map();
|
||||||
let adminModels = [];
|
let adminModels = [];
|
||||||
let adminModelIndex = new Map();
|
let adminModelIndex = new Map();
|
||||||
|
// New unified model chain structure
|
||||||
|
let publicModels = []; // Models displayed in builder dropdown [{id, name, label, icon, tier, supportsMedia, multiplier}]
|
||||||
|
let providerChain = []; // Unified fallback chain [{provider, model, primary}]
|
||||||
let openrouterSettings = {
|
let openrouterSettings = {
|
||||||
primaryModel: OPENROUTER_MODEL_PRIMARY,
|
primaryModel: OPENROUTER_MODEL_PRIMARY,
|
||||||
backupModel1: OPENROUTER_MODEL_BACKUP_1,
|
backupModel1: OPENROUTER_MODEL_BACKUP_1,
|
||||||
@@ -4884,13 +4887,22 @@ function resolveFallbackModel(cli = 'opencode') {
|
|||||||
|
|
||||||
function refreshAdminModelIndex() {
|
function refreshAdminModelIndex() {
|
||||||
const next = new Map();
|
const next = new Map();
|
||||||
adminModels.forEach((model) => {
|
// Index public models (new structure)
|
||||||
|
publicModels.forEach((model) => {
|
||||||
if (!model) return;
|
if (!model) return;
|
||||||
const idKey = String(model.id || '').trim();
|
const idKey = String(model.id || '').trim();
|
||||||
const nameKey = String(model.name || '').trim();
|
const nameKey = String(model.name || '').trim();
|
||||||
if (idKey) next.set(`id:${idKey}`, model);
|
if (idKey) next.set(`id:${idKey}`, model);
|
||||||
if (nameKey) next.set(`name:${nameKey}`, model);
|
if (nameKey) next.set(`name:${nameKey}`, model);
|
||||||
});
|
});
|
||||||
|
// Also index legacy admin models for backward compatibility
|
||||||
|
adminModels.forEach((model) => {
|
||||||
|
if (!model) return;
|
||||||
|
const idKey = String(model.id || '').trim();
|
||||||
|
const nameKey = String(model.name || '').trim();
|
||||||
|
if (idKey && !next.has(`id:${idKey}`)) next.set(`id:${idKey}`, model);
|
||||||
|
if (nameKey && !next.has(`name:${nameKey}`)) next.set(`name:${nameKey}`, model);
|
||||||
|
});
|
||||||
adminModelIndex = next;
|
adminModelIndex = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5139,16 +5151,36 @@ async function ensureOpencodeConfig(session) {
|
|||||||
[providerName]: providerCfg
|
[providerName]: providerCfg
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensure adminModels is loaded
|
// Ensure models are loaded
|
||||||
if (!adminModels || adminModels.length === 0) {
|
if (!adminModels || adminModels.length === 0) {
|
||||||
log('adminModels empty, loading from store', { sessionId: session.id });
|
log('adminModels empty, loading from store', { sessionId: session.id });
|
||||||
await loadAdminModelStore();
|
await loadAdminModelStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find which providers are used in adminModels
|
// Find which providers are used in models
|
||||||
const usedProviders = new Set();
|
const usedProviders = new Set();
|
||||||
|
|
||||||
|
// Check new provider chain first
|
||||||
|
if (providerChain.length > 0) {
|
||||||
|
for (const p of providerChain) {
|
||||||
|
if (p.provider) {
|
||||||
|
usedProviders.add(p.provider.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check public models for provider prefixes in names
|
||||||
|
for (const model of publicModels) {
|
||||||
|
if (model.name && model.name.includes('/')) {
|
||||||
|
const providerFromName = model.name.split('/')[0].toLowerCase();
|
||||||
|
if (providerFromName && providerFromName !== 'opencode') {
|
||||||
|
usedProviders.add(providerFromName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to legacy adminModels
|
||||||
for (const model of adminModels) {
|
for (const model of adminModels) {
|
||||||
// First, try to extract provider from model name (e.g., "chutes/model-name" -> "chutes")
|
|
||||||
if (model.name && model.name.includes('/')) {
|
if (model.name && model.name.includes('/')) {
|
||||||
const providerFromName = model.name.split('/')[0].toLowerCase();
|
const providerFromName = model.name.split('/')[0].toLowerCase();
|
||||||
if (providerFromName && providerFromName !== 'opencode') {
|
if (providerFromName && providerFromName !== 'opencode') {
|
||||||
@@ -5158,7 +5190,6 @@ async function ensureOpencodeConfig(session) {
|
|||||||
|
|
||||||
if (Array.isArray(model.providers)) {
|
if (Array.isArray(model.providers)) {
|
||||||
for (const p of model.providers) {
|
for (const p of model.providers) {
|
||||||
// Handle both string format ["opencode", "chutes"] and object format [{provider: "opencode"}]
|
|
||||||
if (typeof p === 'string') {
|
if (typeof p === 'string') {
|
||||||
usedProviders.add(p.toLowerCase());
|
usedProviders.add(p.toLowerCase());
|
||||||
} else if (p && typeof p === 'object' && p.provider) {
|
} else if (p && typeof p === 'object' && p.provider) {
|
||||||
@@ -5171,9 +5202,10 @@ async function ensureOpencodeConfig(session) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log('Detected providers from adminModels', {
|
log('Detected providers from models', {
|
||||||
usedProviders: Array.from(usedProviders),
|
usedProviders: Array.from(usedProviders),
|
||||||
count: usedProviders.size
|
count: usedProviders.size,
|
||||||
|
source: providerChain.length > 0 ? 'providerChain' : 'legacy'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Provider configurations with their base URLs
|
// Provider configurations with their base URLs
|
||||||
@@ -5726,34 +5758,78 @@ async function loadAdminModelStore() {
|
|||||||
await ensureAssetsDir();
|
await ensureAssetsDir();
|
||||||
const raw = await fs.readFile(ADMIN_MODELS_FILE, 'utf8').catch(() => '[]');
|
const raw = await fs.readFile(ADMIN_MODELS_FILE, 'utf8').catch(() => '[]');
|
||||||
const parsed = JSON.parse(raw || '[]');
|
const parsed = JSON.parse(raw || '[]');
|
||||||
if (Array.isArray(parsed)) adminModels = parsed;
|
|
||||||
else if (Array.isArray(parsed.models)) adminModels = parsed.models;
|
// Check if using new unified structure or old structure
|
||||||
else adminModels = [];
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
adminModels = adminModels.map((m) => {
|
// New unified structure
|
||||||
const providersRaw = Array.isArray(m.providers) && m.providers.length
|
publicModels = Array.isArray(parsed.publicModels) ? parsed.publicModels : [];
|
||||||
? m.providers
|
providerChain = Array.isArray(parsed.providerChain) ? parsed.providerChain : [];
|
||||||
: [{ provider: 'opencode', model: m.name, primary: true }];
|
adminModels = Array.isArray(parsed.adminModels) ? parsed.adminModels : [];
|
||||||
const providers = providersRaw.map((p, idx) => ({
|
} else if (Array.isArray(parsed)) {
|
||||||
provider: normalizeProviderName(p.provider || p.name || 'opencode'),
|
// Old array structure - migrate to new
|
||||||
model: (p.model || p.name || m.name || '').trim() || m.name,
|
adminModels = parsed;
|
||||||
primary: typeof p.primary === 'boolean' ? p.primary : idx === 0,
|
// Create public models from admin models
|
||||||
})).filter((p) => !!p.model);
|
publicModels = parsed.map((m) => ({
|
||||||
const primaryProvider = providers.find((p) => p.primary)?.provider || providers[0]?.provider || 'opencode';
|
|
||||||
return {
|
|
||||||
id: m.id || randomUUID(),
|
id: m.id || randomUUID(),
|
||||||
name: m.name,
|
name: m.name,
|
||||||
label: m.label || m.name,
|
label: m.label || m.name,
|
||||||
icon: m.icon || '',
|
icon: m.icon || '',
|
||||||
cli: normalizeCli(m.cli || 'opencode'),
|
|
||||||
providers,
|
|
||||||
primaryProvider,
|
|
||||||
tier: normalizeTier(m.tier),
|
tier: normalizeTier(m.tier),
|
||||||
supportsMedia: m.supportsMedia ?? false,
|
supportsMedia: m.supportsMedia ?? false,
|
||||||
};
|
multiplier: getTierMultiplier(m.tier),
|
||||||
}).filter((m) => !!m.name);
|
})).filter((m) => !!m.name);
|
||||||
|
// Create unified provider chain from all unique 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 }];
|
||||||
|
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());
|
||||||
|
} else {
|
||||||
|
publicModels = [];
|
||||||
|
providerChain = [];
|
||||||
|
adminModels = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all public models have required fields
|
||||||
|
publicModels = publicModels.map((m) => ({
|
||||||
|
id: m.id || randomUUID(),
|
||||||
|
name: m.name,
|
||||||
|
label: m.label || m.name,
|
||||||
|
icon: m.icon || '',
|
||||||
|
tier: normalizeTier(m.tier),
|
||||||
|
supportsMedia: m.supportsMedia ?? false,
|
||||||
|
multiplier: m.multiplier || getTierMultiplier(m.tier),
|
||||||
|
})).filter((m) => !!m.name);
|
||||||
|
|
||||||
|
// Ensure all provider chain entries have required fields
|
||||||
|
providerChain = providerChain.map((p, idx) => ({
|
||||||
|
provider: normalizeProviderName(p.provider || 'opencode'),
|
||||||
|
model: (p.model || '').trim(),
|
||||||
|
primary: idx === 0,
|
||||||
|
})).filter((p) => !!p.model);
|
||||||
|
|
||||||
refreshAdminModelIndex();
|
refreshAdminModelIndex();
|
||||||
|
log('Loaded admin model store', {
|
||||||
|
publicModels: publicModels.length,
|
||||||
|
providerChain: providerChain.length,
|
||||||
|
legacyAdminModels: adminModels.length
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log('Failed to load admin models, starting empty', { error: String(error) });
|
log('Failed to load admin models, starting empty', { error: String(error) });
|
||||||
|
publicModels = [];
|
||||||
|
providerChain = [];
|
||||||
adminModels = [];
|
adminModels = [];
|
||||||
refreshAdminModelIndex();
|
refreshAdminModelIndex();
|
||||||
}
|
}
|
||||||
@@ -5762,7 +5838,13 @@ async function loadAdminModelStore() {
|
|||||||
async function persistAdminModels() {
|
async function persistAdminModels() {
|
||||||
await ensureStateFile();
|
await ensureStateFile();
|
||||||
await ensureAssetsDir();
|
await ensureAssetsDir();
|
||||||
const payload = JSON.stringify(adminModels, null, 2);
|
// Save new unified structure
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
publicModels,
|
||||||
|
providerChain,
|
||||||
|
adminModels, // Keep legacy for backwards compatibility
|
||||||
|
version: 2, // Schema version
|
||||||
|
}, null, 2);
|
||||||
await safeWriteFile(ADMIN_MODELS_FILE, payload);
|
await safeWriteFile(ADMIN_MODELS_FILE, payload);
|
||||||
refreshAdminModelIndex();
|
refreshAdminModelIndex();
|
||||||
}
|
}
|
||||||
@@ -5955,12 +6037,20 @@ function collectProviderSeeds() {
|
|||||||
const normalized = normalizeProviderName(p);
|
const normalized = normalizeProviderName(p);
|
||||||
if (validProviders.has(normalized)) seeds.add(normalized);
|
if (validProviders.has(normalized)) seeds.add(normalized);
|
||||||
});
|
});
|
||||||
adminModels.forEach((m) => {
|
// Use new provider chain if available, otherwise fall back to legacy adminModels
|
||||||
(m.providers || []).forEach((p) => {
|
if (providerChain.length > 0) {
|
||||||
|
providerChain.forEach((p) => {
|
||||||
const providerName = extractProviderName(p);
|
const providerName = extractProviderName(p);
|
||||||
if (validProviders.has(providerName)) seeds.add(providerName);
|
if (validProviders.has(providerName)) seeds.add(providerName);
|
||||||
});
|
});
|
||||||
});
|
} else {
|
||||||
|
adminModels.forEach((m) => {
|
||||||
|
(m.providers || []).forEach((p) => {
|
||||||
|
const providerName = extractProviderName(p);
|
||||||
|
if (validProviders.has(providerName)) seeds.add(providerName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
(planSettings.planningChain || []).forEach((entry) => {
|
(planSettings.planningChain || []).forEach((entry) => {
|
||||||
const normalized = normalizeProviderName(entry.provider);
|
const normalized = normalizeProviderName(entry.provider);
|
||||||
if (validProviders.has(normalized)) seeds.add(normalized);
|
if (validProviders.has(normalized)) seeds.add(normalized);
|
||||||
@@ -6004,9 +6094,14 @@ async function discoverProviderModels() {
|
|||||||
|
|
||||||
collectProviderSeeds().forEach((p) => add(p));
|
collectProviderSeeds().forEach((p) => add(p));
|
||||||
|
|
||||||
adminModels.forEach((m) => {
|
// Use new provider chain if available, otherwise fall back to legacy adminModels
|
||||||
(m.providers || []).forEach((p) => add(extractProviderName(p), p.model || m.name));
|
if (providerChain.length > 0) {
|
||||||
});
|
providerChain.forEach((p) => add(extractProviderName(p), p.model));
|
||||||
|
} else {
|
||||||
|
adminModels.forEach((m) => {
|
||||||
|
(m.providers || []).forEach((p) => add(extractProviderName(p), p.model || m.name));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
(planSettings.planningChain || []).forEach((entry) => {
|
(planSettings.planningChain || []).forEach((entry) => {
|
||||||
add(entry.provider, entry.model);
|
add(entry.provider, entry.model);
|
||||||
@@ -10163,37 +10258,55 @@ function resolveModelProviders(modelName) {
|
|||||||
function buildOpencodeAttemptChain(cli, preferredModel) {
|
function buildOpencodeAttemptChain(cli, preferredModel) {
|
||||||
const chain = [];
|
const chain = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only add preferredModel if it's a non-empty string
|
// Build chain from unified providerChain with user's preferred model
|
||||||
if (typeof preferredModel === 'string' && preferredModel.trim()) {
|
providerChain.forEach((p, idx) => {
|
||||||
addProviderOptions(preferredModel);
|
const modelToUse = idx === 0 && preferredModel ? preferredModel : p.model;
|
||||||
}
|
const key = `${p.provider}:${modelToUse}`;
|
||||||
getConfiguredModels(cli).forEach((m) => {
|
if (seen.has(key)) return;
|
||||||
if (m.name && m.name !== preferredModel) addProviderOptions(m.name);
|
seen.add(key);
|
||||||
|
chain.push({
|
||||||
|
provider: p.provider,
|
||||||
|
model: modelToUse,
|
||||||
|
primary: idx === 0,
|
||||||
|
cli: normalizeCli(cli || 'opencode'),
|
||||||
|
sourceModel: modelToUse,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
addProviderOptions('default');
|
|
||||||
|
// If no provider chain, fall back to old behavior
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
addProviderOptions('default');
|
||||||
|
}
|
||||||
|
|
||||||
// Log the built chain for debugging
|
// Log the built chain for debugging
|
||||||
log('Built model attempt chain', {
|
log('Built model attempt chain', {
|
||||||
cli,
|
cli,
|
||||||
preferredModel: (typeof preferredModel === 'string' && preferredModel.trim()) ? preferredModel : '(none)',
|
preferredModel: (typeof preferredModel === 'string' && preferredModel.trim()) ? preferredModel : '(none)',
|
||||||
chainLength: chain.length,
|
chainLength: chain.length,
|
||||||
models: chain.map(c => `${c.provider}:${c.model}`).slice(0, 5) // First 5 to avoid log spam
|
models: chain.map(c => `${c.provider}:${c.model}`).slice(0, 5)
|
||||||
});
|
});
|
||||||
|
|
||||||
return chain;
|
return chain;
|
||||||
@@ -11226,15 +11339,15 @@ async function processMessage(sessionId, message) {
|
|||||||
|
|
||||||
function getConfiguredModels(cliParam = 'opencode') {
|
function getConfiguredModels(cliParam = 'opencode') {
|
||||||
const cli = normalizeCli(cliParam || 'opencode');
|
const cli = normalizeCli(cliParam || 'opencode');
|
||||||
const filtered = adminModels.filter((m) => !m.cli || normalizeCli(m.cli) === cli);
|
// Use new publicModels array - filter by CLI if needed (though public models don't have CLI field)
|
||||||
const mapped = filtered.map((m) => ({
|
const mapped = publicModels.map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
label: m.label || m.name,
|
label: m.label || m.name,
|
||||||
icon: m.icon || '',
|
icon: m.icon || '',
|
||||||
cli: m.cli || 'opencode',
|
cli: cli, // All public models work with opencode CLI
|
||||||
providers: Array.isArray(m.providers) ? m.providers : [],
|
providers: providerChain, // Use unified provider chain
|
||||||
primaryProvider: m.primaryProvider || (Array.isArray(m.providers) && m.providers[0]?.provider) || 'opencode',
|
primaryProvider: providerChain[0]?.provider || 'opencode',
|
||||||
tier: m.tier || 'free',
|
tier: m.tier || 'free',
|
||||||
multiplier: getTierMultiplier(m.tier || 'free'),
|
multiplier: getTierMultiplier(m.tier || 'free'),
|
||||||
supportsMedia: m.supportsMedia ?? false,
|
supportsMedia: m.supportsMedia ?? false,
|
||||||
@@ -15742,19 +15855,13 @@ async function handleAdminListIcons(req, res) {
|
|||||||
async function handleAdminModelsList(req, res) {
|
async function handleAdminModelsList(req, res) {
|
||||||
const session = requireAdminAuth(req, res);
|
const session = requireAdminAuth(req, res);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const models = (adminModels || []).map((m) => ({
|
// Return new unified structure
|
||||||
id: m.id,
|
sendJson(res, 200, {
|
||||||
name: m.name,
|
publicModels: publicModels.sort((a, b) => (a.label || '').localeCompare(b.label || '')),
|
||||||
label: m.label || m.name,
|
providerChain,
|
||||||
icon: m.icon || '',
|
// Legacy support
|
||||||
cli: m.cli || 'opencode',
|
models: getConfiguredModels('opencode'),
|
||||||
providers: Array.isArray(m.providers) ? m.providers : [],
|
});
|
||||||
primaryProvider: m.primaryProvider || (Array.isArray(m.providers) && m.providers[0]?.provider) || 'opencode',
|
|
||||||
tier: m.tier || 'free',
|
|
||||||
multiplier: getTierMultiplier(m.tier || 'free'),
|
|
||||||
supportsMedia: m.supportsMedia ?? false,
|
|
||||||
})).sort((a, b) => (a.label || '').localeCompare(b.label || ''));
|
|
||||||
sendJson(res, 200, { models });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAdminModelUpsert(req, res) {
|
async function handleAdminModelUpsert(req, res) {
|
||||||
@@ -15762,39 +15869,96 @@ async function handleAdminModelUpsert(req, res) {
|
|||||||
if (!session) return;
|
if (!session) return;
|
||||||
try {
|
try {
|
||||||
const body = await parseJsonBody(req);
|
const body = await parseJsonBody(req);
|
||||||
const modelName = (body.name || body.model || '').trim();
|
|
||||||
const label = (body.label || body.displayName || modelName).trim();
|
|
||||||
const cli = normalizeCli(body.cli || 'opencode');
|
|
||||||
const tier = normalizeTier(body.tier);
|
|
||||||
if (!modelName) return sendJson(res, 400, { error: 'Model name is required' });
|
|
||||||
const id = body.id || randomUUID();
|
|
||||||
const existingIndex = adminModels.findIndex((m) => m.id === id);
|
|
||||||
const existing = existingIndex >= 0 ? adminModels[existingIndex] : null;
|
|
||||||
let icon = existing?.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' });
|
|
||||||
}
|
|
||||||
let providers = [];
|
|
||||||
if (Array.isArray(body.providers)) {
|
|
||||||
providers = body.providers.map((p, idx) => ({
|
|
||||||
provider: normalizeProviderName(p.provider || p.name || p.id || 'opencode'),
|
|
||||||
model: (p.model || p.name || modelName || '').trim() || modelName,
|
|
||||||
primary: typeof p.primary === 'boolean' ? p.primary : idx === 0,
|
|
||||||
})).filter((p) => !!p.model);
|
|
||||||
} else if (typeof body.provider === 'string') {
|
|
||||||
const normalized = normalizeProviderName(body.provider);
|
|
||||||
providers = [{ provider: normalized, model: modelName, primary: true }];
|
|
||||||
}
|
|
||||||
if (!providers.length) providers = [{ provider: 'opencode', model: modelName, primary: true }];
|
|
||||||
const primaryProvider = providers.find((p) => p.primary)?.provider || providers[0].provider;
|
|
||||||
const supportsMedia = typeof body.supportsMedia === 'boolean' ? body.supportsMedia : false;
|
|
||||||
|
|
||||||
const payload = { id, name: modelName, label: label || modelName, cli, icon, providers, primaryProvider, tier, multiplier: getTierMultiplier(tier), supportsMedia };
|
// Check if this is a public model or provider chain update
|
||||||
if (existingIndex >= 0) adminModels[existingIndex] = { ...adminModels[existingIndex], ...payload };
|
if (body.type === 'publicModel') {
|
||||||
else adminModels.push(payload);
|
// Handle public model update
|
||||||
await persistAdminModels();
|
const modelName = (body.name || body.model || '').trim();
|
||||||
sendJson(res, 200, { model: payload, models: getConfiguredModels(cli) });
|
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),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingIndex >= 0) publicModels[existingIndex] = { ...publicModels[existingIndex], ...payload };
|
||||||
|
else publicModels.push(payload);
|
||||||
|
|
||||||
|
await persistAdminModels();
|
||||||
|
sendJson(res, 200, {
|
||||||
|
publicModel: payload,
|
||||||
|
publicModels: publicModels.sort((a, b) => (a.label || '').localeCompare(b.label || '')),
|
||||||
|
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.sort((a, b) => (a.label || '').localeCompare(b.label || '')),
|
||||||
|
providerChain,
|
||||||
|
models: getConfiguredModels('opencode'),
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendJson(res, 400, { error: error.message || 'Unable to save model' });
|
sendJson(res, 400, { error: error.message || 'Unable to save model' });
|
||||||
}
|
}
|
||||||
@@ -15803,11 +15967,16 @@ async function handleAdminModelUpsert(req, res) {
|
|||||||
async function handleAdminModelDelete(req, res, id) {
|
async function handleAdminModelDelete(req, res, id) {
|
||||||
const session = requireAdminAuth(req, res);
|
const session = requireAdminAuth(req, res);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const before = adminModels.length;
|
const before = publicModels.length;
|
||||||
adminModels = adminModels.filter((m) => m.id !== id);
|
publicModels = publicModels.filter((m) => m.id !== id);
|
||||||
if (adminModels.length === before) return sendJson(res, 404, { error: 'Model not found' });
|
if (publicModels.length === before) return sendJson(res, 404, { error: 'Model not found' });
|
||||||
await persistAdminModels();
|
await persistAdminModels();
|
||||||
sendJson(res, 200, { ok: true, models: getConfiguredModels('opencode') });
|
sendJson(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
publicModels: publicModels.sort((a, b) => (a.label || '').localeCompare(b.label || '')),
|
||||||
|
providerChain,
|
||||||
|
models: getConfiguredModels('opencode'),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAdminOpenRouterSettingsGet(req, res) {
|
async function handleAdminOpenRouterSettingsGet(req, res) {
|
||||||
|
|||||||
Reference in New Issue
Block a user