diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 1d4f9d0d00..1e70bf2c44 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -625,7 +625,7 @@ func lockAndMergeAccountProbeExtra(ctx context.Context, client *dbent.Client, ac delete(extra, key) } probeExplicitlyDisabled := false - probeAccount := account.Platform == service.PlatformOpenAI && account.Type == service.AccountTypeAPIKey + probeAccount := service.IsUpstreamBillingProbeIdentity(account.Platform, account.Type) if probeAccount && explicitProbeEnabled != nil { extra[service.UpstreamBillingProbeEnabledExtraKey] = *explicitProbeEnabled probeExplicitlyDisabled = !*explicitProbeEnabled @@ -709,7 +709,7 @@ func (r *accountRepository) UpdateCredentials(ctx context.Context, id int64, cre credentials = $1::jsonb, extra = CASE -- 凭证整体未变化 ⇒ Ollama 组身份必然未变化;顶层 DISTINCT 守卫防止 - -- 非 Ollama 账号的无变化持久化误清 openai 探测快照或重写 NULL extra。 + -- 非 Ollama 账号的无变化持久化误清探测快照或重写 NULL extra。 WHEN platform IN ('openai', 'anthropic') AND type = 'apikey' AND credentials IS DISTINCT FROM $1::jsonb @@ -720,15 +720,14 @@ func (r *accountRepository) UpdateCredentials(ctx context.Context, id int64, cre AND `+ollamaCloudBaseURLMatchesSQL("$1::jsonb ->> 'base_url'")+` ) ) - THEN (CASE - WHEN platform = 'openai' THEN COALESCE(extra, '{}'::jsonb) - 'upstream_billing_probe' - ELSE COALESCE(extra, '{}'::jsonb) - END) + THEN COALESCE(extra, '{}'::jsonb) + - 'upstream_billing_probe' - 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot' - WHEN platform = 'openai' - AND type = 'apikey' + -- 上游倍率探测已放宽到全部 API-key 平台:凭证变化即视为探测 + -- 身份变化,丢弃 stale 快照。 + WHEN type = 'apikey' AND credentials IS DISTINCT FROM $1::jsonb THEN COALESCE(extra, '{}'::jsonb) - 'upstream_billing_probe' ELSE extra @@ -2839,8 +2838,8 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates args = append(args, pq.Array(ids)) idx++ if updates.ProbeEnabled != nil { - whereClause += " AND platform = $" + itoa(idx) + " AND type = $" + itoa(idx+1) - args = append(args, service.PlatformOpenAI, service.AccountTypeAPIKey) + whereClause += " AND type = $" + itoa(idx) + args = append(args, service.AccountTypeAPIKey) } query := "UPDATE accounts SET " + joinClauses(setClauses, ", ") + whereClause @@ -3382,7 +3381,6 @@ func (r *accountRepository) ListDueUpstreamBillingProbeAccounts(ctx context.Cont FROM accounts WHERE deleted_at IS NULL AND status = 'active' - AND platform = 'openai' AND type = 'apikey' AND extra @> '{"upstream_billing_probe_enabled": true}'::jsonb ), parsed AS MATERIALIZED ( diff --git a/backend/internal/repository/account_repo_upstream_billing_probe_due_integration_test.go b/backend/internal/repository/account_repo_upstream_billing_probe_due_integration_test.go index 104771af03..e88579c1d0 100644 --- a/backend/internal/repository/account_repo_upstream_billing_probe_due_integration_test.go +++ b/backend/internal/repository/account_repo_upstream_billing_probe_due_integration_test.go @@ -153,3 +153,54 @@ func TestListDueUpstreamBillingProbeAccountsSelectsEarliestDueAcrossIDs(t *testi } require.Equal(t, want, got) } + +// 探测资格放宽回归:任何 API-key 平台的启用账号都进入定时探测候选并按 +// 到期时间排序;OAuth 与未启用账号仍被排除。 +func TestListDueUpstreamBillingProbeAccountsIncludesAllAPIKeyPlatforms(t *testing.T) { + ctx := context.Background() + tx := testEntTx(t) + repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil) + now := time.Date(2026, time.July, 26, 3, 0, 0, 0, time.UTC) + _, err := tx.ExecContext(ctx, ` + UPDATE accounts + SET extra = extra - 'upstream_billing_probe_enabled' - 'upstream_billing_probe' + `) + require.NoError(t, err) + + insert := func(name, platform, accountType, nextProbeAt string) int64 { + t.Helper() + var id int64 + extra := fmt.Sprintf(`{ + "upstream_billing_probe_enabled": true, + "upstream_billing_probe": {"status": "ok", "next_probe_at": %q} + }`, nextProbeAt) + err := scanSingleRow(ctx, tx, ` + INSERT INTO accounts (name, platform, type, status, extra) + VALUES ($1, $2, $3, 'active', $4::jsonb) + RETURNING id + `, []any{name, platform, accountType, extra}, &id) + require.NoError(t, err) + return id + } + + openaiDue := insert("probe-openai-due", "openai", service.AccountTypeAPIKey, "2026-07-26T02:57:00Z") + anthropicDue := insert("probe-anthropic-due", "anthropic", service.AccountTypeAPIKey, "2026-07-26T02:58:00Z") + grokDue := insert("probe-grok-due", "grok", service.AccountTypeAPIKey, "2026-07-26T02:59:00Z") + // OAuth 账号即便误持有启用标记也不得入选。 + _ = insert("probe-grok-oauth-excluded", "grok", service.AccountTypeOAuth, "2026-07-26T02:59:00Z") + // 未启用探测的 API-key 账号不入选。 + var disabledID int64 + err = scanSingleRow(ctx, tx, ` + INSERT INTO accounts (name, platform, type, status, extra) + VALUES ('probe-grok-disabled', 'grok', $1, 'active', '{}'::jsonb) + RETURNING id + `, []any{service.AccountTypeAPIKey}, &disabledID) + require.NoError(t, err) + + accounts, err := repo.ListDueUpstreamBillingProbeAccounts(ctx, now, 20) + require.NoError(t, err) + require.Len(t, accounts, 3) + require.Equal(t, openaiDue, accounts[0].ID) + require.Equal(t, anthropicDue, accounts[1].ID) + require.Equal(t, grokDue, accounts[2].ID) +} diff --git a/backend/internal/repository/account_repo_upstream_billing_probe_due_test.go b/backend/internal/repository/account_repo_upstream_billing_probe_due_test.go index aa1fcb4303..1137250636 100644 --- a/backend/internal/repository/account_repo_upstream_billing_probe_due_test.go +++ b/backend/internal/repository/account_repo_upstream_billing_probe_due_test.go @@ -28,7 +28,8 @@ func TestAccountRepositoryListDueUpstreamBillingProbeAccountsBoundsQuery(t *test normalized := normalizeSQLWhitespace(capturedSQL) require.Contains(t, normalized, "deleted_at IS NULL") require.Contains(t, normalized, "status = 'active'") - require.Contains(t, normalized, "platform = 'openai'") + // 探测资格已放宽到全部 API-key 平台:候选 SQL 不得再按 platform 过滤。 + require.NotContains(t, normalized, "platform") require.Contains(t, normalized, "type = 'apikey'") require.Contains(t, normalized, `extra @> '{"upstream_billing_probe_enabled": true}'::jsonb`) require.Contains(t, normalized, "jsonb_path_query_first_tz") diff --git a/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go b/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go index 497059ba5a..38e786d4d0 100644 --- a/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go +++ b/backend/internal/repository/account_repo_upstream_billing_probe_update_test.go @@ -227,8 +227,8 @@ func TestBulkUpdateProbeEligibilityMismatchRollsBack(t *testing.T) { enabled := true mock.ExpectBegin() - mock.ExpectExec(`(?s)UPDATE accounts SET extra = .* WHERE id = ANY\(\$2\) AND deleted_at IS NULL AND platform = \$3 AND type = \$4`). - WithArgs(sqlmock.AnyArg(), `{27,28}`, service.PlatformOpenAI, service.AccountTypeAPIKey). + mock.ExpectExec(`(?s)UPDATE accounts SET extra = .* WHERE id = ANY\(\$2\) AND deleted_at IS NULL AND type = \$3`). + WithArgs(sqlmock.AnyArg(), `{27,28}`, service.AccountTypeAPIKey). WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectRollback() diff --git a/backend/internal/repository/proxy_repo.go b/backend/internal/repository/proxy_repo.go index 5eda079390..4869721f02 100644 --- a/backend/internal/repository/proxy_repo.go +++ b/backend/internal/repository/proxy_repo.go @@ -231,8 +231,7 @@ func invalidateProxyProbeSnapshots(ctx context.Context, exec sqlExecutor, proxyI WHERE proxy_id = $1 AND type = 'apikey' AND ( - (platform = 'openai' - AND extra ? 'upstream_billing_probe' + (extra ? 'upstream_billing_probe' AND extra -> 'upstream_billing_probe' <> 'null'::jsonb) OR (platform IN ('openai', 'anthropic') AND extra ? 'ollama_cloud_usage_snapshot' @@ -747,7 +746,7 @@ func (r *proxyRepository) sweepOneExpiredProxyOnExec(ctx context.Context, exec s rows, err = exec.QueryContext(ctx, ` UPDATE accounts SET proxy_id=NULL, proxy_fallback_origin_id=$1, extra=CASE - WHEN platform='openai' AND type='apikey' AND extra ? 'upstream_billing_probe' + WHEN type='apikey' AND extra ? 'upstream_billing_probe' THEN extra - 'upstream_billing_probe' ELSE extra END, @@ -758,7 +757,7 @@ func (r *proxyRepository) sweepOneExpiredProxyOnExec(ctx context.Context, exec s rows, err = exec.QueryContext(ctx, ` UPDATE accounts SET proxy_id=$2, proxy_fallback_origin_id=$1, extra=CASE - WHEN platform='openai' AND type='apikey' AND extra ? 'upstream_billing_probe' + WHEN type='apikey' AND extra ? 'upstream_billing_probe' THEN extra - 'upstream_billing_probe' ELSE extra END, diff --git a/backend/internal/repository/proxy_repo_upstream_billing_probe_test.go b/backend/internal/repository/proxy_repo_upstream_billing_probe_test.go index d4cd0ad049..2fa420bf30 100644 --- a/backend/internal/repository/proxy_repo_upstream_billing_probe_test.go +++ b/backend/internal/repository/proxy_repo_upstream_billing_probe_test.go @@ -33,7 +33,7 @@ func TestProxyUpdateInvalidatesBoundProbeSnapshotsAndEnqueuesOutboxAtomically(t WithArgs(int64(9)). WillReturnResult(sqlmock.NewResult(0, 0)) expectProxyUpdateReload(mock, 9, "new.example", "user", "pass") - mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*platform = 'openai'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`). + mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`). WithArgs(int64(9)). WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17)).AddRow(int64(18))) mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)")). @@ -76,7 +76,7 @@ func TestProxyUpdateRollsBackWhenProbeInvalidationOutboxFails(t *testing.T) { WithArgs(int64(9)). WillReturnResult(sqlmock.NewResult(0, 0)) expectProxyUpdateReload(mock, 9, "new.example", "", "") - mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*platform = 'openai'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`). + mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`). WithArgs(int64(9)). WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17))) mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)")). diff --git a/backend/internal/service/crs_sync_helpers_test.go b/backend/internal/service/crs_sync_helpers_test.go index bd6434c1f5..344ed19d14 100644 --- a/backend/internal/service/crs_sync_helpers_test.go +++ b/backend/internal/service/crs_sync_helpers_test.go @@ -151,16 +151,31 @@ func TestReconcileCRSUpstreamBillingProbeExtra(t *testing.T) { require.NotContains(t, extra, UpstreamBillingProbeExtraKey) }) + // API-key 平台间切换:探测资格保留(放宽后不再限 OpenAI),开关沿用本地值, + // 但平台属于探测身份,快照必须作废。 + for _, target := range []struct { + name string + platform string + }{ + {name: "anthropic api key", platform: PlatformAnthropic}, + {name: "gemini api key", platform: PlatformGemini}, + } { + t.Run(target.name+" keeps enabled and clears snapshot", func(t *testing.T) { + extra := mergeMap(existing.Extra, remote) + reconcileCRSUpstreamBillingProbeExtra(existing, target.platform, AccountTypeAPIKey, existing.Credentials, extra) + require.Equal(t, false, extra[UpstreamBillingProbeEnabledExtraKey]) + require.NotContains(t, extra, UpstreamBillingProbeExtraKey) + }) + } + for _, target := range []struct { name string platform string typeName string }{ {name: "anthropic oauth", platform: PlatformAnthropic, typeName: AccountTypeOAuth}, - {name: "anthropic api key", platform: PlatformAnthropic, typeName: AccountTypeAPIKey}, {name: "openai oauth", platform: PlatformOpenAI, typeName: AccountTypeOAuth}, {name: "gemini oauth", platform: PlatformGemini, typeName: AccountTypeOAuth}, - {name: "gemini api key", platform: PlatformGemini, typeName: AccountTypeAPIKey}, } { t.Run(target.name+" removes inapplicable state", func(t *testing.T) { extra := mergeMap(existing.Extra, remote) diff --git a/backend/internal/service/crs_sync_service.go b/backend/internal/service/crs_sync_service.go index f227ea4278..e1637accdb 100644 --- a/backend/internal/service/crs_sync_service.go +++ b/backend/internal/service/crs_sync_service.go @@ -1173,7 +1173,7 @@ func reconcileCRSUpstreamBillingProbeExtra( return } target := &Account{Platform: targetPlatform, Type: targetType, Credentials: targetCredentials} - if targetPlatform == PlatformOpenAI && targetType == AccountTypeAPIKey { + if IsUpstreamBillingProbeIdentity(targetPlatform, targetType) { if enabled, ok := existing.Extra[UpstreamBillingProbeEnabledExtraKey]; ok { extra[UpstreamBillingProbeEnabledExtraKey] = enabled } diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index d5c28e68b6..9ddb55f3ed 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -11,6 +11,7 @@ import ( "math" "math/rand/v2" "net/http" + "net/url" "sort" "strconv" "strings" @@ -51,7 +52,7 @@ var ( "UPSTREAM_BILLING_PROBE_UNAVAILABLE", "upstream billing probe is unavailable", ) ErrUpstreamBillingProbeAccountInvalid = infraerrors.BadRequest( - "UPSTREAM_BILLING_PROBE_ACCOUNT_INVALID", "account is not an OpenAI API key account", + "UPSTREAM_BILLING_PROBE_ACCOUNT_INVALID", "account is not an API key account", ) ErrUpstreamBillingProbeIdentityChanged = infraerrors.Conflict( "UPSTREAM_BILLING_PROBE_IDENTITY_CHANGED", "account identity changed during upstream billing probe; retry the probe", @@ -551,13 +552,24 @@ func (s *UpstreamBillingProbeService) probeLoadedAccount(ctx context.Context, ac if s.accountTestService == nil || s.accountTestService.httpUpstream == nil { return s.persistProbeFailure(ctx, account, intervalMinutes, now, 0, "transport_unavailable", 0) } - apiKey := account.GetOpenAIApiKey() + // 平台放宽后取数直读 credentials:所有 API-key 平台的密钥与自定义上游 + // 统一存放在 credentials.api_key / credentials.base_url。 + apiKey := account.GetCredential("api_key") if apiKey == "" { return s.persistProbeFailure(ctx, account, intervalMinutes, now, 0, "missing_api_key", 0) } - baseURL := account.GetOpenAIBaseURL() - if baseURL == "" { - baseURL = "https://api.openai.com" + baseURL := account.GetCredential("base_url") + if account.Platform == PlatformOpenAI { + if baseURL == "" { + // 保持官方语义:OpenAI 账号无自定义 base 时探官方域(404 → unsupported)。 + baseURL = "https://api.openai.com" + } + } else if upstreamBillingProbeTargetIsOfficialAPI(baseURL) { + // 其他平台 base_url 为空或指向官方 API 根域(前端创建时会把空值 + // 填成官方默认域,且提供 us-east-1.api.x.ai 等官方区域预设)⇒ + // 必无 /v1/sub2api/billing;不发请求,直接记 unsupported,避免 + // 拿账号 Key 周期性请求官方域的不存在路径。 + return s.persistProbeFailure(ctx, account, intervalMinutes, now, 0, "unsupported", 0) } normalizedBaseURL, err := s.accountTestService.validateUpstreamBaseURL(baseURL) if err != nil { @@ -580,7 +592,12 @@ func (s *UpstreamBillingProbeService) probeLoadedAccount(ctx context.Context, ac if err != nil { return s.persistProbeFailure(ctx, account, intervalMinutes, now, 0, "request_build_failed", 0) } - reqCtx := WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI) + // OpenAI 账号保持官方 openai 传输画像;其他平台探测走默认画像。 + profile := HTTPUpstreamProfileDefault + if account.Platform == PlatformOpenAI { + profile = HTTPUpstreamProfileOpenAI + } + reqCtx := WithHTTPUpstreamProfile(req.Context(), profile) req = req.WithContext(WithHTTPUpstreamRedirectsDisabled(reqCtx)) req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) @@ -840,8 +857,59 @@ func decodeUpstreamBillingProbeSnapshot(extra map[string]any) *UpstreamBillingPr return &snapshot } +// IsUpstreamBillingProbeIdentity reports whether an account identity may opt +// in to the upstream billing probe. `/v1/sub2api/billing` is a key-scoped +// sub2api convention: any API-key account whose base_url points at a +// sub2api-compatible upstream answers it regardless of the account platform, +// so eligibility is not restricted to OpenAI. +// Non-sub2api upstreams return 404 and the snapshot records "unsupported". +// OAuth/Bedrock credentials carry no static API key to present, hence the +// type restriction. +func IsUpstreamBillingProbeIdentity(platform, accountType string) bool { + return platform != "" && accountType == AccountTypeAPIKey +} + func isUpstreamBillingProbeAccount(account *Account) bool { - return account != nil && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey + return account != nil && IsUpstreamBillingProbeIdentity(account.Platform, account.Type) +} + +// upstreamBillingProbeOfficialAPIDomains lists the root domains of official +// provider APIs. The create form fills empty base_url values with official +// defaults (and offers official regional presets like us-east-1.api.x.ai), +// so probing them would send the account key to an official API path that +// cannot exist. Matching is by registrable root domain — exact host or any +// subdomain, after stripping the port and a trailing DNS dot — because no +// third-party sub2api relay can live under these domains, while custom +// relays (the only targets that can answer /v1/sub2api/billing) always do +// probe. OpenAI-platform accounts never reach this check: they keep the +// upstream-official behavior of probing api.openai.com. +var upstreamBillingProbeOfficialAPIDomains = []string{ + "anthropic.com", + "googleapis.com", + "x.ai", + "grok.com", + "openai.com", +} + +func upstreamBillingProbeTargetIsOfficialAPI(baseURL string) bool { + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + return true + } + parsed, err := url.Parse(baseURL) + if err != nil { + return false + } + host := strings.TrimSuffix(strings.ToLower(parsed.Hostname()), ".") + if host == "" { + return true + } + for _, domain := range upstreamBillingProbeOfficialAPIDomains { + if host == domain || strings.HasSuffix(host, "."+domain) { + return true + } + } + return false } func upstreamBillingProbeEnabled(account *Account) bool { diff --git a/backend/internal/service/upstream_billing_probe_multiplatform_test.go b/backend/internal/service/upstream_billing_probe_multiplatform_test.go new file mode 100644 index 0000000000..c95e159ed8 --- /dev/null +++ b/backend/internal/service/upstream_billing_probe_multiplatform_test.go @@ -0,0 +1,202 @@ +package service + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// 探测资格放宽:/v1/sub2api/billing 是 key 级端点,任何 API-key +// 平台账号都可开启探测;OAuth/Bedrock 无静态 Key 仍不合格。 +func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) { + for _, platform := range []string{ + PlatformOpenAI, PlatformGrok, PlatformAnthropic, PlatformGemini, PlatformAntigravity, + } { + require.True(t, IsUpstreamBillingProbeIdentity(platform, AccountTypeAPIKey), platform) + require.True(t, isUpstreamBillingProbeAccount(&Account{Platform: platform, Type: AccountTypeAPIKey}), platform) + } + require.False(t, IsUpstreamBillingProbeIdentity(PlatformOpenAI, AccountTypeOAuth)) + require.False(t, IsUpstreamBillingProbeIdentity(PlatformGrok, AccountTypeOAuth)) + require.False(t, IsUpstreamBillingProbeIdentity(PlatformAnthropic, AccountTypeBedrock)) + require.False(t, IsUpstreamBillingProbeIdentity("", AccountTypeAPIKey)) + require.False(t, isUpstreamBillingProbeAccount(nil)) +} + +func upstreamBillingProbeValidBody() io.ReadCloser { + return io.NopCloser(strings.NewReader(`{ + "object":"sub2api.key_billing", + "schema_version":1, + "billing_scope":"token", + "group_rate_multiplier":0.02, + "resolved_rate_multiplier":0.02, + "peak_rate_enabled":false, + "effective_rate_multiplier":0.02, + "observed_at":"2026-07-26T01:00:00Z" + }`)) +} + +func TestUpstreamBillingProbeGrokAccountPersistsSnapshot(t *testing.T) { + account := &Account{ + ID: 151, + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 2, + Credentials: map[string]any{ + "api_key": "sk-grok-relay", + "base_url": "https://relay.example/v1", + }, + } + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: upstreamBillingProbeValidBody(), + }} + svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{}) + fixedNow := time.Date(2026, time.July, 26, 2, 0, 0, 0, time.UTC) + svc.now = func() time.Time { return fixedNow } + + snapshot, err := svc.ProbeAccount(context.Background(), account.ID) + require.NoError(t, err) + require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status) + require.Equal(t, 0.02, snapshot.Data["resolved_rate_multiplier"]) + require.Equal(t, "https://relay.example/v1/sub2api/billing", upstream.lastReq.URL.String()) + require.Equal(t, "Bearer sk-grok-relay", upstream.lastReq.Header.Get("Authorization")) + // 非 OpenAI 平台探测使用默认传输画像。 + require.Equal(t, HTTPUpstreamProfileDefault, HTTPUpstreamProfileFromContext(upstream.lastReq.Context())) + + persisted := decodeUpstreamBillingProbeSnapshot(account.Extra) + require.NotNil(t, persisted) + require.Equal(t, UpstreamBillingProbeStatusOK, persisted.Status) +} + +// 非 OpenAI 平台没有自定义 base_url 时,上游是各自官方 API,必无 +// /v1/sub2api/billing:不发请求,直接落 unsupported。 +func TestUpstreamBillingProbeNonOpenAIWithoutBaseURLIsUnsupportedWithoutRequest(t *testing.T) { + account := &Account{ + ID: 152, + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Status: StatusActive, + Credentials: map[string]any{"api_key": "sk-grok-official"}, + } + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}} + upstream := &httpUpstreamRecorder{} + svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{}) + + snapshot, err := svc.ProbeAccount(context.Background(), account.ID) + require.NoError(t, err) + require.Equal(t, UpstreamBillingProbeStatusUnsupported, snapshot.Status) + require.Nil(t, upstream.lastReq) +} + +// 前端创建 API-key 账号时会把空 base_url 填成平台官方默认域;官方 API 同样 +// 必无 /v1/sub2api/billing,不发请求直接 unsupported。 +// 显式端口、DNS 尾点、官方区域子域(前端 Grok 预设含 us-east-1.api.x.ai 等) +// 与跨平台官方域都不能绕过。 +func TestUpstreamBillingProbeOfficialAPIBaseURLIsUnsupportedWithoutRequest(t *testing.T) { + cases := []struct { + platform string + baseURL string + }{ + {PlatformAnthropic, "https://api.anthropic.com"}, + {PlatformAnthropic, "https://api.anthropic.com:443"}, + {PlatformAnthropic, "https://api.anthropic.com./"}, + {PlatformAnthropic, "HTTPS://API.ANTHROPIC.COM/"}, + {PlatformGemini, "https://generativelanguage.googleapis.com"}, + {PlatformAntigravity, "https://cloudcode-pa.googleapis.com"}, + {PlatformGrok, "https://api.x.ai/v1"}, + {PlatformGrok, "https://us-east-1.api.x.ai/v1"}, + {PlatformGrok, "https://eu-west-1.api.x.ai/v1"}, + {PlatformGrok, "https://cli-chat-proxy.grok.com/v1"}, + // 跨平台官方域同样必无该端点,一并拦截。 + {PlatformAnthropic, "https://api.x.ai/v1"}, + {PlatformGrok, "https://api.openai.com"}, + } + for i, tc := range cases { + account := &Account{ + ID: int64(200 + i), + Platform: tc.platform, + Type: AccountTypeAPIKey, + Status: StatusActive, + Credentials: map[string]any{"api_key": "sk-official", "base_url": tc.baseURL}, + } + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}} + upstream := &httpUpstreamRecorder{} + svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{}) + + snapshot, err := svc.ProbeAccount(context.Background(), account.ID) + require.NoError(t, err, "%s %s", tc.platform, tc.baseURL) + require.Equal(t, UpstreamBillingProbeStatusUnsupported, snapshot.Status, "%s %s", tc.platform, tc.baseURL) + require.Nil(t, upstream.lastReq, "%s %s", tc.platform, tc.baseURL) + } +} + +// 官方域判定按规范化 host 的根域后缀匹配:端口、尾点、区域子域都拦; +// 自定义中转与相似但不同的域照常探测。 +func TestUpstreamBillingProbeOfficialAPIHostMatchingIsNormalized(t *testing.T) { + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.x.ai")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.anthropic.com:8443")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://us-west-2.api.x.ai/v1")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://generativelanguage.googleapis.com.")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://x.ai")) + // openai 官方域在全集内;openai 平台账号不经过本判定(行为级测试钉死照探)。 + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.openai.com")) + // 相似但不同的注册域不拦:中转完全可能叫 *-x.ai 之外的任何名字。 + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://relay.example/v1")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notx.ai")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://anthropic.com.evil.example")) + require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.relay-station.example")) +} + +// OpenAI 语义保持不变:无自定义 base 时仍探官方域,且沿用 openai 传输画像。 +func TestUpstreamBillingProbeOpenAIDefaultBaseURLPreserved(t *testing.T) { + account := &Account{ + ID: 17, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Credentials: map[string]any{"api_key": "sk-openai"}, + } + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}} + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: upstreamBillingProbeValidBody(), + }} + svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{}) + + _, err := svc.ProbeAccount(context.Background(), account.ID) + require.NoError(t, err) + require.Equal(t, "https://api.openai.com/v1/sub2api/billing", upstream.lastReq.URL.String()) + require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context())) +} + +func TestUpstreamBillingProbeSetAccountEnabledAcceptsGrokAPIKey(t *testing.T) { + grokAPIKey := &Account{ + ID: 151, + Platform: PlatformGrok, + Type: AccountTypeAPIKey, + Status: StatusActive, + Credentials: map[string]any{"api_key": "sk", "base_url": "https://relay.example"}, + } + grokOAuth := &Account{ID: 152, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive} + repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{ + grokAPIKey.ID: grokAPIKey, + grokOAuth.ID: grokOAuth, + }} + svc := newUpstreamBillingProbeTestService(repo, &upstreamBillingProbeHTTPStub{}, &upstreamBillingProbeSettingRepo{}) + + require.NoError(t, svc.SetAccountEnabled(context.Background(), grokAPIKey.ID, true)) + require.Equal(t, true, repo.accounts[grokAPIKey.ID].Extra[UpstreamBillingProbeEnabledExtraKey]) + + err := svc.SetAccountEnabled(context.Background(), grokOAuth.ID, true) + require.ErrorIs(t, err, ErrUpstreamBillingProbeAccountInvalid) +} diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 1c277cb552..02ddaca92e 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -905,8 +905,8 @@ - -
+ {{ t('admin.accounts.upstreamBilling.autoProbeHint') }} +
+{{ apiKeyHint }}