From 32840db19f88560d0bf20093e55778e790febbbe Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Tue, 24 Feb 2026 19:08:00 +0800 Subject: [PATCH] feat(ai-config): auto-resolve request URL from base URL --- package-lock.json | 4 +- .../implementations/analyze_image_tool.rs | 34 ++------ .../core/src/infrastructure/ai/client.rs | 12 +-- src/crates/core/src/service/config/types.rs | 7 ++ src/crates/core/src/util/types/config.rs | 10 +++ .../config/components/AIModelConfig.scss | 29 +++++++ .../config/components/AIModelConfig.tsx | 77 +++++++++++++++++-- .../config/services/modelConfigs.ts | 20 ++--- .../src/infrastructure/config/types/index.ts | 4 +- .../src/locales/en-US/settings/ai-model.json | 2 + .../src/locales/zh-CN/settings/ai-model.json | 2 + 11 files changed, 146 insertions(+), 55 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9fc0b55c..0879afde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "BitFun", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "BitFun", - "version": "0.1.0", + "version": "0.1.1", "hasInstallScript": true, "dependencies": { "@codemirror/autocomplete": "^6.18.7", diff --git a/src/crates/core/src/agentic/tools/implementations/analyze_image_tool.rs b/src/crates/core/src/agentic/tools/implementations/analyze_image_tool.rs index 25d4c9c8..4e4475fe 100644 --- a/src/crates/core/src/agentic/tools/implementations/analyze_image_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/analyze_image_tool.rs @@ -638,35 +638,13 @@ Important Notes: &vision_model.provider, )?; - let custom_request_body = vision_model - .custom_request_body - .clone() - .map(|body| { - serde_json::from_str(&body).map_err(|e| { - BitFunError::parse(format!( - "Failed to parse custom request body for model {}: {}", - vision_model.name, e - )) - }) - }) - .transpose()?; - // Vision models cannot set max_tokens (e.g., glm-4v doesn't support this parameter) - let model_config = ModelConfig { - name: vision_model.name.clone(), - model: vision_model.model_name.clone(), - api_key: vision_model.api_key.clone(), - base_url: vision_model.base_url.clone(), - format: vision_model.provider.clone(), - context_window: vision_model.context_window.unwrap_or(128000), - max_tokens: None, - enable_thinking_process: false, - support_preserved_thinking: false, - custom_headers: vision_model.custom_headers.clone(), - custom_headers_mode: vision_model.custom_headers_mode.clone(), - skip_ssl_verify: vision_model.skip_ssl_verify, - custom_request_body, - }; + // and should never use the thinking process. + let mut model_config = ModelConfig::try_from(vision_model.clone()) + .map_err(|e| BitFunError::parse(format!("Config conversion failed for vision model {}: {}", vision_model.name, e)))?; + model_config.max_tokens = None; + model_config.enable_thinking_process = false; + model_config.support_preserved_thinking = false; let ai_client = Arc::new(AIClient::new(model_config)); diff --git a/src/crates/core/src/infrastructure/ai/client.rs b/src/crates/core/src/infrastructure/ai/client.rs index 173a62a7..ff168a5e 100644 --- a/src/crates/core/src/infrastructure/ai/client.rs +++ b/src/crates/core/src/infrastructure/ai/client.rs @@ -449,10 +449,10 @@ impl AIClient { extra_body: Option, max_tries: usize, ) -> Result { - let url = self.config.base_url.clone(); + let url = self.config.request_url.clone(); debug!( - "OpenAI config: model={}, base_url={}, max_tries={}", - self.config.model, self.config.base_url, max_tries + "OpenAI config: model={}, request_url={}, max_tries={}", + self.config.model, self.config.request_url, max_tries ); // Use OpenAI message converter @@ -582,10 +582,10 @@ impl AIClient { extra_body: Option, max_tries: usize, ) -> Result { - let url = self.config.base_url.clone(); + let url = self.config.request_url.clone(); debug!( - "Anthropic config: model={}, base_url={}, max_tries={}", - self.config.model, self.config.base_url, max_tries + "Anthropic config: model={}, request_url={}, max_tries={}", + self.config.model, self.config.request_url, max_tries ); // Use Anthropic message converter diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index 3ac3555a..f77178e8 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -697,6 +697,12 @@ pub struct AIModelConfig { pub provider: String, pub model_name: String, pub base_url: String, + + /// Computed actual request URL (auto-derived from base_url + provider format). + /// Stored by the frontend when config is saved; falls back to base_url if absent. + #[serde(default)] + pub request_url: Option, + pub api_key: String, /// Context window size (total token limit for input + output). pub context_window: Option, @@ -1108,6 +1114,7 @@ impl Default for AIModelConfig { provider: String::new(), model_name: String::new(), base_url: String::new(), + request_url: None, api_key: String::new(), context_window: None, max_tokens: None, diff --git a/src/crates/core/src/util/types/config.rs b/src/crates/core/src/util/types/config.rs index 8039a864..76e2b394 100644 --- a/src/crates/core/src/util/types/config.rs +++ b/src/crates/core/src/util/types/config.rs @@ -7,6 +7,9 @@ use serde::{Deserialize, Serialize}; pub struct AIConfig { pub name: String, pub base_url: String, + /// Actual request URL + /// Falls back to base_url when absent + pub request_url: String, pub api_key: String, pub model: String, pub format: String, @@ -38,9 +41,16 @@ impl TryFrom for AIConfig { None }; + // Use stored request_url if present, otherwise fall back to base_url (legacy configs) + let request_url = other + .request_url + .filter(|u| !u.is_empty()) + .unwrap_or_else(|| other.base_url.clone()); + Ok(AIConfig { name: other.name.clone(), base_url: other.base_url.clone(), + request_url, api_key: other.api_key.clone(), model: other.model_name.clone(), format: other.provider.clone(), diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.scss b/src/web-ui/src/infrastructure/config/components/AIModelConfig.scss index 5b00c8cb..a109173c 100644 --- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.scss @@ -692,6 +692,35 @@ } } + &__resolved-url { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 6px; + + .resolved-url__label { + font-size: $font-size-xs; + color: var(--color-text-secondary); + } + + .resolved-url__value { + display: block; + font-size: $font-size-xs; + font-family: $font-family-mono; + color: var(--color-text-primary); + background: var(--element-bg-subtle, rgba(255, 255, 255, 0.05)); + border: 1px solid var(--border-base, rgba(255, 255, 255, 0.1)); + border-radius: var(--size-radius-sm, 4px); + padding: 3px 8px; + word-break: break-all; + } + + .resolved-url__hint { + font-size: 11px; + color: var(--color-text-muted); + } + } + &__form-actions { display: flex; gap: $size-gap-3; diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx index ab8d0e3b..2ef49a12 100644 --- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx @@ -1,5 +1,3 @@ - - import React, { useState, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Plus, Edit2, Trash2, Wifi, Loader, Search, ChevronDown, ChevronUp, AlertTriangle, X, Settings, ArrowLeft, ExternalLink } from 'lucide-react'; @@ -21,6 +19,28 @@ import './AIModelConfig.scss'; const log = createLogger('AIModelConfig'); +/** + * Compute the actual request URL from a base URL and provider format. + * Rules: + * - Ends with '#' → strip '#', use as-is (force override) + * - openai → append '/chat/completions' unless already present + * - anthropic → append '/v1/messages' unless already present + * - other → use base_url as-is + */ +function resolveRequestUrl(baseUrl: string, provider: string): string { + const trimmed = baseUrl.trim().replace(/\/+$/, ''); + if (trimmed.endsWith('#')) { + return trimmed.slice(0, -1).replace(/\/+$/, ''); + } + if (provider === 'openai') { + return trimmed.endsWith('chat/completions') ? trimmed : `${trimmed}/chat/completions`; + } + if (provider === 'anthropic') { + return trimmed.endsWith('v1/messages') ? trimmed : `${trimmed}/v1/messages`; + } + return trimmed; +} + const AIModelConfig: React.FC = () => { const { t } = useTranslation('settings/ai-model'); const { t: tDefault } = useTranslation('settings/default-model'); @@ -166,6 +186,7 @@ const AIModelConfig: React.FC = () => { setEditingConfig({ name: defaultModel ? `${providerName} - ${defaultModel}` : '', base_url: template.baseUrl, + request_url: resolveRequestUrl(template.baseUrl, template.format), api_key: '', model_name: defaultModel, provider: template.format, @@ -187,7 +208,8 @@ const AIModelConfig: React.FC = () => { setSelectedProviderId(null); setEditingConfig({ name: '', - base_url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', + base_url: 'https://open.bigmodel.cn/api/paas/v4', + request_url: resolveRequestUrl('https://open.bigmodel.cn/api/paas/v4', 'openai'), api_key: '', model_name: '', provider: 'openai', @@ -239,6 +261,7 @@ const AIModelConfig: React.FC = () => { id: editingConfig.id || `model_${Date.now()}`, name: editingConfig.name, base_url: editingConfig.base_url, + request_url: editingConfig.request_url || resolveRequestUrl(editingConfig.base_url, editingConfig.provider || 'openai'), api_key: editingConfig.api_key || '', model_name: editingConfig.model_name || 'search-api', provider: editingConfig.provider || 'openai', @@ -638,10 +661,12 @@ const AIModelConfig: React.FC = () => { value={editingConfig.base_url || currentTemplate.baseUrl} onChange={(value) => { const selectedOption = currentTemplate.baseUrlOptions!.find(opt => opt.url === value); + const newProvider = selectedOption?.format || editingConfig.provider || 'openai'; setEditingConfig(prev => ({ ...prev, base_url: value as string, - provider: selectedOption?.format || prev?.provider + request_url: resolveRequestUrl(value as string, newProvider), + provider: newProvider })); }} placeholder={t('form.baseUrl')} @@ -655,11 +680,26 @@ const AIModelConfig: React.FC = () => { setEditingConfig(prev => ({ ...prev, base_url: e.target.value }))} + onChange={(e) => setEditingConfig(prev => ({ + ...prev, + base_url: e.target.value, + request_url: resolveRequestUrl(e.target.value, prev?.provider || 'openai') + }))} onFocus={(e) => e.target.select()} placeholder={currentTemplate.baseUrl} /> )} + {editingConfig.base_url && ( +
+ {t('form.resolvedUrlLabel')} + + {resolveRequestUrl(editingConfig.base_url, editingConfig.provider || 'openai')} + + {!(currentTemplate.baseUrlOptions && currentTemplate.baseUrlOptions.length > 0) && ( + {t('form.forceUrlHint')} + )} +
+ )} @@ -667,7 +707,11 @@ const AIModelConfig: React.FC = () => { setEditingConfig(prev => ({ ...prev, base_url: e.target.value }))} + onChange={(e) => setEditingConfig(prev => ({ + ...prev, + base_url: e.target.value, + request_url: resolveRequestUrl(e.target.value, prev?.provider || 'openai') + }))} onFocus={(e) => e.target.select()} placeholder={ editingConfig.category === 'search_enhanced' @@ -818,6 +870,15 @@ const AIModelConfig: React.FC = () => { {t('form.searchApiHint')} )} + {editingConfig.base_url && ( +
+ {t('form.resolvedUrlLabel')} + + {resolveRequestUrl(editingConfig.base_url, editingConfig.provider || 'openai')} + + {t('form.forceUrlHint')} +
+ )} diff --git a/src/web-ui/src/infrastructure/config/services/modelConfigs.ts b/src/web-ui/src/infrastructure/config/services/modelConfigs.ts index d4b4f5b6..93ffbbc5 100644 --- a/src/web-ui/src/infrastructure/config/services/modelConfigs.ts +++ b/src/web-ui/src/infrastructure/config/services/modelConfigs.ts @@ -12,7 +12,7 @@ export const PROVIDER_TEMPLATES: Record = { anthropic: { id: 'anthropic', name: t('settings/ai-model:providers.anthropic.name'), - baseUrl: 'https://api.anthropic.com/v1/messages', + baseUrl: 'https://api.anthropic.com', format: 'anthropic', models: ['claude-opus-4-6', 'claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101', 'claude-haiku-4-5-20251001'], requiresApiKey: true, @@ -23,7 +23,7 @@ export const PROVIDER_TEMPLATES: Record = { minimax: { id: 'minimax', name: t('settings/ai-model:providers.minimax.name'), - baseUrl: 'https://api.minimaxi.com/anthropic/v1/messages', + baseUrl: 'https://api.minimaxi.com/anthropic', format: 'anthropic', models: ['MiniMax-M2.1', 'MiniMax-M2.1-lightning', 'MiniMax-M2'], requiresApiKey: true, @@ -34,7 +34,7 @@ export const PROVIDER_TEMPLATES: Record = { moonshot: { id: 'moonshot', name: t('settings/ai-model:providers.moonshot.name'), - baseUrl: 'https://api.moonshot.cn/v1/chat/completions', + baseUrl: 'https://api.moonshot.cn/v1', format: 'openai', models: ['kimi-k2.5', 'kimi-k2', 'kimi-k2-thinking'], requiresApiKey: true, @@ -45,7 +45,7 @@ export const PROVIDER_TEMPLATES: Record = { deepseek: { id: 'deepseek', name: t('settings/ai-model:providers.deepseek.name'), - baseUrl: 'https://api.deepseek.com/chat/completions', + baseUrl: 'https://api.deepseek.com', format: 'openai', models: ['deepseek-chat', 'deepseek-reasoner'], requiresApiKey: true, @@ -56,23 +56,23 @@ export const PROVIDER_TEMPLATES: Record = { zhipu: { id: 'zhipu', name: t('settings/ai-model:providers.zhipu.name'), - baseUrl: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', format: 'openai', models: ['glm-4.7', 'glm-4.7-flash', 'glm-4.6'], requiresApiKey: true, description: t('settings/ai-model:providers.zhipu.description'), helpUrl: 'https://open.bigmodel.cn/usercenter/apikeys', baseUrlOptions: [ - { url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', format: 'openai', note: 'default' }, - { url: 'https://open.bigmodel.cn/api/anthropic/v1/messages', format: 'anthropic', note: 'anthropic' }, - { url: 'https://open.bigmodel.cn/api/coding/paas/v4/chat/completions', format: 'openai', note: 'codingPlan' }, + { url: 'https://open.bigmodel.cn/api/paas/v4', format: 'openai', note: 'default' }, + { url: 'https://open.bigmodel.cn/api/anthropic', format: 'anthropic', note: 'anthropic' }, + { url: 'https://open.bigmodel.cn/api/coding/paas', format: 'openai', note: 'codingPlan' }, ] }, qwen: { id: 'qwen', name: t('settings/ai-model:providers.qwen.name'), - baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', format: 'openai', models: ['qwen3-max', 'qwen3-coder-plus', 'qwen3-coder-flash'], requiresApiKey: true, @@ -83,7 +83,7 @@ export const PROVIDER_TEMPLATES: Record = { volcengine: { id: 'volcengine', name: t('settings/ai-model:providers.volcengine.name'), - baseUrl: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions', + baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', format: 'openai', models: ['doubao-seed-1-8-251228', 'glm-4-7-251222', 'doubao-seed-code-preview-251028'], requiresApiKey: true, diff --git a/src/web-ui/src/infrastructure/config/types/index.ts b/src/web-ui/src/infrastructure/config/types/index.ts index 56ff8d2c..4040b7fe 100644 --- a/src/web-ui/src/infrastructure/config/types/index.ts +++ b/src/web-ui/src/infrastructure/config/types/index.ts @@ -120,7 +120,9 @@ export interface AIModelConfig { name: string; provider: string; api_key?: string; - base_url: string; + base_url: string; + /** Computed actual request URL, derived from base_url + provider format. Stored on save. */ + request_url?: string; model_name: string; description?: string; context_window?: number; diff --git a/src/web-ui/src/locales/en-US/settings/ai-model.json b/src/web-ui/src/locales/en-US/settings/ai-model.json index 228b8c40..ed3fae38 100644 --- a/src/web-ui/src/locales/en-US/settings/ai-model.json +++ b/src/web-ui/src/locales/en-US/settings/ai-model.json @@ -95,6 +95,8 @@ "modelName": "Model Name", "modelNameHint": "GLM-ASR uses glm-asr, other speech models use their corresponding model names", "baseUrl": "API URL", + "resolvedUrlLabel": "Request URL: ", + "forceUrlHint": "Append # to force use the exact URL you entered", "searchApiHint": "Zhipu AI search API URL, or other compatible search API URL", "apiKey": "API Key", "apiKeyPlaceholder": "Enter your API Key", diff --git a/src/web-ui/src/locales/zh-CN/settings/ai-model.json b/src/web-ui/src/locales/zh-CN/settings/ai-model.json index 7b2119e8..bb7d8660 100644 --- a/src/web-ui/src/locales/zh-CN/settings/ai-model.json +++ b/src/web-ui/src/locales/zh-CN/settings/ai-model.json @@ -95,6 +95,8 @@ "modelName": "模型名称", "modelNameHint": "智谱 GLM-ASR 使用 glm-asr,其他语音模型填写对应的模型名称", "baseUrl": "API地址", + "resolvedUrlLabel": "实际请求地址:", + "forceUrlHint": "以 # 结尾可强制使用输入地址", "searchApiHint": "智谱AI搜索API地址,或其他兼容的搜索API地址", "apiKey": "API密钥", "apiKeyPlaceholder": "输入您的 API Key",