mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 23:50:17 +00:00
feat: add built-in provider API key and model configuration
- Add Configure button on built-in provider cards (openai, anthropic, gemini, ollama, etc.) to set API key and default model via web UI - Store overrides as `llm_builtin_overrides` setting (per-provider key/model map) using the existing generic settings k/v API - Add LlmBuiltinOverride struct in settings.rs; resolve in resolve_registry_provider() with priority: env var > selected_model > llm_builtin_overrides[id] > default - Restore provider's configured model to selected_model on provider switch, so /model command always takes precedence at runtime - Fix fetch-models button in built-in configure mode: use hardcoded base_url from BUILTIN_PROVIDERS instead of the hidden form field - Add edit support for custom providers with pre-filled dialog - Show current model on active and configured provider cards - Convert add/edit provider form to a modal dialog - Sync selected_model when editing or deleting an active custom provider
This commit is contained in:
+155
-31
@@ -4689,6 +4689,9 @@ document.addEventListener('click', function(e) {
|
||||
case 'edit-custom-provider':
|
||||
editCustomProvider(el.dataset.id);
|
||||
break;
|
||||
case 'configure-builtin-provider':
|
||||
configureBuiltinProvider(el.dataset.id);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4715,30 +4718,32 @@ function apiFetchVoid(path, options) {
|
||||
}
|
||||
|
||||
// Generated from providers.json + nearai/bedrock (handled separately in llm.rs)
|
||||
// Fields: id, name, adapter, base_url, builtin, default_model, api_key_required, can_list_models
|
||||
// nearai/bedrock use special auth flows — no Configure button (api_key_required=false, can_list_models=false)
|
||||
const BUILTIN_PROVIDERS = [
|
||||
{ id: 'nearai', name: 'NEAR AI', adapter: 'nearai', base_url: 'https://api.near.ai/v1', builtin: true },
|
||||
{ id: 'openai', name: 'OpenAI', adapter: 'open_ai_completions', base_url: 'https://api.openai.com/v1', builtin: true },
|
||||
{ id: 'anthropic', name: 'Anthropic', adapter: 'anthropic', base_url: 'https://api.anthropic.com', builtin: true },
|
||||
{ id: 'ollama', name: 'Ollama', adapter: 'ollama', base_url: 'http://localhost:11434', builtin: true },
|
||||
{ id: 'openai_compatible', name: 'OpenAI Compatible', adapter: 'open_ai_completions', base_url: '', builtin: true },
|
||||
{ id: 'gemini', name: 'Google Gemini', adapter: 'open_ai_completions', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai', builtin: true },
|
||||
{ id: 'groq', name: 'Groq', adapter: 'open_ai_completions', base_url: 'https://api.groq.com/openai/v1', builtin: true },
|
||||
{ id: 'openrouter', name: 'OpenRouter', adapter: 'open_ai_completions', base_url: 'https://openrouter.ai/api/v1', builtin: true },
|
||||
{ id: 'deepseek', name: 'DeepSeek', adapter: 'open_ai_completions', base_url: 'https://api.deepseek.com/v1', builtin: true },
|
||||
{ id: 'mistral', name: 'Mistral', adapter: 'open_ai_completions', base_url: 'https://api.mistral.ai/v1', builtin: true },
|
||||
{ id: 'tinfoil', name: 'Tinfoil', adapter: 'open_ai_completions', base_url: 'https://inference.tinfoil.sh/v1', builtin: true },
|
||||
{ id: 'nvidia', name: 'NVIDIA NIM', adapter: 'open_ai_completions', base_url: 'https://integrate.api.nvidia.com/v1', builtin: true },
|
||||
{ id: 'together', name: 'Together AI', adapter: 'open_ai_completions', base_url: 'https://api.together.xyz/v1', builtin: true },
|
||||
{ id: 'fireworks', name: 'Fireworks AI', adapter: 'open_ai_completions', base_url: 'https://api.fireworks.ai/inference/v1', builtin: true },
|
||||
{ id: 'cerebras', name: 'Cerebras', adapter: 'open_ai_completions', base_url: 'https://api.cerebras.ai/v1', builtin: true },
|
||||
{ id: 'sambanova', name: 'SambaNova', adapter: 'open_ai_completions', base_url: 'https://api.sambanova.ai/v1', builtin: true },
|
||||
{ id: 'zai', name: 'Z.AI', adapter: 'open_ai_completions', base_url: 'https://api.z.ai/api/paas/v4', builtin: true },
|
||||
{ id: 'venice', name: 'Venice.ai', adapter: 'open_ai_completions', base_url: 'https://api.venice.ai/api/v1', builtin: true },
|
||||
{ id: 'minimax', name: 'MiniMax', adapter: 'open_ai_completions', base_url: 'https://api.minimax.io/v1', builtin: true },
|
||||
{ id: 'ionet', name: 'io.net', adapter: 'open_ai_completions', base_url: 'https://api.intelligence.io.solutions/api/v1', builtin: true },
|
||||
{ id: 'cloudflare', name: 'Cloudflare AI', adapter: 'open_ai_completions', base_url: '', builtin: true },
|
||||
{ id: 'yandex', name: 'Yandex AI Studio', adapter: 'open_ai_completions', base_url: 'https://ai.api.cloud.yandex.net/v1', builtin: true },
|
||||
{ id: 'bedrock', name: 'AWS Bedrock', adapter: 'bedrock', base_url: '', builtin: true },
|
||||
{ id: 'nearai', name: 'NEAR AI', adapter: 'nearai', base_url: 'https://api.near.ai/v1', builtin: true, default_model: '', api_key_required: false, can_list_models: false },
|
||||
{ id: 'openai', name: 'OpenAI', adapter: 'open_ai_completions', base_url: 'https://api.openai.com/v1', builtin: true, default_model: 'gpt-5-mini', api_key_required: true, can_list_models: true },
|
||||
{ id: 'anthropic', name: 'Anthropic', adapter: 'anthropic', base_url: 'https://api.anthropic.com', builtin: true, default_model: 'claude-sonnet-4-20250514', api_key_required: true, can_list_models: true },
|
||||
{ id: 'ollama', name: 'Ollama', adapter: 'ollama', base_url: 'http://localhost:11434', builtin: true, default_model: 'llama3', api_key_required: false, can_list_models: true },
|
||||
{ id: 'openai_compatible', name: 'OpenAI Compatible', adapter: 'open_ai_completions', base_url: '', builtin: true, default_model: 'default', api_key_required: false, can_list_models: false },
|
||||
{ id: 'gemini', name: 'Google Gemini', adapter: 'open_ai_completions', base_url: 'https://generativelanguage.googleapis.com/v1beta/openai', builtin: true, default_model: 'gemini-2.5-flash', api_key_required: true, can_list_models: true },
|
||||
{ id: 'groq', name: 'Groq', adapter: 'open_ai_completions', base_url: 'https://api.groq.com/openai/v1', builtin: true, default_model: 'llama-3.3-70b-versatile', api_key_required: true, can_list_models: true },
|
||||
{ id: 'openrouter', name: 'OpenRouter', adapter: 'open_ai_completions', base_url: 'https://openrouter.ai/api/v1', builtin: true, default_model: 'openai/gpt-4o', api_key_required: true, can_list_models: false },
|
||||
{ id: 'deepseek', name: 'DeepSeek', adapter: 'open_ai_completions', base_url: 'https://api.deepseek.com/v1', builtin: true, default_model: 'deepseek-chat', api_key_required: true, can_list_models: false },
|
||||
{ id: 'mistral', name: 'Mistral', adapter: 'open_ai_completions', base_url: 'https://api.mistral.ai/v1', builtin: true, default_model: 'mistral-large-latest', api_key_required: true, can_list_models: true },
|
||||
{ id: 'tinfoil', name: 'Tinfoil', adapter: 'open_ai_completions', base_url: 'https://inference.tinfoil.sh/v1', builtin: true, default_model: 'kimi-k2-5', api_key_required: true, can_list_models: false },
|
||||
{ id: 'nvidia', name: 'NVIDIA NIM', adapter: 'open_ai_completions', base_url: 'https://integrate.api.nvidia.com/v1', builtin: true, default_model: 'meta/llama-3.3-70b-instruct', api_key_required: true, can_list_models: true },
|
||||
{ id: 'together', name: 'Together AI', adapter: 'open_ai_completions', base_url: 'https://api.together.xyz/v1', builtin: true, default_model: 'meta-llama/Llama-3-70b-chat-hf', api_key_required: true, can_list_models: false },
|
||||
{ id: 'fireworks', name: 'Fireworks AI', adapter: 'open_ai_completions', base_url: 'https://api.fireworks.ai/inference/v1', builtin: true, default_model: 'accounts/fireworks/models/llama-v3p1-70b-instruct', api_key_required: true, can_list_models: false },
|
||||
{ id: 'cerebras', name: 'Cerebras', adapter: 'open_ai_completions', base_url: 'https://api.cerebras.ai/v1', builtin: true, default_model: 'llama-3.3-70b', api_key_required: true, can_list_models: false },
|
||||
{ id: 'sambanova', name: 'SambaNova', adapter: 'open_ai_completions', base_url: 'https://api.sambanova.ai/v1', builtin: true, default_model: 'Meta-Llama-3.1-70B-Instruct', api_key_required: true, can_list_models: false },
|
||||
{ id: 'zai', name: 'Z.AI', adapter: 'open_ai_completions', base_url: 'https://api.z.ai/api/paas/v4', builtin: true, default_model: 'glm-5', api_key_required: true, can_list_models: false },
|
||||
{ id: 'venice', name: 'Venice.ai', adapter: 'open_ai_completions', base_url: 'https://api.venice.ai/api/v1', builtin: true, default_model: 'llama-3.3-70b', api_key_required: true, can_list_models: false },
|
||||
{ id: 'minimax', name: 'MiniMax', adapter: 'open_ai_completions', base_url: 'https://api.minimax.io/v1', builtin: true, default_model: 'MiniMax-M2.5', api_key_required: true, can_list_models: false },
|
||||
{ id: 'ionet', name: 'io.net', adapter: 'open_ai_completions', base_url: 'https://api.intelligence.io.solutions/api/v1', builtin: true, default_model: 'deepseek-coder-v2-instruct', api_key_required: true, can_list_models: true },
|
||||
{ id: 'cloudflare', name: 'Cloudflare AI', adapter: 'open_ai_completions', base_url: '', builtin: true, default_model: '@cf/meta/llama-3.3-70b-instruct-fp8-fast', api_key_required: true, can_list_models: false },
|
||||
{ id: 'yandex', name: 'Yandex AI Studio', adapter: 'open_ai_completions', base_url: 'https://ai.api.cloud.yandex.net/v1', builtin: true, default_model: 'yandexgpt-lite', api_key_required: true, can_list_models: true },
|
||||
{ id: 'bedrock', name: 'AWS Bedrock', adapter: 'bedrock', base_url: '', builtin: true, default_model: '', api_key_required: false, can_list_models: false },
|
||||
];
|
||||
|
||||
const ADAPTER_LABELS = {
|
||||
@@ -4752,7 +4757,9 @@ const ADAPTER_LABELS = {
|
||||
let _customProviders = [];
|
||||
let _activeLlmBackend = '';
|
||||
let _selectedModel = '';
|
||||
let _builtinOverrides = {};
|
||||
let _editingProviderId = null;
|
||||
let _configuringBuiltinId = null;
|
||||
let _configLoaded = false;
|
||||
|
||||
function loadConfig() {
|
||||
@@ -4769,12 +4776,19 @@ function loadConfig() {
|
||||
} catch (e) {
|
||||
_customProviders = [];
|
||||
}
|
||||
try {
|
||||
const val = s['llm_builtin_overrides'];
|
||||
_builtinOverrides = (val && typeof val === 'object' && !Array.isArray(val)) ? val : {};
|
||||
} catch (e) {
|
||||
_builtinOverrides = {};
|
||||
}
|
||||
_configLoaded = true;
|
||||
renderProviders();
|
||||
}).catch(() => {
|
||||
_activeLlmBackend = 'nearai';
|
||||
_selectedModel = '';
|
||||
_customProviders = [];
|
||||
_builtinOverrides = {};
|
||||
_configLoaded = true;
|
||||
renderProviders();
|
||||
});
|
||||
@@ -4808,14 +4822,22 @@ function renderProviders() {
|
||||
const editBtn = !p.builtin
|
||||
? '<button class="provider-action-btn" data-action="edit-custom-provider" data-id="' + escHtml(p.id) + '">' + I18n.t('common.edit') + '</button>'
|
||||
: '';
|
||||
// Show Configure for built-in providers that support it (not nearai/bedrock)
|
||||
const configureBtn = p.builtin && p.id !== 'nearai' && p.id !== 'bedrock'
|
||||
? '<button class="provider-action-btn" data-action="configure-builtin-provider" data-id="' + escHtml(p.id) + '">' + I18n.t('config.configureProvider') + '</button>'
|
||||
: '';
|
||||
const useBtn = !isActive
|
||||
? '<button class="provider-action-btn" data-action="set-active-provider" data-id="' + escHtml(p.id) + '">' + I18n.t('config.useProvider') + '</button>'
|
||||
: '';
|
||||
const baseUrlText = p.base_url
|
||||
? '<span class="provider-url">' + escHtml(p.base_url) + '</span>'
|
||||
: '';
|
||||
const modelText = isActive && _selectedModel
|
||||
? '<span class="provider-current-model">' + escHtml(I18n.t('config.currentModel', { model: _selectedModel })) + '</span>'
|
||||
// Show configured model: for active provider use _selectedModel, for others check _builtinOverrides
|
||||
const displayModel = isActive
|
||||
? _selectedModel
|
||||
: (p.builtin && _builtinOverrides[p.id] ? (_builtinOverrides[p.id].model || '') : '');
|
||||
const modelText = displayModel
|
||||
? '<span class="provider-current-model">' + escHtml(I18n.t('config.currentModel', { model: displayModel })) + '</span>'
|
||||
: '';
|
||||
|
||||
return '<div class="provider-card' + (isActive ? ' provider-card-active' : '') + '">'
|
||||
@@ -4830,7 +4852,7 @@ function renderProviders() {
|
||||
+ modelText
|
||||
+ '</div>'
|
||||
+ '<div class="provider-card-actions">'
|
||||
+ useBtn + editBtn + deleteBtn
|
||||
+ useBtn + configureBtn + editBtn + deleteBtn
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
@@ -4842,7 +4864,12 @@ function escHtml(s) {
|
||||
|
||||
function setActiveProvider(id) {
|
||||
const provider = [...BUILTIN_PROVIDERS, ..._customProviders].find((p) => p.id === id);
|
||||
const defaultModel = provider && provider.default_model ? provider.default_model : null;
|
||||
// Restore the last-configured model for this provider, falling back to the provider's default
|
||||
const restoredModel =
|
||||
(_builtinOverrides[id] && _builtinOverrides[id].model) ||
|
||||
(provider && provider.default_model) ||
|
||||
null;
|
||||
const defaultModel = restoredModel;
|
||||
const modelUpdate = defaultModel
|
||||
? apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: defaultModel } })
|
||||
: apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' });
|
||||
@@ -4896,6 +4923,35 @@ function editCustomProvider(id) {
|
||||
document.getElementById('provider-name').focus();
|
||||
}
|
||||
|
||||
function configureBuiltinProvider(id) {
|
||||
const p = BUILTIN_PROVIDERS.find((p) => p.id === id);
|
||||
if (!p) return;
|
||||
_configuringBuiltinId = id;
|
||||
const titleEl = document.getElementById('provider-form-title');
|
||||
titleEl.textContent = I18n.t('config.configureProvider') + ': ' + escHtml(p.name || id);
|
||||
titleEl.removeAttribute('data-i18n');
|
||||
// Hide name/id/adapter rows; show base-url as read-only for reference
|
||||
document.getElementById('provider-name-row').style.display = 'none';
|
||||
document.getElementById('provider-id-row').style.display = 'none';
|
||||
document.getElementById('provider-adapter-row').style.display = 'none';
|
||||
const baseUrlInput = document.getElementById('provider-base-url');
|
||||
if (p.base_url) {
|
||||
document.getElementById('provider-base-url-row').style.display = '';
|
||||
baseUrlInput.value = p.base_url;
|
||||
baseUrlInput.readOnly = true;
|
||||
baseUrlInput.style.opacity = '0.6';
|
||||
} else {
|
||||
document.getElementById('provider-base-url-row').style.display = 'none';
|
||||
}
|
||||
document.getElementById('provider-api-key-row').style.display = p.api_key_required !== false ? '' : 'none';
|
||||
document.getElementById('fetch-models-btn').style.display = p.can_list_models ? '' : 'none';
|
||||
const override = _builtinOverrides[id] || {};
|
||||
document.getElementById('provider-api-key').value = override.api_key || '';
|
||||
document.getElementById('provider-model').value = override.model || p.default_model || '';
|
||||
openProviderDialog(true);
|
||||
document.getElementById('provider-model').focus();
|
||||
}
|
||||
|
||||
// Add provider form
|
||||
|
||||
document.getElementById('add-provider-btn').addEventListener('click', () => {
|
||||
@@ -4915,6 +4971,14 @@ document.getElementById('provider-dialog-overlay').addEventListener('click', ()
|
||||
});
|
||||
|
||||
function openProviderDialog(isEdit) {
|
||||
if (!isEdit) {
|
||||
// Add mode: ensure all rows visible
|
||||
['provider-name-row', 'provider-id-row', 'provider-adapter-row',
|
||||
'provider-base-url-row', 'provider-api-key-row'].forEach((id) => {
|
||||
document.getElementById(id).style.display = '';
|
||||
});
|
||||
document.getElementById('fetch-models-btn').style.display = '';
|
||||
}
|
||||
document.getElementById('provider-dialog').style.display = 'flex';
|
||||
if (!isEdit) {
|
||||
document.getElementById('provider-name').focus();
|
||||
@@ -4922,11 +4986,20 @@ function openProviderDialog(isEdit) {
|
||||
}
|
||||
|
||||
document.getElementById('test-provider-btn').addEventListener('click', () => {
|
||||
const adapter = document.getElementById('provider-adapter').value;
|
||||
const baseUrl = document.getElementById('provider-base-url').value.trim();
|
||||
let adapter = document.getElementById('provider-adapter').value;
|
||||
let baseUrl = document.getElementById('provider-base-url').value.trim();
|
||||
const apiKey = document.getElementById('provider-api-key').value.trim();
|
||||
const model = document.getElementById('provider-model').value.trim();
|
||||
|
||||
// For built-in providers, use the hardcoded adapter/base_url from BUILTIN_PROVIDERS
|
||||
if (_configuringBuiltinId) {
|
||||
const p = BUILTIN_PROVIDERS.find((x) => x.id === _configuringBuiltinId);
|
||||
if (p) {
|
||||
adapter = p.adapter;
|
||||
baseUrl = p.base_url;
|
||||
}
|
||||
}
|
||||
|
||||
const btn = document.getElementById('test-provider-btn');
|
||||
const result = document.getElementById('test-connection-result');
|
||||
|
||||
@@ -4956,6 +5029,38 @@ document.getElementById('test-provider-btn').addEventListener('click', () => {
|
||||
});
|
||||
|
||||
document.getElementById('save-provider-btn').addEventListener('click', () => {
|
||||
// Built-in configure mode: save api_key + model to llm_builtin_overrides
|
||||
if (_configuringBuiltinId) {
|
||||
const apiKey = document.getElementById('provider-api-key').value.trim();
|
||||
const model = document.getElementById('provider-model').value.trim();
|
||||
const id = _configuringBuiltinId;
|
||||
const override = {};
|
||||
if (apiKey) override.api_key = apiKey;
|
||||
if (model) override.model = model;
|
||||
const prev = _builtinOverrides[id];
|
||||
_builtinOverrides[id] = override;
|
||||
const isActive = id === _activeLlmBackend;
|
||||
const modelUpdate = isActive
|
||||
? (model
|
||||
? apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: model } })
|
||||
: apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' }))
|
||||
: Promise.resolve();
|
||||
apiFetchVoid('/api/settings/llm_builtin_overrides', { method: 'PUT', body: { value: _builtinOverrides } })
|
||||
.then(() => modelUpdate)
|
||||
.then(() => {
|
||||
if (isActive) _selectedModel = model;
|
||||
renderProviders();
|
||||
resetProviderForm();
|
||||
document.getElementById('config-restart-notice').style.display = 'flex';
|
||||
showToast(I18n.t('config.providerConfigured', { name: id }));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (prev !== undefined) { _builtinOverrides[id] = prev; } else { delete _builtinOverrides[id]; }
|
||||
showToast(I18n.t('error.unknown') + ': ' + e.message, 'error');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const name = document.getElementById('provider-name').value.trim();
|
||||
const id = document.getElementById('provider-id').value.trim();
|
||||
const adapter = document.getElementById('provider-adapter').value;
|
||||
@@ -5019,13 +5124,23 @@ document.getElementById('save-provider-btn').addEventListener('click', () => {
|
||||
|
||||
function resetProviderForm() {
|
||||
_editingProviderId = null;
|
||||
_configuringBuiltinId = null;
|
||||
document.getElementById('provider-dialog').style.display = 'none';
|
||||
// Restore all hidden rows and buttons
|
||||
['provider-name-row', 'provider-id-row', 'provider-adapter-row',
|
||||
'provider-base-url-row', 'provider-api-key-row'].forEach((id) => {
|
||||
document.getElementById(id).style.display = '';
|
||||
});
|
||||
document.getElementById('fetch-models-btn').style.display = '';
|
||||
const titleEl = document.getElementById('provider-form-title');
|
||||
titleEl.setAttribute('data-i18n', 'config.newProvider');
|
||||
titleEl.textContent = I18n.t('config.newProvider');
|
||||
const idField = document.getElementById('provider-id');
|
||||
idField.readOnly = false;
|
||||
idField.style.opacity = '';
|
||||
const baseUrlField = document.getElementById('provider-base-url');
|
||||
baseUrlField.readOnly = false;
|
||||
baseUrlField.style.opacity = '';
|
||||
['provider-name', 'provider-id', 'provider-base-url', 'provider-api-key', 'provider-model'].forEach((id) => {
|
||||
document.getElementById(id).value = '';
|
||||
});
|
||||
@@ -5041,10 +5156,19 @@ document.getElementById('provider-model-select').addEventListener('change', (e)
|
||||
});
|
||||
|
||||
document.getElementById('fetch-models-btn').addEventListener('click', () => {
|
||||
const adapter = document.getElementById('provider-adapter').value;
|
||||
const baseUrl = document.getElementById('provider-base-url').value.trim();
|
||||
let adapter = document.getElementById('provider-adapter').value;
|
||||
let baseUrl = document.getElementById('provider-base-url').value.trim();
|
||||
const apiKey = document.getElementById('provider-api-key').value.trim();
|
||||
|
||||
// For built-in providers, use the hardcoded base_url and adapter from BUILTIN_PROVIDERS
|
||||
if (_configuringBuiltinId) {
|
||||
const p = BUILTIN_PROVIDERS.find((x) => x.id === _configuringBuiltinId);
|
||||
if (p) {
|
||||
adapter = p.adapter;
|
||||
baseUrl = p.base_url;
|
||||
}
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
showToast(I18n.t('config.providerBaseUrlRequired'), 'error');
|
||||
return;
|
||||
|
||||
@@ -348,6 +348,8 @@ I18n.register('en', {
|
||||
'config.restartNotice': 'Changes take effect after restart.',
|
||||
'config.builtin': 'built-in',
|
||||
'config.useProvider': 'Use',
|
||||
'config.configureProvider': 'Configure',
|
||||
'config.providerConfigured': 'Provider "{name}" configured (restart to apply)',
|
||||
'config.currentModel': 'Model: {model}',
|
||||
'config.providerName': 'Display Name',
|
||||
'config.providerNamePlaceholder': 'My Provider',
|
||||
|
||||
@@ -348,6 +348,8 @@ I18n.register('zh-CN', {
|
||||
'config.restartNotice': '更改将在重启后生效。',
|
||||
'config.builtin': '内置',
|
||||
'config.useProvider': '使用',
|
||||
'config.configureProvider': '配置',
|
||||
'config.providerConfigured': '提供商 "{name}" 已配置(重启后生效)',
|
||||
'config.currentModel': '模型:{model}',
|
||||
'config.providerName': '显示名称',
|
||||
'config.providerNamePlaceholder': '我的提供商',
|
||||
|
||||
@@ -54,16 +54,16 @@
|
||||
</div>
|
||||
<div class="provider-dialog-body">
|
||||
<div class="config-form">
|
||||
<div class="config-form-row">
|
||||
<div class="config-form-row" id="provider-name-row">
|
||||
<label data-i18n="config.providerName">Display Name</label>
|
||||
<input type="text" id="provider-name" data-i18n="config.providerNamePlaceholder" data-i18n-attr="placeholder" placeholder="My Provider">
|
||||
</div>
|
||||
<div class="config-form-row">
|
||||
<div class="config-form-row" id="provider-id-row">
|
||||
<label data-i18n="config.providerId">Provider ID</label>
|
||||
<input type="text" id="provider-id" data-i18n="config.providerIdPlaceholder" data-i18n-attr="placeholder" placeholder="my-provider">
|
||||
<span class="config-form-hint" data-i18n="config.providerIdHint">Lowercase letters, numbers, hyphens</span>
|
||||
</div>
|
||||
<div class="config-form-row">
|
||||
<div class="config-form-row" id="provider-adapter-row">
|
||||
<label data-i18n="config.providerAdapter">API Adapter</label>
|
||||
<select id="provider-adapter">
|
||||
<option value="open_ai_completions" data-i18n="config.adapterOpenAI">OpenAI Compatible</option>
|
||||
@@ -71,11 +71,11 @@
|
||||
<option value="ollama" data-i18n="config.adapterOllama">Ollama</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="config-form-row">
|
||||
<div class="config-form-row" id="provider-base-url-row">
|
||||
<label data-i18n="config.providerBaseUrl">Base URL</label>
|
||||
<input type="text" id="provider-base-url" placeholder="https://api.example.com/v1">
|
||||
</div>
|
||||
<div class="config-form-row">
|
||||
<div class="config-form-row" id="provider-api-key-row">
|
||||
<label data-i18n="config.providerApiKey">API Key</label>
|
||||
<input type="password" id="provider-api-key" placeholder="sk-...">
|
||||
</div>
|
||||
|
||||
+109
-4
@@ -342,8 +342,14 @@ impl LlmConfig {
|
||||
}
|
||||
Some(creds.token)
|
||||
} else if let Some(env_var) = api_key_env {
|
||||
// Resolve API key from env (including secrets store overlay)
|
||||
optional_env(env_var)?.map(SecretString::from)
|
||||
// Resolve API key: env var (including secrets store overlay) > settings override
|
||||
optional_env(env_var)?.map(SecretString::from).or_else(|| {
|
||||
settings
|
||||
.llm_builtin_overrides
|
||||
.get(backend)
|
||||
.and_then(|o| o.api_key.as_ref())
|
||||
.map(|k| SecretString::from(k.clone()))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -392,8 +398,16 @@ impl LlmConfig {
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve model
|
||||
let model = Self::resolve_model(model_env, settings, default_model)?;
|
||||
// Resolve model: env var > selected_model (/model command) > per-provider override > registry default
|
||||
let model = optional_env(model_env)?
|
||||
.or_else(|| settings.selected_model.clone())
|
||||
.or_else(|| {
|
||||
settings
|
||||
.llm_builtin_overrides
|
||||
.get(backend)
|
||||
.and_then(|o| o.model.clone())
|
||||
})
|
||||
.unwrap_or_else(|| default_model.to_string());
|
||||
|
||||
// Resolve extra headers
|
||||
let extra_headers = if let Some(env_var) = extra_headers_env {
|
||||
@@ -1193,4 +1207,95 @@ mod tests {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_override_model_used_when_no_selected_model() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
std::env::remove_var("GROQ_MODEL");
|
||||
}
|
||||
|
||||
let mut overrides = std::collections::HashMap::new();
|
||||
overrides.insert(
|
||||
"groq".to_string(),
|
||||
crate::settings::LlmBuiltinOverride { api_key: None, model: Some("llama-3.1-8b-instant".to_string()) },
|
||||
);
|
||||
let settings = Settings {
|
||||
llm_backend: Some("groq".to_string()),
|
||||
llm_builtin_overrides: overrides,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
assert_eq!(
|
||||
provider.model, "llama-3.1-8b-instant",
|
||||
"builtin override model should be used when selected_model is unset"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_model_takes_priority_over_builtin_override_model() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
std::env::remove_var("GROQ_MODEL");
|
||||
}
|
||||
|
||||
let mut overrides = std::collections::HashMap::new();
|
||||
overrides.insert(
|
||||
"groq".to_string(),
|
||||
crate::settings::LlmBuiltinOverride { api_key: None, model: Some("llama-3.1-8b-instant".to_string()) },
|
||||
);
|
||||
let settings = Settings {
|
||||
llm_backend: Some("groq".to_string()),
|
||||
selected_model: Some("llama-3.3-70b-versatile".to_string()),
|
||||
llm_builtin_overrides: overrides,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
assert_eq!(
|
||||
provider.model, "llama-3.3-70b-versatile",
|
||||
"selected_model (/model command) must take priority over builtin override"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_override_api_key_used_when_no_env_var() {
|
||||
let _guard = ENV_MUTEX.lock().expect("env mutex poisoned");
|
||||
// SAFETY: Under ENV_MUTEX.
|
||||
unsafe {
|
||||
std::env::remove_var("LLM_BACKEND");
|
||||
std::env::remove_var("GROQ_API_KEY");
|
||||
std::env::remove_var("GROQ_MODEL");
|
||||
}
|
||||
|
||||
let mut overrides = std::collections::HashMap::new();
|
||||
overrides.insert(
|
||||
"groq".to_string(),
|
||||
crate::settings::LlmBuiltinOverride {
|
||||
api_key: Some("gsk_test_key".to_string()),
|
||||
model: Some("llama-3.3-70b-versatile".to_string()),
|
||||
},
|
||||
);
|
||||
let settings = Settings {
|
||||
llm_backend: Some("groq".to_string()),
|
||||
llm_builtin_overrides: overrides,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let cfg = LlmConfig::resolve(&settings).expect("resolve should succeed");
|
||||
let provider = cfg.provider.expect("provider config should be present");
|
||||
use secrecy::ExposeSecret as _;
|
||||
let key = provider.api_key.expect("api_key should be set from builtin override");
|
||||
assert_eq!(
|
||||
key.expose_secret(), "gsk_test_key",
|
||||
"builtin override api_key should be used when env var is absent"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1370,4 +1370,68 @@ mod tests {
|
||||
// Should be 2 separate User messages (text user + tool result user)
|
||||
assert_eq!(history.len(), 2);
|
||||
}
|
||||
|
||||
/// Empty user messages (e.g. after thinking-tag stripping) must be skipped.
|
||||
/// Strict providers like Kimi return 400 when "content": "" is sent.
|
||||
#[test]
|
||||
fn test_empty_user_message_is_skipped() {
|
||||
let empty = ChatMessage::user("");
|
||||
let non_empty = ChatMessage::user("hello");
|
||||
let messages = vec![empty, non_empty];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
|
||||
assert_eq!(history.len(), 1, "empty user message must be dropped");
|
||||
match &history[0] {
|
||||
RigMessage::User { content } => {
|
||||
assert_eq!(content.len(), 1);
|
||||
let first = content.iter().next().expect("one content item");
|
||||
match first {
|
||||
UserContent::Text(t) => assert_eq!(t.text, "hello"),
|
||||
other => panic!("expected Text, got {:?}", other),
|
||||
}
|
||||
}
|
||||
other => panic!("expected User message, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty assistant messages (e.g. after thinking-tag stripping) must be skipped.
|
||||
#[test]
|
||||
fn test_empty_assistant_message_is_skipped() {
|
||||
let empty_asst = ChatMessage {
|
||||
role: crate::llm::Role::Assistant,
|
||||
content: String::new(),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
content_parts: vec![],
|
||||
};
|
||||
let non_empty = ChatMessage::user("hi");
|
||||
let messages = vec![empty_asst, non_empty];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
|
||||
assert_eq!(history.len(), 1, "empty assistant message must be dropped");
|
||||
assert!(matches!(history[0], RigMessage::User { .. }));
|
||||
}
|
||||
|
||||
/// A conversation mixing normal and empty messages: only non-empty ones survive.
|
||||
#[test]
|
||||
fn test_mixed_empty_and_non_empty_messages_filtered_correctly() {
|
||||
let user1 = ChatMessage::user("first");
|
||||
let empty_asst = ChatMessage {
|
||||
role: crate::llm::Role::Assistant,
|
||||
content: String::new(),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
content_parts: vec![],
|
||||
};
|
||||
let user2 = ChatMessage::user("");
|
||||
let asst = ChatMessage::assistant("response");
|
||||
let messages = vec![user1, empty_asst, user2, asst];
|
||||
let (_preamble, history) = convert_messages(&messages);
|
||||
|
||||
assert_eq!(history.len(), 2, "only non-empty messages should survive");
|
||||
assert!(matches!(history[0], RigMessage::User { .. }));
|
||||
assert!(matches!(history[1], RigMessage::Assistant { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Stores user preferences in ~/.ironclaw/settings.json.
|
||||
//! Settings are loaded with env var > settings.json > default priority.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -32,6 +33,20 @@ pub struct CustomLlmProviderSettings {
|
||||
pub builtin: bool,
|
||||
}
|
||||
|
||||
/// Per-provider overrides for built-in LLM providers (API key and/or model).
|
||||
///
|
||||
/// Stored as `llm_builtin_overrides` in the settings store, keyed by provider ID
|
||||
/// (e.g. `"openai"`, `"gemini"`). Resolved at startup during `LlmConfig::resolve()`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LlmBuiltinOverride {
|
||||
/// API key override (takes precedence over env var).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub api_key: Option<String>,
|
||||
/// Default model override (takes precedence over global `selected_model`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
/// User settings persisted to disk.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Settings {
|
||||
@@ -86,6 +101,10 @@ pub struct Settings {
|
||||
#[serde(default)]
|
||||
pub llm_custom_providers: Vec<CustomLlmProviderSettings>,
|
||||
|
||||
/// Per-provider overrides for built-in providers (API key and/or model).
|
||||
#[serde(default)]
|
||||
pub llm_builtin_overrides: HashMap<String, LlmBuiltinOverride>,
|
||||
|
||||
/// Ollama base URL (when llm_backend = "ollama").
|
||||
#[serde(default)]
|
||||
pub ollama_base_url: Option<String>,
|
||||
|
||||
Reference in New Issue
Block a user