From 88d24f60122b9319546d59faf499ee9c564bc2a4 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 18 Sep 2026 19:57:09 +0800 Subject: [PATCH] fix(accounts): normalize API key upstream endpoints --- .../internal/handler/admin/account_handler.go | 5 +- .../account_handler_available_models_test.go | 2 +- backend/internal/service/account_base_url.go | 50 +++++++++++++++++++ .../service/account_base_url_input_test.go | 48 ++++++++++++++++++ backend/internal/service/account_service.go | 2 + .../internal/service/account_test_service.go | 4 ++ backend/internal/service/admin_account.go | 9 ++++ .../components/account/CreateAccountModal.vue | 34 +++++++++---- .../__tests__/credentialsBuilder.spec.ts | 13 +++++ .../components/account/credentialsBuilder.ts | 19 +++++++ 10 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 backend/internal/service/account_base_url.go create mode 100644 backend/internal/service/account_base_url_input_test.go diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 8b041907c28f..7449d7acfcf8 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -2964,13 +2964,16 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) { response.BadRequest(c, "Invalid request: "+err.Error()) return } + // Accept the same host/path shorthand as the account form. The service + // validator also normalizes this defensively for non-HTTP callers. + req.BaseURL = service.NormalizeUpstreamBaseURLInput(req.BaseURL) modelMapping := make(map[string]any, len(req.ModelMapping)) for sourceModel, upstreamModel := range req.ModelMapping { modelMapping[sourceModel] = upstreamModel } baseURLs := make(map[string]any, len(req.APIBaseURLs)) for protocol, baseURL := range req.APIBaseURLs { - baseURLs[protocol] = baseURL + baseURLs[protocol] = service.NormalizeUpstreamBaseURLInput(baseURL) } tempAccount := &service.Account{ diff --git a/backend/internal/handler/admin/account_handler_available_models_test.go b/backend/internal/handler/admin/account_handler_available_models_test.go index 81d2564a265c..b0e222c1df80 100644 --- a/backend/internal/handler/admin/account_handler_available_models_test.go +++ b/backend/internal/handler/admin/account_handler_available_models_test.go @@ -575,7 +575,7 @@ func TestAccountHandlerSyncUpstreamModelsPreviewUsesProvidedModelMapping(t *test strings.NewReader(`{ "platform":"openai", "type":"apikey", - "base_url":"https://provider.example/v1", + "base_url":"provider.example/v1", "api_key":"key", "model_mapping":{"public-glm":"glm-5.3"} }`), diff --git a/backend/internal/service/account_base_url.go b/backend/internal/service/account_base_url.go new file mode 100644 index 000000000000..f27f799e9fa3 --- /dev/null +++ b/backend/internal/service/account_base_url.go @@ -0,0 +1,50 @@ +package service + +import ( + "net/url" + "strings" +) + +// NormalizeUpstreamBaseURLInput accepts the shorthand form commonly pasted +// into the admin account form (for example, "relay.example.com/v1") while +// leaving explicit URL schemes untouched. URL validation still happens at +// the request boundary, so this helper only supplies the conventional HTTPS +// scheme; it does not allow private hosts or bypass the configured allowlist. +func NormalizeUpstreamBaseURLInput(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + if strings.HasPrefix(trimmed, "//") { + return "https:" + trimmed + } + if parsed, err := url.Parse(trimmed); err == nil && parsed.Scheme != "" && strings.Contains(trimmed, "://") { + return trimmed + } + return "https://" + trimmed +} + +// NormalizeAccountCredentialBaseURLs normalizes the URL fields used by +// OpenAI-compatible API-key accounts. It is intentionally idempotent and +// handles both JSON-decoded map[string]any and typed map[string]string values +// used by imports/tests. +func NormalizeAccountCredentialBaseURLs(credentials map[string]any) { + if credentials == nil { + return + } + if raw, ok := credentials["base_url"].(string); ok { + credentials["base_url"] = NormalizeUpstreamBaseURLInput(raw) + } + switch values := credentials["api_base_urls"].(type) { + case map[string]any: + for key, raw := range values { + if value, ok := raw.(string); ok { + values[key] = NormalizeUpstreamBaseURLInput(value) + } + } + case map[string]string: + for key, value := range values { + values[key] = NormalizeUpstreamBaseURLInput(value) + } + } +} diff --git a/backend/internal/service/account_base_url_input_test.go b/backend/internal/service/account_base_url_input_test.go new file mode 100644 index 000000000000..66c7f46ec439 --- /dev/null +++ b/backend/internal/service/account_base_url_input_test.go @@ -0,0 +1,48 @@ +package service + +import "testing" + +func TestNormalizeUpstreamBaseURLInput(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "host shorthand", in: " relay.example.com/v1 ", want: "https://relay.example.com/v1"}, + {name: "scheme preserved", in: "https://relay.example.com/v1", want: "https://relay.example.com/v1"}, + {name: "http scheme preserved", in: "http://relay.example.com", want: "http://relay.example.com"}, + {name: "protocol relative", in: "//relay.example.com/v1", want: "https://relay.example.com/v1"}, + {name: "empty", in: " ", want: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NormalizeUpstreamBaseURLInput(tt.in); got != tt.want { + t.Fatalf("NormalizeUpstreamBaseURLInput(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestNormalizeAccountCredentialBaseURLs(t *testing.T) { + credentials := map[string]any{ + "base_url": "relay.example.com", + "api_base_urls": map[string]any{ + "chat_completions": "relay.example.com/v1", + "anthropic": "https://relay.example.com/anthropic", + }, + } + NormalizeAccountCredentialBaseURLs(credentials) + if got := credentials["base_url"]; got != "https://relay.example.com" { + t.Fatalf("base_url = %#v", got) + } + urls, ok := credentials["api_base_urls"].(map[string]any) + if !ok { + t.Fatalf("api_base_urls has unexpected type %T", credentials["api_base_urls"]) + } + if got := urls["chat_completions"]; got != "https://relay.example.com/v1" { + t.Fatalf("chat_completions = %#v", got) + } + if got := urls["anthropic"]; got != "https://relay.example.com/anthropic" { + t.Fatalf("anthropic = %#v", got) + } +} diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index d05844c151c1..9313048572ac 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -223,6 +223,7 @@ func NewAccountService(accountRepo AccountRepository, groupRepo GroupRepository) // Create 创建账号 func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) (*Account, error) { + NormalizeAccountCredentialBaseURLs(req.Credentials) // 验证分组是否存在(如果指定了分组) if len(req.GroupIDs) > 0 { if err := s.validateGroupIDsExist(ctx, req.GroupIDs); err != nil { @@ -332,6 +333,7 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount } if req.Credentials != nil { + NormalizeAccountCredentialBaseURLs(*req.Credentials) account.Credentials = SanitizeStoredCredentials(account.Platform, *req.Credentials) } diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index ed1233191dc7..a05840499701 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -265,6 +265,10 @@ func (s *AccountTestService) validateUpstreamBaseURL(raw string) (string, error) if s.cfg == nil { return "", errors.New("config is not available") } + // The admin form accepts a host/path shorthand. Normalize it before the + // shared URL validator so model previews, connection tests, and billing + // probes all interpret the same input consistently. + raw = NormalizeUpstreamBaseURLInput(raw) if !s.cfg.Security.URLAllowlist.Enabled { return urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) } diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index 913143987f4e..e77fcc2202c7 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -553,6 +553,12 @@ func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]an } func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) { + if input != nil { + // Keep the persisted credential endpoint in the same canonical form used + // by model previews and connection tests. This also makes the Cindy + // mutation decision see the same normalized identity as the create path. + NormalizeAccountCredentialBaseURLs(input.Credentials) + } if dbent.TxFromContext(ctx) == nil && isCanonicalCindyAccountInput(input.Platform, input.Type, input.Credentials) { if s.cindyAccountMutations == nil { return nil, errors.New("cindy account mutation is unavailable") @@ -565,6 +571,7 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou } func (s *adminServiceImpl) createAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) { + NormalizeAccountCredentialBaseURLs(input.Credentials) if err := ValidateOpenAIReasoningPolicyExtra(input.Extra); err != nil { return nil, err } @@ -694,6 +701,7 @@ func (s *adminServiceImpl) updateAccount(ctx context.Context, id int64, input *U if err != nil { return nil, err } + NormalizeAccountCredentialBaseURLs(input.Credentials) if err := ValidateModelContextOverrides(account, input.ModelContextOverrides); err != nil { return nil, err } @@ -765,6 +773,7 @@ func (s *adminServiceImpl) updateAccount(ctx context.Context, id int64, input *U // 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏, // 全对象 PUT 编辑时不会再带回 token,避免覆盖时清空已有凭证。 account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials) + NormalizeAccountCredentialBaseURLs(account.Credentials) // 校验并规范化请求头覆写配置(header 名小写化、格式检查) if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil { return nil, err diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 6271e14f641f..2d4a1683ae5e 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -4088,6 +4088,7 @@ import { defaultOpenCodeProtocolRules, isCNProviderPlatform, isHeaderOverrideCapable, + normalizeAccountBaseUrl, validateHeaderOverrideRows, type CnAccountMode, type CnApiProtocol, @@ -4509,6 +4510,12 @@ const syncPreviewCredentials = computed(() => { const baseUrl = isMultiProtocolPlatform.value && apiProtocol.value === 'adaptive' ? adaptiveBaseUrls.value.chat_completions.trim() || apiKeyBaseUrl.value.trim() : apiKeyBaseUrl.value.trim() + const apiBaseUrls = apiProtocol.value === 'adaptive' + ? Object.fromEntries(Object.entries(adaptiveBaseUrls.value).map(([protocol, value]) => [ + protocol, + normalizeAccountBaseUrl(value) + ])) + : undefined const modelMapping = buildModelMappingObject( modelRestrictionMode.value, allowedModels.value, @@ -4517,11 +4524,11 @@ const syncPreviewCredentials = computed(() => { return { platform: form.platform, type: form.type, - base_url: baseUrl || undefined, + base_url: baseUrl ? normalizeAccountBaseUrl(baseUrl) : undefined, ...(isCNPlatform.value ? { account_mode: accountMode.value, api_protocol: apiProtocol.value, - api_base_urls: apiProtocol.value === 'adaptive' ? { ...adaptiveBaseUrls.value } : undefined + api_base_urls: apiBaseUrls } : {}), api_key: apiKeyValue.value, ...(modelMapping ? { model_mapping: modelMapping } : {}) @@ -5226,12 +5233,17 @@ const { platform: form.platform, type: form.type, base_url: form.platform === 'antigravity' && antigravityAccountType.value === 'upstream' - ? upstreamBaseUrl.value.trim() - : form.type === 'apikey' ? apiKeyBaseUrl.value.trim() : undefined, + ? normalizeAccountBaseUrl(upstreamBaseUrl.value) + : form.type === 'apikey' ? normalizeAccountBaseUrl(apiKeyBaseUrl.value) : undefined, ...(isCNPlatform.value ? { account_mode: accountMode.value, api_protocol: apiProtocol.value, - api_base_urls: apiProtocol.value === 'adaptive' ? { ...adaptiveBaseUrls.value } : undefined + api_base_urls: apiProtocol.value === 'adaptive' + ? Object.fromEntries(Object.entries(adaptiveBaseUrls.value).map(([protocol, value]) => [ + protocol, + normalizeAccountBaseUrl(value) + ])) + : undefined } : {}), model_mapping: mappings ?? {}, model_ids: [...new Set([ @@ -6013,7 +6025,7 @@ const handleSubmit = async () => { // Build upstream credentials (and optional model restriction) const credentials: Record = { - base_url: upstreamBaseUrl.value.trim(), + base_url: normalizeAccountBaseUrl(upstreamBaseUrl.value), api_key: upstreamApiKey.value.trim() } @@ -6089,7 +6101,7 @@ const handleSubmit = async () => { // Build credentials with optional model mapping const credentials: Record = { - base_url: apiKeyBaseUrl.value.trim() || defaultBaseUrl, + base_url: normalizeAccountBaseUrl(apiKeyBaseUrl.value.trim() || defaultBaseUrl), api_key: apiKeyValue.value.trim() } if (form.platform === 'gemini') { @@ -6109,7 +6121,9 @@ const handleSubmit = async () => { ) const protocolBaseUrls: Record = {} for (const item of cnAdaptiveProtocolOptions.value) { - protocolBaseUrls[item.value] = (adaptiveBaseUrls.value[item.value] || defaults[item.value]).trim() + protocolBaseUrls[item.value] = normalizeAccountBaseUrl( + (adaptiveBaseUrls.value[item.value] || defaults[item.value]).trim() + ) } credentials.api_base_urls = protocolBaseUrls credentials.base_url = protocolBaseUrls.chat_completions @@ -6118,7 +6132,7 @@ const handleSubmit = async () => { apiKeyBaseUrl.value.trim() || defaultCNBaseUrl(form.platform, currentOpenCodeOrCNMode(), apiProtocol.value) ).trim() if (apiProtocol.value !== 'adaptive' && resolvedCNBase) { - credentials.base_url = resolvedCNBase + credentials.base_url = normalizeAccountBaseUrl(resolvedCNBase) } // 智谱团队版 Coding Plan:组织/项目 ID 写入凭据(非空才写) if (form.platform === 'zhipu' && accountMode.value === 'coding') { @@ -6291,7 +6305,7 @@ const createAccountAndFinish = async ( } if (platform === 'grok') { if (!credentials.base_url) { - credentials.base_url = apiKeyBaseUrl.value.trim() || 'https://api.x.ai/v1' + credentials.base_url = normalizeAccountBaseUrl(apiKeyBaseUrl.value.trim() || 'https://api.x.ai/v1') } const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value) if (modelMapping) { diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts index f0c5b6616565..753008a43bd0 100644 --- a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts +++ b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts @@ -16,6 +16,7 @@ import { defaultCNBaseUrl, defaultOpenCodeProtocolRules, isCustomGrokBaseUrl, + normalizeAccountBaseUrl, resolveOpenCodeAccountMode, isHeaderOverrideCapable, GROK_BASE_URL_PRESETS, @@ -28,6 +29,18 @@ import { validateHeaderOverrideRows } from '../credentialsBuilder' +describe('normalizeAccountBaseUrl', () => { + it('adds https to a host/path pasted without a scheme', () => { + expect(normalizeAccountBaseUrl(' api2.aigcbest.top/v1 ')).toBe('https://api2.aigcbest.top/v1') + }) + + it('preserves explicit schemes and empty input', () => { + expect(normalizeAccountBaseUrl('https://relay.example/v1')).toBe('https://relay.example/v1') + expect(normalizeAccountBaseUrl('http://relay.example')).toBe('http://relay.example') + expect(normalizeAccountBaseUrl('')).toBe('') + }) +}) + describe('applyInterceptWarmup', () => { it('create + enabled=true: should set intercept_warmup_requests to true', () => { const creds: Record = { access_token: 'tok' } diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts index 09fc1896c9b6..f7acb0e216ee 100644 --- a/frontend/src/components/account/credentialsBuilder.ts +++ b/frontend/src/components/account/credentialsBuilder.ts @@ -1,5 +1,24 @@ import { openAIPlanTypeLabel } from '@/utils/planType' +/** + * Normalize the endpoint text accepted by the account form. Users commonly + * paste a host/path without a scheme; treating that as HTTPS keeps the model + * preview, account creation, and later forwarding paths on the same URL. + * Explicit schemes remain unchanged and are still validated by the backend. + */ +export function normalizeAccountBaseUrl(value: string): string { + const trimmed = value.trim() + if (!trimmed) return '' + if (trimmed.startsWith('//')) return `https:${trimmed}` + try { + const parsed = new URL(trimmed) + if (parsed.protocol && trimmed.includes('://')) return trimmed + } catch { + // The backend will provide the authoritative validation error. + } + return `https://${trimmed}` +} + export function applyInterceptWarmup( credentials: Record, enabled: boolean,