fix: model provider config hardening (#2572)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jinxin
2026-04-22 11:15:24 +08:00
committed by GitHub
parent e29429d727
commit d4d5263ea6
10 changed files with 1002 additions and 56 deletions

View File

@@ -28,6 +28,7 @@ I18n.register('en', {
'restart.progressTitle': 'Restarting IronClaw',
'restart.progressSubtitle': 'Please wait for the process to restart...',
'restart.checkLogs': 'Check the Logs tab for details after restart completes.',
'restart.closeTooltip': 'Close',
// Theme
'theme.tooltipDark': 'Theme: Dark (click for Light)',
@@ -101,6 +102,7 @@ I18n.register('en', {
'status.reconnecting': 'Reconnecting...',
'status.teeVerified': 'TEE Verified',
'status.restart': 'Restart',
'status.restartTooltip': 'Gracefully restart the process',
'status.active': 'Active',
'status.installed': 'Installed',
'status.awaitingPairing': 'Awaiting Pairing',
@@ -457,6 +459,10 @@ I18n.register('en', {
'config.builtin': 'built-in',
'config.useProvider': 'Use',
'config.configureProvider': 'Configure',
'config.notConfigured': 'Not Configured',
'config.configureToUse': 'Configure the API key before using this provider.',
'config.baseUrlRequired': 'Base URL is required. Please configure the provider first.',
'config.modelRequired': 'A model must be configured before using this provider.',
'config.providerConfigured': 'Provider "{name}" configured (restart to apply)',
'config.currentModel': 'Model: {model}',
'config.providerName': 'Display Name',

View File

@@ -28,6 +28,7 @@ I18n.register('ko', {
'restart.progressTitle': 'IronClaw 재시작 중',
'restart.progressSubtitle': '프로세스가 재시작될 때까지 기다려 주세요...',
'restart.checkLogs': '재시작이 완료된 후 자세한 내용은 로그 탭을 확인하세요.',
'restart.closeTooltip': '닫기',
// 테마
'theme.tooltipDark': '테마: 다크 (클릭하여 라이트로 변경)',
@@ -101,6 +102,7 @@ I18n.register('ko', {
'status.reconnecting': '재연결 중...',
'status.teeVerified': 'TEE 검증됨',
'status.restart': '재시작',
'status.restartTooltip': '프로세스를 정상적으로 재시작합니다',
'status.active': '활성',
'status.installed': '설치됨',
'status.awaitingPairing': '페어링 대기 중',
@@ -456,6 +458,10 @@ I18n.register('ko', {
'config.builtin': '내장',
'config.useProvider': '사용',
'config.configureProvider': '구성',
'config.notConfigured': '미구성',
'config.configureToUse': '이 공급자를 사용하기 전에 API 키를 구성하세요.',
'config.baseUrlRequired': '베이스 URL이 필요합니다. 먼저 공급자를 구성하세요.',
'config.modelRequired': '이 공급자를 사용하기 전에 모델을 구성해야 합니다.',
'config.providerConfigured': '공급자 "{name}"이(가) 구성되었습니다 (재시작 필요)',
'config.currentModel': '모델: {model}',
'config.providerName': '표시 이름',

View File

@@ -28,6 +28,7 @@ I18n.register('zh-CN', {
'restart.progressTitle': '正在重启 IronClaw',
'restart.progressSubtitle': '请等待进程重启...',
'restart.checkLogs': '重启完成后,请查看日志标签页了解详情。',
'restart.closeTooltip': '关闭',
// 主题
'theme.tooltipDark': '主题:深色(点击切换浅色)',
@@ -101,6 +102,7 @@ I18n.register('zh-CN', {
'status.reconnecting': '重新连接中...',
'status.teeVerified': 'TEE 已验证',
'status.restart': '重启',
'status.restartTooltip': '优雅地重启进程',
'status.active': '已激活',
'status.installed': '已安装',
'status.awaitingPairing': '等待配对',
@@ -456,6 +458,10 @@ I18n.register('zh-CN', {
'config.builtin': '内置',
'config.useProvider': '使用',
'config.configureProvider': '配置',
'config.notConfigured': '未配置',
'config.configureToUse': '请先配置 API 密钥后再使用此提供商。',
'config.baseUrlRequired': '需要配置基础 URL请先配置此提供商。',
'config.modelRequired': '使用此提供商前需要配置模型。',
'config.providerConfigured': '提供商 "{name}" 已配置(重启后生效)',
'config.currentModel': '模型:{model}',
'config.providerName': '显示名称',

View File

@@ -60,6 +60,98 @@ function scrollToProviders() {
if (section) section.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/** Check whether a provider has all required credentials (API key + base URL if required). */
function isProviderConfigured(provider) {
// ── API key check ──────────────────────────────────────────────────────
// Built-in providers carry `api_key_required` from the backend registry.
// Custom providers don't — derive the requirement from the adapter instead:
// ollama runs locally and needs no key; other adapters do.
const needsKey = provider.builtin
? provider.api_key_required !== false
: provider.adapter !== 'ollama';
const hasEnvKey = provider.has_api_key === true;
const overrideKey = provider.builtin && _builtinOverrides[provider.id]
? _builtinOverrides[provider.id].api_key
: undefined;
// For custom providers, `api_key` is either the sentinel (vaulted on the
// server) OR a freshly-entered plaintext string that hasn't been swapped
// for the sentinel yet. Both mean the provider is configured.
const customKey = !provider.builtin ? provider.api_key : undefined;
const hasDbKey = provider.builtin
? (overrideKey === API_KEY_UNCHANGED || (typeof overrideKey === 'string' && overrideKey.length > 0))
: (customKey === API_KEY_UNCHANGED || (typeof customKey === 'string' && customKey.length > 0));
const keyOk = !needsKey || hasEnvKey || hasDbKey;
if (!keyOk) return false;
// ── Base URL check ─────────────────────────────────────────────────────
// Built-ins with `base_url_required` (e.g. openai_compatible) have no
// hardcoded fallback in the client layer, so activation must be gated on
// having a URL from SOME source. Custom providers always need a URL
// because they have no default at all.
const needsBaseUrl = provider.builtin
? provider.base_url_required === true
: true;
if (!needsBaseUrl) return true;
const overrideBaseUrl = provider.builtin && _builtinOverrides[provider.id]
? _builtinOverrides[provider.id].base_url
: undefined;
const hasOverrideBaseUrl = typeof overrideBaseUrl === 'string' && overrideBaseUrl.trim().length > 0;
const hasEnvBaseUrl = typeof provider.env_base_url === 'string' && provider.env_base_url.trim().length > 0;
// `provider.base_url` is the registry default for built-ins (may be empty
// when base_url_required=true and there's no default) OR the user-set URL
// for custom providers.
const hasProviderBaseUrl = typeof provider.base_url === 'string' && provider.base_url.trim().length > 0;
return hasOverrideBaseUrl || hasEnvBaseUrl || hasProviderBaseUrl;
}
/**
* Determine what's missing on an unconfigured provider for a precise toast.
* Returns 'base_url' if the base URL is missing, 'api_key' if the key is
* missing, or 'ok' if nothing is missing. Mirrors the checks in
* isProviderConfigured — keep the two in sync.
*/
function providerMissingReason(provider) {
// API key check — matches isProviderConfigured above.
const needsKey = provider.builtin
? provider.api_key_required !== false
: provider.adapter !== 'ollama';
if (needsKey) {
const hasEnvKey = provider.has_api_key === true;
const overrideKey = provider.builtin && _builtinOverrides[provider.id]
? _builtinOverrides[provider.id].api_key
: undefined;
const customKey = !provider.builtin ? provider.api_key : undefined;
const hasDbKey = provider.builtin
? (overrideKey === API_KEY_UNCHANGED || (typeof overrideKey === 'string' && overrideKey.length > 0))
: (customKey === API_KEY_UNCHANGED || (typeof customKey === 'string' && customKey.length > 0));
if (!hasEnvKey && !hasDbKey) return 'api_key';
}
// Base URL check — matches isProviderConfigured above.
const needsBaseUrl = provider.builtin
? provider.base_url_required === true
: true;
if (needsBaseUrl) {
const overrideBaseUrl = provider.builtin && _builtinOverrides[provider.id]
? _builtinOverrides[provider.id].base_url
: undefined;
const hasOverrideBaseUrl = typeof overrideBaseUrl === 'string' && overrideBaseUrl.trim().length > 0;
const hasEnvBaseUrl = typeof provider.env_base_url === 'string' && provider.env_base_url.trim().length > 0;
const hasProviderBaseUrl = typeof provider.base_url === 'string' && provider.base_url.trim().length > 0;
if (!hasOverrideBaseUrl && !hasEnvBaseUrl && !hasProviderBaseUrl) return 'base_url';
}
return 'ok';
}
/** Open the appropriate configuration dialog for a provider. */
function openProviderConfigDialog(provider) {
if (provider.builtin && provider.id !== 'bedrock') {
configureBuiltinProvider(provider.id);
} else if (!provider.builtin) {
editCustomProvider(provider.id);
}
}
function renderProviders() {
const list = document.getElementById('providers-list');
const allProviders = [..._builtinProviders, ..._customProviders].sort((a, b) => {
@@ -76,12 +168,16 @@ function renderProviders() {
list.innerHTML = allProviders.map((p) => {
const isActive = p.id === _activeLlmBackend;
const adapterLabel = ADAPTER_LABELS[p.adapter] || p.adapter;
const isConfigured = isProviderConfigured(p);
const activeBadge = isActive
? '<span class="provider-badge provider-badge-active">' + I18n.t('status.active') + '</span>'
: '';
const builtinBadge = p.builtin
? '<span class="provider-badge provider-badge-builtin">' + I18n.t('config.builtin') + '</span>'
: '';
const unconfiguredBadge = !isActive && !isConfigured
? '<span class="provider-badge provider-badge-unconfigured">' + I18n.t('config.notConfigured') + '</span>'
: '';
const deleteBtn = !p.builtin && !isActive
? '<button class="provider-action-btn provider-delete-btn" data-action="delete-custom-provider" data-id="' + escapeHtml(p.id) + '">' + I18n.t('common.delete') + '</button>'
: '';
@@ -92,7 +188,8 @@ function renderProviders() {
const configureBtn = p.builtin && p.id !== 'bedrock'
? '<button class="provider-action-btn" data-action="configure-builtin-provider" data-id="' + escapeHtml(p.id) + '">' + I18n.t('config.configureProvider') + '</button>'
: '';
const useBtn = !isActive
// Only show "Use" if provider is configured; unconfigured providers must be configured first
const useBtn = !isActive && isConfigured
? '<button class="provider-action-btn" data-action="set-active-provider" data-id="' + escapeHtml(p.id) + '">' + I18n.t('config.useProvider') + '</button>'
: '';
const overrideBaseUrl = p.builtin && _builtinOverrides[p.id] ? (_builtinOverrides[p.id].base_url || '') : '';
@@ -113,7 +210,7 @@ function renderProviders() {
+ '<div class="provider-card-header">'
+ '<span class="provider-name">' + escapeHtml(p.name || p.id) + '</span>'
+ '<span class="provider-id-label">' + escapeHtml(p.id) + '</span>'
+ activeBadge + builtinBadge
+ activeBadge + builtinBadge + unconfiguredBadge
+ '</div>'
+ '<div class="provider-card-meta">'
+ '<span class="provider-adapter">' + escapeHtml(adapterLabel) + '</span>'
@@ -129,12 +226,28 @@ function renderProviders() {
function setActiveProvider(id) {
const provider = [..._builtinProviders, ..._customProviders].find((p) => p.id === id);
if (provider && !isProviderConfigured(provider)) {
// Pick a specific message so the user knows WHAT is missing, not just
// "configure the provider". Check base URL first because a provider
// that needs both a key and a URL typically surfaces URL entry first
// in the dialog layout.
const reason = providerMissingReason(provider);
const toastKey = reason === 'base_url' ? 'config.baseUrlRequired' : 'config.configureToUse';
showToast(I18n.t(toastKey), 'error');
openProviderConfigDialog(provider);
return;
}
// 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 overrideModel = _builtinOverrides[id] && _builtinOverrides[id].model;
const envModel = provider && provider.env_model;
const restoredModel = overrideModel || envModel || (provider && provider.default_model) || null;
const defaultModel = restoredModel;
// Guard: a model must be available
if (!defaultModel) {
showToast(I18n.t('config.modelRequired') || 'Model is required', 'error');
if (provider) openProviderConfigDialog(provider);
return;
}
const modelUpdate = () => defaultModel
? apiFetchVoid('/api/settings/selected_model', { method: 'PUT', body: { value: defaultModel } })
: apiFetchVoid('/api/settings/selected_model', { method: 'DELETE' });

View File

@@ -105,6 +105,11 @@
color: var(--text-secondary);
}
.provider-badge-unconfigured {
background: rgba(251, 191, 36, 0.15);
color: #b45309;
}
.provider-card-meta {
display: flex;
align-items: center;