import { html } from 'lit'; import { LightElement } from '../lib/base.js'; const STRENGTH_COLORS = { very_high: '#ef4444', high: '#f97316', average: '#eab308', low: '#84cc16', very_low: '#22c55e', }; const STRENGTH_LABELS = { very_high: 'Very High', high: 'High', average: 'Average', low: 'Low', very_low: 'Very Low', }; const STRENGTH_OPTIONS = ['very_low', 'low', 'average', 'high', 'very_high']; const SCOPE_OPTIONS = ['coding', 'writing', 'reasoning', 'math', 'basic', 'search']; function emptyMeta() { // `reasoning` is the selected reasoning value: a string for a ValueSet mode, // a number for a Range mode, or null (off). Interpreted per provider. return { strength: '', scope: [], priority: 100, is_default: false, reasoning: null }; } function emptyOrForm() { return { model_id: '', name: '', max_tokens: '', ...emptyMeta(), }; } function emptyDefaultForm() { return { model_id: '', name: '', extra_params: '', ...emptyMeta() }; } export class ModelsLlmSection extends LightElement { static properties = { onback: { attribute: false }, _models: { state: true }, _providers: { state: true }, _modal: { state: true }, _saving: { state: true }, _error: { state: true }, _orModels: { state: true }, _orLoading: { state: true }, _orSearch: { state: true }, _orForm: { state: true }, _form: { state: true }, _pickedProvider: { state: true }, _reasoningMode: { state: true }, }; constructor() { super(); this.onback = null; this._models = []; this._providers = []; this._modal = null; this._saving = false; this._error = null; this._orModels = []; this._orLoading = false; this._orSearch = ''; this._orForm = emptyOrForm(); this._form = emptyDefaultForm(); this._pickedProvider = null; this._reasoningMode = null; } connectedCallback() { super.connectedCallback(); this._load(); } async _load() { try { const [modelsRes, providersRes] = await Promise.all([ fetch('/api/llm/models'), fetch('/api/llm/providers'), ]); if (!modelsRes.ok) throw new Error(`models: HTTP ${modelsRes.status}`); if (!providersRes.ok) throw new Error(`providers: HTTP ${providersRes.status}`); this._models = await modelsRes.json(); this._providers = await providersRes.json(); } catch (e) { this._error = e.message; } } // ── Move up/down ───────────────────────────────────────────────────────────── async _moveUp(i) { if (i <= 0) return; const models = [...this._models]; [models[i - 1], models[i]] = [models[i], models[i - 1]]; this._models = models; await this._savePriorities(); } async _moveDown(i) { if (i >= this._models.length - 1) return; const models = [...this._models]; [models[i], models[i + 1]] = [models[i + 1], models[i]]; this._models = models; await this._savePriorities(); } async _savePriorities() { try { await Promise.all(this._models.map((m, i) => fetch(`/api/llm/models/${m.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, // A PUT overwrites every column, so carry through all preserved // fields (catalog metadata + reasoning) — omitting them would wipe // them on reorder. body: JSON.stringify({ provider_id: m.provider_id, model_id: m.model_id, name: m.name, strength: m.strength ?? null, scope: m.scope, is_default: m.is_default, priority: (i + 1) * 10, extra_params: m.extra_params ?? null, context_length: m.context_length ?? null, max_output_tokens: m.max_output_tokens ?? null, knowledge_cutoff: m.knowledge_cutoff ?? null, capabilities: m.capabilities ?? null, reasoning: m.reasoning ?? null, }), }) )); await this._load(); } catch (e) { this._error = `Failed to save order: ${e.message}`; await this._load(); } } // ── Add flow ───────────────────────────────────────────────────────────────── _openAdd() { this._error = null; this._pickedProvider = null; this._modal = 'provider-pick'; } async _pickProvider(provider) { this._error = null; this._pickedProvider = provider; this._reasoningMode = null; const hasModelPicker = ['openrouter', 'ollama', 'lm_studio', 'deepseek', 'zai'].includes(provider.type); if (hasModelPicker) { this._orForm = emptyOrForm(); this._orSearch = ''; this._orModels = []; this._modal = 'add-openrouter'; await this._loadOrModels(provider.id); } else { this._form = { ...emptyDefaultForm(), provider_id: provider.id }; this._modal = 'add-default'; } } async _loadOrModels(providerId) { this._orLoading = true; try { const res = await fetch(`/api/llm/providers/${providerId}/models`); if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); this._orModels = await res.json(); } catch (e) { this._error = `Failed to load models: ${e.message}`; } finally { this._orLoading = false; } } // ── Edit flow ──────────────────────────────────────────────────────────────── async _openEdit(model) { this._error = null; try { const res = await fetch(`/api/llm/models/${model.id}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const record = await res.json(); this._form = { strength: record.strength ?? '', scope: record.scope ?? [], priority: record.priority, is_default: record.is_default, provider_id: record.provider_id, model_id: record.model_id, name: record.name, extra_params: record.extra_params ? JSON.stringify(record.extra_params) : '', reasoning: record.reasoning ?? null, // Preserved so the PUT doesn't wipe catalog metadata (see _submitEdit). context_length: record.context_length ?? null, max_output_tokens: record.max_output_tokens ?? null, knowledge_cutoff: record.knowledge_cutoff ?? null, capabilities: record.capabilities ?? null, }; // The reasoning control descriptor lives on the list item (LlmModelInfo). this._modal = { mode: 'edit', id: record.id, name: record.name, model_id: record.model_id, reasoning_mode: model.reasoning_mode ?? null, }; } catch (e) { this._error = e.message; } } // ── Delete ─────────────────────────────────────────────────────────────────── async _delete(model) { if (!confirm(`Delete model "${model.name}"?`)) return; try { const res = await fetch(`/api/llm/models/${model.id}`, { method: 'DELETE' }); if (!res.ok) throw new Error(await res.text()); await this._load(); } catch (e) { this._error = e.message; } } // ── Submit: add default ────────────────────────────────────────────────────── async _submitDefault(e) { e.preventDefault(); if (this._saving) return; this._saving = true; this._error = null; const f = this._form; let extra_params = null; if (f.extra_params && f.extra_params.trim()) { try { extra_params = JSON.parse(f.extra_params); } catch { this._error = 'Extra params: invalid JSON'; this._saving = false; return; } } try { const res = await fetch('/api/llm/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider_id: Number(this._pickedProvider.id), model_id: f.model_id, name: f.name || f.model_id, strength: f.strength || null, scope: f.scope, is_default: f.is_default, priority: Number(f.priority), extra_params, reasoning: f.reasoning ?? null, }), }); if (!res.ok) throw new Error(await res.text()); this._modal = null; await this._load(); } catch (err) { this._error = err.message; } finally { this._saving = false; } } // ── Submit: add openrouter ─────────────────────────────────────────────────── async _submitCatalog(e) { e.preventDefault(); if (this._saving) return; if (!this._orForm.model_id) { this._error = 'Select a model'; return; } this._saving = true; this._error = null; const f = this._orForm; const extra_params = {}; if (f.max_tokens) extra_params.max_tokens = Number(f.max_tokens); const selected = this._orModels.find(m => m.id === f.model_id); try { const res = await fetch('/api/llm/models', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider_id: Number(this._pickedProvider.id), model_id: f.model_id, name: f.name || f.model_id, strength: f.strength || null, scope: f.scope, is_default: f.is_default, priority: Number(f.priority), extra_params: Object.keys(extra_params).length ? extra_params : null, context_length: selected?.context_length ?? null, max_output_tokens: selected?.max_completion_tokens ?? null, knowledge_cutoff: selected?.knowledge_cutoff ?? null, capabilities: selected?.capabilities?.length ? selected.capabilities : null, reasoning: f.reasoning ?? null, }), }); if (!res.ok) throw new Error(await res.text()); this._modal = null; await this._load(); } catch (err) { this._error = err.message; } finally { this._saving = false; } } // ── Submit: edit ───────────────────────────────────────────────────────────── async _submitEdit(e) { e.preventDefault(); if (this._saving) return; this._saving = true; this._error = null; const f = this._form; let extra_params = null; if (f.extra_params && f.extra_params.trim()) { try { extra_params = JSON.parse(f.extra_params); } catch { this._error = 'Extra params: invalid JSON'; this._saving = false; return; } } try { const res = await fetch(`/api/llm/models/${this._modal.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider_id: f.provider_id, model_id: f.model_id, name: f.name, strength: f.strength || null, scope: f.scope, is_default: f.is_default, priority: Number(f.priority), extra_params, reasoning: f.reasoning ?? null, context_length: f.context_length ?? null, max_output_tokens: f.max_output_tokens ?? null, knowledge_cutoff: f.knowledge_cutoff ?? null, capabilities: f.capabilities ?? null, }), }); if (!res.ok) throw new Error(await res.text()); this._modal = null; await this._load(); } catch (err) { this._error = err.message; } finally { this._saving = false; } } // ── Field helpers ──────────────────────────────────────────────────────────── _setField(field, value) { this._form = { ...this._form, [field]: value }; } // Manual "add model" flow: the reasoning control descriptor depends on the // model_id, so fetch it (per provider) when the field settles. async _fetchDefaultReasoning(modelId) { this._reasoningMode = null; this._setField('reasoning', null); const pid = this._pickedProvider?.id; if (!pid || !modelId.trim()) return; try { const res = await fetch(`/api/llm/providers/${pid}/reasoning-mode?model_id=${encodeURIComponent(modelId)}`); if (res.ok) this._reasoningMode = await res.json(); } catch { /* descriptor is optional; ignore */ } } _setOrField(field, value) { this._orForm = { ...this._orForm, [field]: value }; } _toggleScope(scope, isOr = false) { if (isOr) { const s = this._orForm.scope; this._orForm = { ...this._orForm, scope: s.includes(scope) ? s.filter(x => x !== scope) : [...s, scope] }; } else { const s = this._form.scope; this._form = { ...this._form, scope: s.includes(scope) ? s.filter(x => x !== scope) : [...s, scope] }; } } _closeModal() { this._modal = null; this._error = null; } // ── Render helpers ─────────────────────────────────────────────────────────── _fmtP(p) { if (p == null) return null; if (p === 0) return '$0'; return p < 0.01 ? `$${p.toFixed(4)}` : `$${p.toFixed(3)}`; } _renderPriceCell(model) { const inp = this._fmtP(model.price_input_per_million); const out = this._fmtP(model.price_output_per_million); if (!inp && !out) return html`—`; return html` ${inp ?? '?'} → ${out ?? '?'} `; } _renderStrengthDot(strength) { if (!strength) return html`—`; return html` `; } _renderStatus(status) { const cfg = { healthy: { color: '#22c55e', title: 'Healthy' }, degraded: { color: '#eab308', title: 'Degraded' }, down: { color: '#ef4444', title: 'Down' }, }[status] ?? { color: '#888', title: status }; return html``; } _renderCard(m, i) { const first = i === 0; const last = i === this._models.length - 1; return html`
Model ID and provider cannot be changed. To use a different model, add a new entry.
${this._error ? html`No models configured yet.