Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/internal/handler/admin/account_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
}`),
Expand Down
50 changes: 50 additions & 0 deletions backend/internal/service/account_base_url.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
48 changes: 48 additions & 0 deletions backend/internal/service/account_base_url_input_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions backend/internal/service/account_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

Expand Down
4 changes: 4 additions & 0 deletions backend/internal/service/account_test_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
9 changes: 9 additions & 0 deletions backend/internal/service/admin_account.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
34 changes: 24 additions & 10 deletions frontend/src/components/account/CreateAccountModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4088,6 +4088,7 @@ import {
defaultOpenCodeProtocolRules,
isCNProviderPlatform,
isHeaderOverrideCapable,
normalizeAccountBaseUrl,
validateHeaderOverrideRows,
type CnAccountMode,
type CnApiProtocol,
Expand Down Expand Up @@ -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,
Expand All @@ -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 } : {})
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -6013,7 +6025,7 @@ const handleSubmit = async () => {

// Build upstream credentials (and optional model restriction)
const credentials: Record<string, unknown> = {
base_url: upstreamBaseUrl.value.trim(),
base_url: normalizeAccountBaseUrl(upstreamBaseUrl.value),
api_key: upstreamApiKey.value.trim()
}

Expand Down Expand Up @@ -6089,7 +6101,7 @@ const handleSubmit = async () => {

// Build credentials with optional model mapping
const credentials: Record<string, unknown> = {
base_url: apiKeyBaseUrl.value.trim() || defaultBaseUrl,
base_url: normalizeAccountBaseUrl(apiKeyBaseUrl.value.trim() || defaultBaseUrl),
api_key: apiKeyValue.value.trim()
}
if (form.platform === 'gemini') {
Expand All @@ -6109,7 +6121,9 @@ const handleSubmit = async () => {
)
const protocolBaseUrls: Record<string, string> = {}
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
Expand All @@ -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') {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
defaultCNBaseUrl,
defaultOpenCodeProtocolRules,
isCustomGrokBaseUrl,
normalizeAccountBaseUrl,
resolveOpenCodeAccountMode,
isHeaderOverrideCapable,
GROK_BASE_URL_PRESETS,
Expand All @@ -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<string, unknown> = { access_token: 'tok' }
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/components/account/credentialsBuilder.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
enabled: boolean,
Expand Down
Loading