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`
${this._renderStrengthDot(m.strength)} ${this._renderStatus(m.status)} ${m.name} ${m.is_default ? html`default` : ''}
${m.provider_name} ${m.model_id} ${this._renderPriceCell(m)}
${(m.scope ?? []).length > 0 || m.extra_params ? html`
${(m.scope ?? []).map(s => html`${s}`)} ${m.extra_params ? html`+params` : ''}
` : ''}
`; } // Data-driven reasoning control. `mode` is the provider-supplied descriptor // (ReasoningMode): a `value_set` (discrete choices → dropdown) or a `range` // (numeric budget → checkbox + number input). `form.reasoning` holds the // current value (string for value_set, number for range, or null = off). _renderReasoning(form, setField, mode) { if (!mode) return ''; if (mode.type === 'value_set') { return html`
`; } // range const enabled = form.reasoning != null; return html`
setField('reasoning', e.target.checked ? (mode.default ?? mode.min) : null)} />
${enabled ? html`
setField('reasoning', e.target.value === '' ? null : Number(e.target.value))} /> ${mode.unit ?? ''}
` : ''}
`; } _renderMetaFields(form, setField, toggleScope, reasoningMode) { return html` ${this._renderReasoning(form, setField, reasoningMode)}
setField('priority', e.target.value)} />
${SCOPE_OPTIONS.map(s => html`
toggleScope(s)} />
`)}
setField('is_default', e.target.checked)} />
`; } // ── Modal: provider picker ─────────────────────────────────────────────────── _renderProviderPick() { return html`
{ if (e.target === e.currentTarget) this._closeModal(); }}>
Add Model — Choose Provider
${this._error ? html`
${this._error}
` : ''}
${this._providers.filter(p => (p.supported_types ?? []).includes('llm')).map(p => html` `)}
`; } // ── Modal: add default ─────────────────────────────────────────────────────── _renderAddDefault() { const f = this._form; const p = this._pickedProvider; return html`
{ if (e.target === e.currentTarget) this._closeModal(); }}>
Add Model ${p?.name}
${this._error ? html`
${this._error}
` : ''}
this._submitDefault(e)}>
this._setField('model_id', e.target.value)} @change=${(e) => this._fetchDefaultReasoning(e.target.value)} />
this._setField('name', e.target.value)} />
${this._renderMetaFields(f, (k, v) => this._setField(k, v), (s) => this._toggleScope(s), this._reasoningMode)}
`; } // ── Modal: add model from catalog ──────────────────────────────────────────── _renderAddCatalog() { const f = this._orForm; const p = this._pickedProvider; const search = this._orSearch.toLowerCase(); const filtered = this._orModels.filter(m => !search || m.id.toLowerCase().includes(search) || m.name.toLowerCase().includes(search) ); const selected = this._orModels.find(m => m.id === f.model_id); const supportsMaxTokens = selected?.capabilities?.includes('max_tokens') ?? true; const formatPrice = (m) => { const i = this._fmtP(m.price_input_per_million); const o = this._fmtP(m.price_output_per_million); if (!i && !o) return null; return `${i ?? '?'} → ${o ?? '?'}/M`; }; return html`
{ if (e.target === e.currentTarget) this._closeModal(); }}>
Add Model ${p?.name}
${this._error ? html`
${this._error}
` : ''}
this._submitCatalog(e)}>
{ this._orSearch = e.target.value; }} /> ${this._orLoading ? html`
Loading models…
` : html`
${filtered.length === 0 ? html`
No models found
` : filtered.map(m => html`
this._setOrField('model_id', m.id)}>
${m.name}
${m.id} ${(m.price_input_per_million != null || m.price_output_per_million != null) ? html` ${formatPrice(m)} ` : ''} ${m.context_length ? html` ${(m.context_length / 1000).toFixed(0)}k ctx ` : ''}
`) }
` }
this._setOrField('name', e.target.value)} />
${supportsMaxTokens ? html`
this._setOrField('max_tokens', e.target.value)} />
` : ''} ${this._renderMetaFields(f, (k, v) => this._setOrField(k, v), (s) => this._toggleScope(s, true), selected?.reasoning)}
`; } // ── Modal: edit ────────────────────────────────────────────────────────────── _renderEdit() { const m = this._modal; const f = this._form; return html`
{ if (e.target === e.currentTarget) this._closeModal(); }}>
Edit ${m.name}

Model ID and provider cannot be changed. To use a different model, add a new entry.

${this._error ? html`
${this._error}
` : ''}
this._submitEdit(e)}>
this._setField('name', e.target.value)} />
Used to reference this model (e.g. in an agent's client). Must be unique.
${this._renderMetaFields(f, (k, v) => this._setField(k, v), (s) => this._toggleScope(s), this._modal.reasoning_mode)}
`; } render() { return html`
${this.onback ? html` ` : ''}

LLM Models

${this._models.length} model${this._models.length !== 1 ? 's' : ''}
${this._providers.length === 0 ? html`

Add a Provider first, then come back here to add models.

` : ''} ${this._error && !this._modal ? html`
${this._error}
` : ''}
${this._models.length === 0 && this._providers.length > 0 ? html`

No models configured yet.

` : this._models.map((m, i) => this._renderCard(m, i))}
${this._modal === 'provider-pick' ? this._renderProviderPick() : ''} ${this._modal === 'add-openrouter' ? this._renderAddCatalog() : ''} ${this._modal === 'add-default' ? this._renderAddDefault() : ''} ${this._modal?.mode === 'edit' ? this._renderEdit() : ''} `; } }