feat: add test connection for custom LLM providers

- Add POST /api/llm/test_connection endpoint that validates
  connectivity and auth for OpenAI-compatible, Anthropic, and
  Ollama adapters (10s timeout, per-adapter request logic)
- Add "Test" button next to Save/Cancel in the add-provider form;
  result shown inline with green/red styling
- Hide delete button for the active provider instead of showing
  an error toast
- Sort the active provider to the top of the provider list
- Clear selected_model when switching providers to avoid
  model-not-supported errors on the new provider
- Add i18n keys for test/testing states (en + zh-CN)
This commit is contained in:
italic-jinxin
2026-03-18 19:45:07 +08:00
parent 3800a09613
commit 2339616637
7 changed files with 687 additions and 49 deletions
+263
View File
@@ -301,6 +301,9 @@ pub async fn start_server(
"/api/settings/{key}",
axum::routing::delete(settings_delete_handler),
)
// LLM utilities
.route("/api/llm/test_connection", post(llm_test_connection_handler))
.route("/api/llm/list_models", post(llm_list_models_handler))
// Gateway control plane
.route("/api/gateway/status", get(gateway_status_handler))
// OpenAI-compatible API
@@ -2589,6 +2592,266 @@ async fn settings_delete_handler(
Ok(StatusCode::NO_CONTENT)
}
#[derive(serde::Deserialize)]
struct TestConnectionRequest {
adapter: String,
base_url: String,
#[serde(default)]
api_key: Option<String>,
#[serde(default)]
model: Option<String>,
}
#[derive(serde::Serialize)]
struct TestConnectionResponse {
ok: bool,
message: String,
}
async fn llm_test_connection_handler(
Json(body): Json<TestConnectionRequest>,
) -> Json<TestConnectionResponse> {
Json(test_provider_connection(body).await)
}
async fn test_provider_connection(req: TestConnectionRequest) -> TestConnectionResponse {
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
{
Ok(c) => c,
Err(e) => {
return TestConnectionResponse {
ok: false,
message: format!("Failed to build HTTP client: {e}"),
}
}
};
let base = req.base_url.trim_end_matches('/');
match req.adapter.as_str() {
"ollama" => {
let url = format!("{base}/api/tags");
match client.get(&url).send().await {
Ok(r) if r.status().is_success() => TestConnectionResponse {
ok: true,
message: format!("Connected ({})", r.status()),
},
Ok(r) => TestConnectionResponse {
ok: false,
message: format!("Server returned {}", r.status()),
},
Err(e) => TestConnectionResponse {
ok: false,
message: format!("Connection failed: {e}"),
},
}
}
"anthropic" => {
let url = format!("{base}/messages");
let model = req.model.as_deref().unwrap_or("claude-3-haiku-20240307");
let payload = serde_json::json!({
"model": model,
"max_tokens": 1,
"messages": [{"role": "user", "content": "hi"}]
});
let mut builder = client
.post(&url)
.header("anthropic-version", "2023-06-01")
.json(&payload);
if let Some(key) = req.api_key.as_deref().filter(|k| !k.is_empty()) {
builder = builder.header("x-api-key", key);
}
interpret_chat_response(builder.send().await)
}
_ => {
// OpenAI-compatible
let url = format!("{base}/chat/completions");
let model = req.model.as_deref().unwrap_or("gpt-4o-mini");
let payload = serde_json::json!({
"model": model,
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 1
});
let mut builder = client.post(&url).json(&payload);
if let Some(key) = req.api_key.as_deref().filter(|k| !k.is_empty()) {
builder = builder.header("Authorization", format!("Bearer {key}"));
}
interpret_chat_response(builder.send().await)
}
}
}
fn interpret_chat_response(
result: Result<reqwest::Response, reqwest::Error>,
) -> TestConnectionResponse {
match result {
Ok(r) => {
let status = r.status();
if status.is_success() {
TestConnectionResponse { ok: true, message: format!("Connected ({})", status) }
} else if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
TestConnectionResponse {
ok: false,
message: format!("Authentication failed ({})", status),
}
} else if status.is_client_error() {
// 400/422 = server reachable, likely wrong model name — still a success for connectivity
TestConnectionResponse {
ok: true,
message: format!("Server reachable ({})", status),
}
} else {
TestConnectionResponse {
ok: false,
message: format!("Server error ({})", status),
}
}
}
Err(e) => TestConnectionResponse { ok: false, message: format!("Connection failed: {e}") },
}
}
#[derive(serde::Deserialize)]
struct ListModelsRequest {
adapter: String,
base_url: String,
#[serde(default)]
api_key: Option<String>,
}
#[derive(serde::Serialize)]
struct ListModelsResponse {
ok: bool,
models: Vec<String>,
message: String,
}
async fn llm_list_models_handler(
Json(body): Json<ListModelsRequest>,
) -> Json<ListModelsResponse> {
Json(fetch_provider_models(body).await)
}
async fn fetch_provider_models(req: ListModelsRequest) -> ListModelsResponse {
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
{
Ok(c) => c,
Err(e) => {
return ListModelsResponse {
ok: false,
models: vec![],
message: format!("Failed to build HTTP client: {e}"),
}
}
};
let base = req.base_url.trim_end_matches('/');
let auth = req.api_key.as_deref().filter(|k| !k.is_empty());
match req.adapter.as_str() {
"ollama" => {
let url = format!("{base}/api/tags");
match client.get(&url).send().await {
Ok(r) if r.status().is_success() => {
let body: serde_json::Value = r.json().await.unwrap_or_default();
let models: Vec<String> = body["models"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| m["name"].as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
if models.is_empty() {
ListModelsResponse {
ok: false,
models: vec![],
message: "No models found".to_string(),
}
} else {
ListModelsResponse {
ok: true,
message: format!("{} model(s) found", models.len()),
models,
}
}
}
Ok(r) => ListModelsResponse {
ok: false,
models: vec![],
message: format!("Server returned {}", r.status()),
},
Err(e) => ListModelsResponse {
ok: false,
models: vec![],
message: format!("Connection failed: {e}"),
},
}
}
_ => {
// OpenAI-compatible and Anthropic both support GET /models
let url = format!("{base}/models");
let mut builder = client.get(&url);
if let Some(key) = auth {
builder = builder.header("Authorization", format!("Bearer {key}"));
}
// Anthropic also needs the version header and uses x-api-key
if req.adapter == "anthropic" {
if let Some(key) = auth {
builder = client
.get(&url)
.header("x-api-key", key)
.header("anthropic-version", "2023-06-01");
}
}
match builder.send().await {
Ok(r) if r.status().is_success() => {
let body: serde_json::Value = r.json().await.unwrap_or_default();
// OpenAI: {"data": [{"id": "..."}]}
// Anthropic: {"data": [{"id": "..."}]}
let models: Vec<String> = body["data"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| m["id"].as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
if models.is_empty() {
ListModelsResponse {
ok: false,
models: vec![],
message: "No models found in response".to_string(),
}
} else {
ListModelsResponse {
ok: true,
message: format!("{} model(s) found", models.len()),
models,
}
}
}
Ok(r) => ListModelsResponse {
ok: false,
models: vec![],
message: format!("Server returned {} — list models not supported", r.status()),
},
Err(e) => ListModelsResponse {
ok: false,
models: vec![],
message: format!("Connection failed: {e}"),
},
}
}
}
}
async fn settings_export_handler(
State(state): State<Arc<GatewayState>>,
) -> Result<Json<SettingsExportResponse>, StatusCode> {
+172 -7
View File
@@ -4686,6 +4686,9 @@ document.addEventListener('click', function(e) {
case 'delete-custom-provider':
deleteCustomProvider(el.dataset.id);
break;
case 'edit-custom-provider':
editCustomProvider(el.dataset.id);
break;
}
});
@@ -4748,6 +4751,8 @@ const ADAPTER_LABELS = {
let _customProviders = [];
let _activeLlmBackend = '';
let _selectedModel = '';
let _editingProviderId = null;
let _configLoaded = false;
function loadConfig() {
@@ -4757,6 +4762,7 @@ function loadConfig() {
apiFetch('/api/settings/export').then((d) => {
const s = (d && d.settings) ? d.settings : {};
_activeLlmBackend = s['llm_backend'] ? String(s['llm_backend']) : 'nearai';
_selectedModel = s['selected_model'] ? String(s['selected_model']) : '';
try {
const val = s['llm_custom_providers'];
_customProviders = Array.isArray(val) ? val : (val ? JSON.parse(val) : []);
@@ -4767,6 +4773,7 @@ function loadConfig() {
renderProviders();
}).catch(() => {
_activeLlmBackend = 'nearai';
_selectedModel = '';
_customProviders = [];
_configLoaded = true;
renderProviders();
@@ -4798,12 +4805,18 @@ function renderProviders() {
const deleteBtn = !p.builtin && !isActive
? '<button class="provider-action-btn provider-delete-btn" data-action="delete-custom-provider" data-id="' + escHtml(p.id) + '">' + I18n.t('common.delete') + '</button>'
: '';
const editBtn = !p.builtin
? '<button class="provider-action-btn" data-action="edit-custom-provider" data-id="' + escHtml(p.id) + '">' + I18n.t('common.edit') + '</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>'
: '';
return '<div class="provider-card' + (isActive ? ' provider-card-active' : '') + '">'
+ '<div class="provider-card-header">'
@@ -4814,9 +4827,10 @@ function renderProviders() {
+ '<div class="provider-card-meta">'
+ '<span class="provider-adapter">' + escHtml(adapterLabel) + '</span>'
+ baseUrlText
+ modelText
+ '</div>'
+ '<div class="provider-card-actions">'
+ useBtn + deleteBtn
+ useBtn + editBtn + deleteBtn
+ '</div>'
+ '</div>';
}).join('');
@@ -4827,10 +4841,16 @@ 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;
const modelUpdate = defaultModel
? apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: defaultModel } })
: apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' });
apiFetchVoid('/api/settings/llm_backend', { method: 'PUT', body: { value: id } })
.then(() => apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' }))
.then(() => modelUpdate)
.then(() => {
_activeLlmBackend = id;
_selectedModel = defaultModel || '';
renderProviders();
document.getElementById('providers-list').scrollIntoView({ behavior: 'smooth', block: 'start' });
document.getElementById('config-restart-notice').style.display = 'flex';
@@ -4856,18 +4876,85 @@ function saveCustomProviders() {
return apiFetchVoid('/api/settings/llm_custom_providers', { method: 'PUT', body: { value: _customProviders } });
}
function editCustomProvider(id) {
const p = _customProviders.find((p) => p.id === id);
if (!p) return;
_editingProviderId = id;
const titleEl = document.getElementById('provider-form-title');
titleEl.textContent = I18n.t('config.editProvider');
titleEl.removeAttribute('data-i18n');
document.getElementById('provider-name').value = p.name || '';
const idField = document.getElementById('provider-id');
idField.value = p.id;
idField.readOnly = true;
idField.style.opacity = '0.6';
document.getElementById('provider-adapter').value = p.adapter || 'open_ai_completions';
document.getElementById('provider-base-url').value = p.base_url || '';
document.getElementById('provider-api-key').value = p.api_key || '';
document.getElementById('provider-model').value = p.default_model || '';
openProviderDialog(true);
document.getElementById('provider-name').focus();
}
// Add provider form
document.getElementById('add-provider-btn').addEventListener('click', () => {
document.getElementById('add-provider-form').style.display = '';
document.getElementById('add-provider-btn').style.display = 'none';
document.getElementById('provider-name').focus();
openProviderDialog(false);
});
document.getElementById('cancel-provider-btn').addEventListener('click', () => {
resetProviderForm();
});
document.getElementById('cancel-provider-footer-btn').addEventListener('click', () => {
resetProviderForm();
});
document.getElementById('provider-dialog-overlay').addEventListener('click', () => {
resetProviderForm();
});
function openProviderDialog(isEdit) {
document.getElementById('provider-dialog').style.display = 'flex';
if (!isEdit) {
document.getElementById('provider-name').focus();
}
}
document.getElementById('test-provider-btn').addEventListener('click', () => {
const adapter = document.getElementById('provider-adapter').value;
const 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();
const btn = document.getElementById('test-provider-btn');
const result = document.getElementById('test-connection-result');
btn.disabled = true;
btn.textContent = I18n.t('config.testing');
result.style.display = 'none';
result.className = 'test-connection-result';
apiFetch('/api/llm/test_connection', {
method: 'POST',
body: { adapter, base_url: baseUrl, api_key: apiKey || undefined, model: model || undefined },
})
.then((data) => {
result.textContent = data.message;
result.className = 'test-connection-result ' + (data.ok ? 'test-ok' : 'test-fail');
result.style.display = '';
})
.catch((e) => {
result.textContent = e.message;
result.className = 'test-connection-result test-fail';
result.style.display = '';
})
.finally(() => {
btn.disabled = false;
btn.textContent = I18n.t('config.testConnection');
});
});
document.getElementById('save-provider-btn').addEventListener('click', () => {
const name = document.getElementById('provider-name').value.trim();
const id = document.getElementById('provider-id').value.trim();
@@ -4880,6 +4967,32 @@ document.getElementById('save-provider-btn').addEventListener('click', () => {
showToast(I18n.t('config.providerFieldsRequired'), 'error');
return;
}
if (_editingProviderId) {
// Update existing provider
const idx = _customProviders.findIndex((p) => p.id === _editingProviderId);
if (idx === -1) return;
const original = _customProviders[idx];
_customProviders[idx] = { ...original, name, adapter, base_url: baseUrl, default_model: model || undefined, api_key: apiKey || undefined };
const isActive = _editingProviderId === _activeLlmBackend;
const modelUpdate = isActive
? (model
? apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: model } })
: apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' }))
: Promise.resolve();
saveCustomProviders().then(() => modelUpdate).then(() => {
if (isActive) _selectedModel = model;
renderProviders();
resetProviderForm();
document.getElementById('config-restart-notice').style.display = 'flex';
showToast(I18n.t('config.providerUpdated', { name }));
}).catch((e) => {
_customProviders[idx] = original;
showToast(I18n.t('error.unknown') + ': ' + e.message, 'error');
});
return;
}
if (!/^[a-z0-9_-]+$/.test(id)) {
showToast(I18n.t('config.providerIdInvalid'), 'error');
return;
@@ -4905,14 +5018,66 @@ document.getElementById('save-provider-btn').addEventListener('click', () => {
});
function resetProviderForm() {
document.getElementById('add-provider-form').style.display = 'none';
document.getElementById('add-provider-btn').style.display = '';
_editingProviderId = null;
document.getElementById('provider-dialog').style.display = 'none';
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 = '';
['provider-name', 'provider-id', 'provider-base-url', 'provider-api-key', 'provider-model'].forEach((id) => {
document.getElementById(id).value = '';
});
document.getElementById('provider-adapter').selectedIndex = 0;
const sel = document.getElementById('provider-model-select');
sel.innerHTML = '';
sel.style.display = 'none';
document.getElementById('test-connection-result').style.display = 'none';
}
document.getElementById('provider-model-select').addEventListener('change', (e) => {
document.getElementById('provider-model').value = e.target.value;
});
document.getElementById('fetch-models-btn').addEventListener('click', () => {
const adapter = document.getElementById('provider-adapter').value;
const baseUrl = document.getElementById('provider-base-url').value.trim();
const apiKey = document.getElementById('provider-api-key').value.trim();
if (!baseUrl) {
showToast(I18n.t('config.providerBaseUrlRequired'), 'error');
return;
}
const btn = document.getElementById('fetch-models-btn');
btn.disabled = true;
btn.textContent = '…';
apiFetch('/api/llm/list_models', {
method: 'POST',
body: { adapter, base_url: baseUrl, api_key: apiKey || undefined },
})
.then((data) => {
const select = document.getElementById('provider-model-select');
if (data.ok && data.models && data.models.length > 0) {
const currentModel = document.getElementById('provider-model').value;
select.innerHTML = data.models
.map((m) => `<option value="${escHtml(m)}"${m === currentModel ? ' selected' : ''}>${escHtml(m)}</option>`)
.join('');
select.style.display = '';
showToast(I18n.t('config.modelsFetched', { count: data.models.length }));
} else {
showToast(data.message || I18n.t('config.modelsFetchFailed'), 'error');
}
})
.catch((e) => showToast(e.message, 'error'))
.finally(() => {
btn.disabled = false;
btn.textContent = '↻';
});
});
// Auto-fill provider ID from name
document.getElementById('provider-name').addEventListener('input', (e) => {
const idField = document.getElementById('provider-id');
+9
View File
@@ -348,6 +348,7 @@ I18n.register('en', {
'config.restartNotice': 'Changes take effect after restart.',
'config.builtin': 'built-in',
'config.useProvider': 'Use',
'config.currentModel': 'Model: {model}',
'config.providerName': 'Display Name',
'config.providerNamePlaceholder': 'My Provider',
'config.providerId': 'Provider ID',
@@ -362,9 +363,17 @@ I18n.register('en', {
'config.providerModel': 'Default Model',
'config.providerActivated': 'Switched to {name} (restart to apply)',
'config.providerAdded': 'Added provider "{name}" (restart to apply)',
'config.providerUpdated': 'Provider "{name}" updated (restart to apply)',
'config.editProvider': 'Edit Provider',
'config.providerDeleted': 'Provider deleted',
'config.confirmDeleteProvider': 'Delete provider "{id}"?',
'config.cannotDeleteActiveProvider': 'Cannot delete the active provider. Switch to another provider first.',
'config.testConnection': 'Test',
'config.testing': 'Testing…',
'config.fetchModels': 'Fetch available models',
'config.modelsFetched': '{count} model(s) loaded — type to filter',
'config.modelsFetchFailed': 'Failed to fetch models',
'config.providerBaseUrlRequired': 'Base URL is required to fetch models',
'config.providerFieldsRequired': 'Display name and Provider ID are required',
'config.providerIdInvalid': 'Provider ID: use only lowercase letters, numbers, hyphens',
'config.providerIdTaken': 'Provider ID "{id}" is already taken',
+9
View File
@@ -348,6 +348,7 @@ I18n.register('zh-CN', {
'config.restartNotice': '更改将在重启后生效。',
'config.builtin': '内置',
'config.useProvider': '使用',
'config.currentModel': '模型:{model}',
'config.providerName': '显示名称',
'config.providerNamePlaceholder': '我的提供商',
'config.providerId': '提供商 ID',
@@ -362,9 +363,17 @@ I18n.register('zh-CN', {
'config.providerModel': '默认模型',
'config.providerActivated': '已切换到 {name}(重启后生效)',
'config.providerAdded': '已添加提供商 "{name}"(重启后生效)',
'config.providerUpdated': '提供商 "{name}" 已更新(重启后生效)',
'config.editProvider': '编辑提供商',
'config.providerDeleted': '提供商已删除',
'config.confirmDeleteProvider': '确定删除提供商 "{id}"',
'config.cannotDeleteActiveProvider': '无法删除当前正在使用的提供商,请先切换到其他提供商。',
'config.testConnection': '测试',
'config.testing': '测试中…',
'config.fetchModels': '获取可用模型',
'config.modelsFetched': '已加载 {count} 个模型,可输入过滤',
'config.modelsFetchFailed': '获取模型列表失败',
'config.providerBaseUrlRequired': '请先填写 Base URL',
'config.providerFieldsRequired': '显示名称和提供商 ID 为必填项',
'config.providerIdInvalid': '提供商 ID 只能包含小写字母、数字和连字符',
'config.providerIdTaken': '提供商 ID "{id}" 已被占用',
+54 -38
View File
@@ -44,6 +44,60 @@
</div>
</div>
<!-- Provider Add/Edit Dialog -->
<div id="provider-dialog" class="provider-dialog" style="display:none">
<div class="provider-dialog-overlay" id="provider-dialog-overlay"></div>
<div class="provider-dialog-content">
<div class="provider-dialog-header">
<h2 id="provider-form-title" data-i18n="config.newProvider">New Provider</h2>
<button class="provider-dialog-close" id="cancel-provider-btn" title="Close">×</button>
</div>
<div class="provider-dialog-body">
<div class="config-form">
<div class="config-form-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">
<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">
<label data-i18n="config.providerAdapter">API Adapter</label>
<select id="provider-adapter">
<option value="open_ai_completions" data-i18n="config.adapterOpenAI">OpenAI Compatible</option>
<option value="anthropic" data-i18n="config.adapterAnthropic">Anthropic</option>
<option value="ollama" data-i18n="config.adapterOllama">Ollama</option>
</select>
</div>
<div class="config-form-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">
<label data-i18n="config.providerApiKey">API Key</label>
<input type="password" id="provider-api-key" placeholder="sk-...">
</div>
<div class="config-form-row">
<label data-i18n="config.providerModel">Default Model</label>
<div class="provider-model-input-group">
<input type="text" id="provider-model" placeholder="gpt-4o">
<button id="fetch-models-btn" class="btn-fetch-models" type="button" data-i18n-title="config.fetchModels" title="Fetch models"></button>
</div>
<select id="provider-model-select" style="display:none"></select>
</div>
<div id="test-connection-result" class="test-connection-result" style="display:none"></div>
</div>
</div>
<div class="provider-dialog-footer">
<button id="save-provider-btn" data-i18n="common.save">Save</button>
<button id="test-provider-btn" class="btn-secondary" data-i18n="config.testConnection">Test</button>
<button id="cancel-provider-footer-btn" class="btn-secondary" data-i18n="common.cancel">Cancel</button>
</div>
</div>
</div>
<!-- Restart Confirmation Modal -->
<div id="restart-confirm-modal" class="restart-modal" style="display: none;">
<div class="restart-modal-overlay" id="restart-overlay"></div>
@@ -334,44 +388,6 @@
<div class="empty-state" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="extensions-section config-form-section" id="add-provider-form" style="display:none">
<h3 data-i18n="config.newProvider">New Provider</h3>
<div class="config-form">
<div class="config-form-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">
<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">
<label data-i18n="config.providerAdapter">API Adapter</label>
<select id="provider-adapter">
<option value="open_ai_completions" data-i18n="config.adapterOpenAI">OpenAI Compatible</option>
<option value="anthropic" data-i18n="config.adapterAnthropic">Anthropic</option>
<option value="ollama" data-i18n="config.adapterOllama">Ollama</option>
</select>
</div>
<div class="config-form-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">
<label data-i18n="config.providerApiKey">API Key</label>
<input type="password" id="provider-api-key" placeholder="sk-...">
</div>
<div class="config-form-row">
<label data-i18n="config.providerModel">Default Model</label>
<input type="text" id="provider-model" placeholder="gpt-4o">
</div>
<div class="config-form-actions">
<button id="save-provider-btn" data-i18n="common.save">Save</button>
<button id="cancel-provider-btn" class="btn-secondary" data-i18n="common.cancel">Cancel</button>
</div>
</div>
</div>
</div>
</div>
+170 -4
View File
@@ -4281,6 +4281,13 @@ mark {
opacity: 0.7;
}
.provider-current-model {
font-size: 11px;
color: var(--accent);
font-family: var(--font-mono);
font-weight: 500;
}
.provider-card-actions {
display: flex;
gap: 6px;
@@ -4311,18 +4318,125 @@ mark {
/* Config form */
.config-form-section {
.provider-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.provider-dialog-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.provider-dialog-content {
position: relative;
z-index: 10000;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
background: var(--bg-secondary);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.4);
width: 100%;
max-width: 480px;
margin: 0 1rem;
display: flex;
flex-direction: column;
max-height: 90vh;
}
.provider-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.provider-dialog-header h2 {
font-size: 14px;
font-weight: 600;
color: var(--text);
margin: 0;
}
.provider-dialog-close {
color: var(--text-secondary);
font-size: 18px;
line-height: 1;
padding: 2px 6px;
background: transparent;
border: none;
border-radius: var(--radius);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.provider-dialog-close:hover {
color: var(--text);
background: var(--bg-hover);
}
.provider-dialog-body {
padding: 18px;
overflow-y: auto;
flex: 1;
}
.provider-dialog-footer {
display: flex;
gap: 8px;
padding: 14px 18px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.provider-dialog-footer button {
padding: 6px 18px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, transform 0.2s;
}
.provider-dialog-footer button:first-child {
background: var(--accent);
color: #09090b;
border: none;
}
.provider-dialog-footer button:first-child:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
.provider-dialog-footer .btn-secondary {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
}
.provider-dialog-footer .btn-secondary:hover {
color: var(--text);
border-color: rgba(255, 255, 255, 0.2);
}
.config-form {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 480px;
}
.config-form-row {
@@ -4396,3 +4510,55 @@ mark {
color: var(--text);
border-color: rgba(255, 255, 255, 0.2);
}
.provider-model-input-group {
display: flex;
gap: 6px;
align-items: center;
}
.provider-model-input-group input {
flex: 1;
}
.btn-fetch-models {
padding: 5px 10px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-secondary);
cursor: pointer;
font-size: 15px;
line-height: 1;
transition: color 0.15s, border-color 0.15s;
flex-shrink: 0;
}
.btn-fetch-models:hover {
color: var(--text);
border-color: rgba(255, 255, 255, 0.2);
}
.btn-fetch-models:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.test-connection-result {
margin-top: 8px;
padding: 6px 12px;
border-radius: var(--radius);
font-size: 13px;
}
.test-connection-result.test-ok {
background: rgba(74, 222, 128, 0.12);
color: #4ade80;
border: 1px solid rgba(74, 222, 128, 0.3);
}
.test-connection-result.test-fail {
background: rgba(248, 113, 113, 0.12);
color: #f87171;
border: 1px solid rgba(248, 113, 113, 0.3);
}
+10
View File
@@ -290,6 +290,10 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
}
crate::llm::Role::User => {
if msg.content_parts.is_empty() {
// Skip empty user messages — some providers (e.g. Kimi) reject "content": ""
if msg.content.is_empty() {
continue;
}
history.push(RigMessage::user(&msg.content));
} else {
// Build multimodal user message with text + image parts
@@ -353,6 +357,12 @@ fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<RigMessage
history.push(RigMessage::assistant(&msg.content));
}
} else {
// Skip empty assistant messages — these occur when thinking-tag stripping
// leaves a blank response; sending "content": "" causes 400 on strict
// OpenAI-compatible providers (e.g. Kimi).
if msg.content.is_empty() {
continue;
}
history.push(RigMessage::assistant(&msg.content));
}
}