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:
italic-jinxin
2026-03-18 20:47:26 +08:00
parent 2339616637
commit 275a6b1761
7 changed files with 356 additions and 40 deletions
+155 -31
View File
@@ -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;
+2
View File
@@ -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',
+2
View File
@@ -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': '我的提供商',
+5 -5
View File
@@ -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
View File
@@ -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"
);
}
}
+64
View File
@@ -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 { .. }));
}
}
+19
View File
@@ -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>,