fix(llm): let custom OpenAI-compatible providers opt out of API Key requirement (#89)

This commit is contained in:
matevip 2026-05-09 14:26:35 +08:00
parent 88fb28a22a
commit d2886bdaa3
7 changed files with 32 additions and 3 deletions

View File

@ -12,5 +12,6 @@ public class CreateCustomProviderRequest {
private String apiKeyPrefix;
private String protocol;
private String chatModel;
private Boolean requireApiKey;
private List<ModelInfoDTO> models;
}

View File

@ -11,6 +11,7 @@ public class ProviderConfigRequest {
private String protocol;
private String chatModel;
private Map<String, Object> generateKwargs;
private Boolean requireApiKey;
/**
* RFC-009 P3.5: provider's position in the multi-model failover chain.
* {@code 0} = excluded; positive ints define ascending try-order. When

View File

@ -129,6 +129,9 @@ public class ModelProviderService {
provider.setBaseUrl(request.getBaseUrl());
provider.setChatModel(ModelProtocol.resolveChatModel(request.getProtocol(), request.getChatModel()));
provider.setGenerateKwargs(writeJson(request.getGenerateKwargs()));
if (request.getRequireApiKey() != null) {
provider.setRequireApiKey(request.getRequireApiKey());
}
// RFC-009 P3.5: only update fallback priority when the caller explicitly
// sends a value. null leaves it untouched (existing chain unchanged).
if (request.getFallbackPriority() != null) {
@ -169,7 +172,7 @@ public class ModelProviderService {
provider.setSupportModelDiscovery(false);
provider.setSupportConnectionCheck(false);
provider.setFreezeUrl(false);
provider.setRequireApiKey(true);
provider.setRequireApiKey(request.getRequireApiKey() == null || Boolean.TRUE.equals(request.getRequireApiKey()));
modelProviderMapper.insert(provider);
if (request.getModels() != null) {

View File

@ -608,6 +608,7 @@ export default {
protocolGemini: 'Gemini Native',
protocolDashScope: 'DashScope Native',
advancedHint: 'Use this for generation options such as temperature, max_tokens, and top_p.',
requireApiKeyHint: 'Turn this off for internal or local OpenAI-compatible services that do not require auth. Connection tests will omit the Authorization header.',
fallbackPriorityHint: 'Pool try-order (lower wins): 0 = excluded, 1 = first in line, 2 = second, and so on. Providers sharing the same value are tried in alphabetical order of their ID.',
fallbackBadge: 'Preferred #{priority}',
fallbackBadgeTitle: 'Position in the available pool — lower numbers are tried first',
@ -683,6 +684,7 @@ export default {
providerName: 'Provider Name',
defaultBaseUrl: 'Default Base URL',
apiKeyPrefix: 'API Key Prefix',
requireApiKey: 'Requires API Key',
protocol: 'Protocol',
generateKwargs: 'Generate Kwargs (JSON)',
fallbackPriority: 'Pool Try-Order',

View File

@ -494,6 +494,7 @@ export default {
protocolGemini: 'Gemini 原生',
protocolDashScope: 'DashScope 原生',
advancedHint: '用于补充 temperature、max_tokens、top_p 等生成参数。',
requireApiKeyHint: '公司内部或本地 OpenAI 兼容服务如果不需要鉴权,可以关闭此项;测试连接时将不会发送 Authorization 头。',
fallbackPriorityHint: '池内尝试顺序数字越小越先0 = 不参与1 = 第一顺位2 = 第二顺位,依此类推。多个提供商共用同一数字时按 ID 字典序。',
fallbackBadge: '偏好 #{priority}',
fallbackBadgeTitle: '可用池内的尝试顺序,数字越小越先尝试',
@ -569,6 +570,7 @@ export default {
providerName: '提供商名称',
defaultBaseUrl: '默认 Base URL',
apiKeyPrefix: 'API Key 前缀',
requireApiKey: '需要 API Key',
protocol: '协议',
generateKwargs: 'Generate Kwargs (JSON)',
fallbackPriority: '池内尝试顺序',

View File

@ -39,6 +39,7 @@ export function useProviderForm(deps: ListDeps) {
apiKeyPrefix: 'sk-',
protocol: 'openai-compatible',
chatModel: 'OpenAIChatModel',
requireApiKey: true,
generateKwargsText: '{}',
enableSearch: false,
searchStrategy: '',
@ -110,6 +111,7 @@ export function useProviderForm(deps: ListDeps) {
apiKeyPrefix: 'sk-',
protocol: 'openai-compatible',
chatModel: 'OpenAIChatModel',
requireApiKey: true,
generateKwargsText: '{}',
enableSearch: false,
searchStrategy: '',
@ -134,6 +136,7 @@ export function useProviderForm(deps: ListDeps) {
apiKeyPrefix: provider.apiKeyPrefix || 'sk-',
protocol,
chatModel: provider.chatModel || 'OpenAIChatModel',
requireApiKey: protocol === 'openai-compatible' ? provider.requireApiKey !== false : true,
generateKwargsText: JSON.stringify(kwargs, null, 2),
enableSearch: searchDefault,
searchStrategy: (kwargs.searchStrategy as string) || '',
@ -175,12 +178,16 @@ export function useProviderForm(deps: ListDeps) {
}
// RFC-009 P3.5: clamp to non-negative, coerce string input back to integer.
const fallbackPriority = Math.max(0, Math.floor(Number(providerForm.fallbackPriority) || 0))
const requireApiKey = providerForm.protocol === 'openai-compatible'
? providerForm.requireApiKey
: true
if (editingProvider.value) {
await modelApi.updateProviderConfig(editingProvider.value.id, {
apiKey: providerForm.apiKey,
baseUrl: providerForm.baseUrl,
protocol: providerForm.protocol,
chatModel: protocolToChatModel(providerForm.protocol),
requireApiKey,
generateKwargs: kwargs,
fallbackPriority,
})
@ -192,6 +199,7 @@ export function useProviderForm(deps: ListDeps) {
apiKeyPrefix: providerForm.apiKeyPrefix,
protocol: providerForm.protocol,
chatModel: protocolToChatModel(providerForm.protocol),
requireApiKey,
models: [],
})
if (providerForm.apiKey || providerForm.generateKwargsText || fallbackPriority > 0) {
@ -200,6 +208,7 @@ export function useProviderForm(deps: ListDeps) {
baseUrl: providerForm.baseUrl,
protocol: providerForm.protocol,
chatModel: protocolToChatModel(providerForm.protocol),
requireApiKey,
generateKwargs: kwargs,
fallbackPriority,
})

View File

@ -29,6 +29,16 @@
/>
<div class="field-hint">{{ baseUrlHint }}</div>
</div>
<div v-if="editingProvider?.authType !== 'oauth' && form.protocol === 'openai-compatible'" class="form-group">
<div class="search-toggle-row">
<label class="form-label" style="margin-bottom: 0">{{ t('settings.model.fields.requireApiKey') }}</label>
<label class="toggle-switch">
<input type="checkbox" v-model="form.requireApiKey" />
<span class="toggle-slider"></span>
</label>
</div>
<div class="field-hint">{{ t('settings.model.requireApiKeyHint') }}</div>
</div>
<!-- OAuth 登录区域auth_type === 'oauth' 时显示 -->
<div v-if="editingProvider?.authType === 'oauth'" class="form-group full-width oauth-group">
<label class="form-label">{{ t('settings.model.oauthTitle') }}</label>
@ -65,7 +75,7 @@
</div>
</div>
<!-- API Key 输入区域 OAuth 时显示 -->
<div v-else class="form-group">
<div v-else-if="form.protocol !== 'openai-compatible' || form.requireApiKey" class="form-group">
<label class="form-label">{{ t('settings.model.apiKey') }}</label>
<input
v-model="form.apiKey"
@ -76,7 +86,7 @@
/>
<div class="field-hint">{{ t('settings.model.leaveBlankKeep') }}</div>
</div>
<div class="form-group">
<div v-if="editingProvider?.authType !== 'oauth' && (form.protocol !== 'openai-compatible' || form.requireApiKey)" class="form-group">
<label class="form-label">{{ t('settings.model.fields.apiKeyPrefix') }}</label>
<input v-model="form.apiKeyPrefix" class="form-input" />
</div>
@ -177,6 +187,7 @@ defineProps<{
apiKeyPrefix: string
protocol: string
chatModel: string
requireApiKey: boolean
generateKwargsText: string
enableSearch: boolean
searchStrategy: string