Add support for multiple providers

This commit is contained in:
dwrz
2026-06-19 14:31:41 +00:00
parent c71300b1bf
commit f5e72b1c7a
12 changed files with 948 additions and 869 deletions

View File

@@ -970,3 +970,20 @@ body {
border: none;
}
}
/* Unavailable model indicators in settings select */
.settings-select option.model-unavailable-not-found {
color: #dc2626;
font-style: italic;
}
.settings-select option.model-unavailable-error {
color: #6b7280;
font-style: italic;
}
.settings-select optgroup {
font-weight: 600;
font-family: var(--font-mono);
color: var(--color-text-secondary);
}

View File

@@ -2,6 +2,7 @@ const STREAM_ENDPOINT = '/v1/chat/voice/stream';
const ICONS_URL = '/static/icons.svg';
const MODELS_ENDPOINT = '/v1/models';
const MODEL_KEY = 'odidere_model';
const PROVIDER_KEY = 'odidere_provider';
const STORAGE_KEY = 'odidere_history';
const SYSTEM_MESSAGE_KEY = 'odidere_system_message';
const VOICE_KEY = 'odidere_voice';
@@ -243,7 +244,9 @@ class Odidere {
// Save selections on change.
this.$model.addEventListener('change', () => {
localStorage.setItem(MODEL_KEY, this.$model.value);
const $opt = this.$model.options[this.$model.selectedIndex];
localStorage.setItem(PROVIDER_KEY, $opt.dataset.provider);
localStorage.setItem(MODEL_KEY, $opt.dataset.model);
});
this.$voice.addEventListener('change', () => {
localStorage.setItem(VOICE_KEY, this.$voice.value);
@@ -1348,13 +1351,18 @@ class Odidere {
// ====================
/**
* #fetchModels fetches available models from the API and populates selectors.
* The response now includes models with availability status and provider grouping.
*/
async #fetchModels() {
try {
const res = await fetch(MODELS_ENDPOINT);
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json();
this.#populateModels(data.models, data.default_model);
this.#populateModels(
data.providers,
data.default_provider,
data.default_model,
);
} catch (e) {
console.error('failed to fetch models:', e);
this.#populateModelsFallback();
@@ -1452,10 +1460,12 @@ class Odidere {
);
}
const $opt = this.$model.options[this.$model.selectedIndex];
const payload = {
messages,
voice: voice ?? this.$voice.value,
model: this.$model.value,
provider: $opt?.dataset.provider,
model: $opt?.dataset.model,
};
const systemMessage = localStorage.getItem(SYSTEM_MESSAGE_KEY);
if (systemMessage) {
@@ -1795,21 +1805,58 @@ class Odidere {
// RENDER: SELECTS
// ====================
/**
* #populateModels populates the model selector with available options.
* @param {Array<{id: string}>} models
* #populateModels populates the model selector grouped by provider.
* Models are displayed with availability indicators:
* - Available: normal selectable option
* - Not found: red strikethrough, disabled
* - Provider error: grayed out, disabled
* @param {Object<string, Array<{model: string, status: string}>>} providers
* @param {string} defaultProvider
* @param {string} defaultModel
*/
#populateModels(models, defaultModel) {
#populateModels(providers, defaultProvider, defaultModel) {
this.$model.innerHTML = '';
for (const m of models) {
const $opt = this.document.createElement('option');
$opt.value = m.id;
$opt.textContent = m.id;
this.$model.appendChild($opt);
// Sort provider names.
const sortedProviders = Object.keys(providers).sort();
// Create optgroups for each provider.
for (const provider of sortedProviders) {
const $optgroup = this.document.createElement('optgroup');
$optgroup.label = provider;
// Sort models within provider.
for (const m of providers[provider].sort((a, b) =>
a.model.localeCompare(b.model),
)) {
const $opt = this.document.createElement('option');
$opt.value = m.model;
$opt.dataset.provider = provider;
$opt.dataset.model = m.model;
switch (m.status) {
case 'available':
$opt.textContent = m.model;
break;
case 'not_found':
$opt.textContent = `~~${m.model}~~ (not found)`;
$opt.disabled = true;
$opt.classList.add('model-unavailable-not-found');
break;
case 'provider_error':
$opt.textContent = `${m.model} (provider error)`;
$opt.disabled = true;
$opt.classList.add('model-unavailable-error');
break;
}
$optgroup.appendChild($opt);
}
this.$model.appendChild($optgroup);
}
this.#loadModel(defaultModel);
this.#loadModel(defaultProvider, defaultModel);
}
/**
@@ -1829,24 +1876,37 @@ class Odidere {
/**
* #loadModel restores the model selection from localStorage or uses the
* default.
* @param {string} defaultProvider
* @param {string} defaultModel
*/
#loadModel(defaultModel) {
const stored = localStorage.getItem(MODEL_KEY);
const escaped = stored ? CSS.escape(stored) : null;
#loadModel(defaultProvider, defaultModel) {
const storedProvider = localStorage.getItem(PROVIDER_KEY);
const storedModel = localStorage.getItem(MODEL_KEY);
// Try stored value first, then default, then first available option.
let selectedValue;
if (escaped && this.$model.querySelector(`option[value="${escaped}"]`)) {
selectedValue = stored;
} else if (defaultModel) {
selectedValue = defaultModel;
} else if (this.$model.options.length > 0) {
selectedValue = this.$model.options[0].value;
let $opt;
if (storedProvider && storedModel) {
$opt = this.$model.querySelector(
`option[data-provider="${CSS.escape(storedProvider)}"][data-model="${CSS.escape(storedModel)}"]`,
);
}
if (!$opt && defaultProvider && defaultModel) {
$opt = this.$model.querySelector(
`option[data-provider="${CSS.escape(defaultProvider)}"][data-model="${CSS.escape(defaultModel)}"]`,
);
}
if (!$opt && this.$model.options.length > 0) {
// Find first available (non-disabled) option.
for (const opt of this.$model.options) {
if (!opt.disabled) {
$opt = opt;
break;
}
}
}
if (selectedValue) {
this.$model.value = selectedValue;
if ($opt) {
this.$model.value = $opt.value;
}
}