Files
sub2api/backend/internal/service/account.go
T

3146 lines
89 KiB
Go
Raw Normal View History

// Package service provides business logic and domain services for the application.
package service
import (
"encoding/json"
"errors"
"hash/fnv"
"log/slog"
"net/url"
"reflect"
"sort"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
)
type Account struct {
ID int64
Name string
Notes *string
Platform string
Type string
Credentials map[string]any
Extra map[string]any
ProxyID *int64
ProxyFallbackOriginID *int64
ProxyFallbackOriginName *string // 仅展示用
Concurrency int
Priority int
// RateMultiplier 账号计费倍率(>=0,允许 0 表示该账号计费为 0)。
// 使用指针用于兼容旧版本调度缓存(Redis)中缺字段的情况:nil 表示按 1.0 处理。
RateMultiplier *float64
LoadFactor *int // 调度负载因子;nil 表示使用 Concurrency
Status string
ErrorMessage string
LastUsedAt *time.Time
ExpiresAt *time.Time
AutoPauseOnExpired bool
CreatedAt time.Time
UpdatedAt time.Time
Schedulable bool
RateLimitedAt *time.Time
RateLimitResetAt *time.Time
OverloadUntil *time.Time
TempUnschedulableUntil *time.Time
TempUnschedulableReason string
SessionWindowStart *time.Time
SessionWindowEnd *time.Time
SessionWindowStatus string
ParentAccountID *int64 // non-nil → 影子账号(不持凭据,透传母账号凭据)
QuotaDimension string // 用量维度:"" / "global" / "spark"
Proxy *Proxy
AccountGroups []AccountGroup
GroupIDs []int64
Groups []*Group
// model_mapping 热路径缓存(非持久化字段)
modelMappingCache map[string]string
modelMappingCacheReady bool
modelMappingCacheCredentialsPtr uintptr
modelMappingCacheRawPtr uintptr
modelMappingCacheRawLen int
modelMappingCacheRawSig uint64
modelMappingCacheRuntimeVersion uint64
// header_overrides 热路径缓存(非持久化字段,同 model_mapping 缓存先例)
headerOverrideCache map[string]string
headerOverrideCacheReady bool
headerOverrideCacheCredentialsPtr uintptr
headerOverrideCacheRawPtr uintptr
headerOverrideCacheRawLen int
headerOverrideCacheRawSig uint64
}
type OpenAIEndpointCapability string
const openAILongContextBillingEnabledKey = "openai_long_context_billing_enabled"
const (
OpenAIEndpointCapabilityChatCompletions OpenAIEndpointCapability = "chat_completions"
OpenAIEndpointCapabilityEmbeddings OpenAIEndpointCapability = "embeddings"
OpenAIEndpointCapabilityAlphaSearch OpenAIEndpointCapability = "alpha_search"
OpenAIEndpointCapabilityLive OpenAIEndpointCapability = "live"
// OpenAIEndpointCapabilityGrokMediaGeneration keeps image/video generation
// away from Grok accounts that are explicitly disabled or whose billing
// entitlement probe was forbidden. Video status lookups intentionally do not
// require this capability so already-submitted requests remain queryable.
OpenAIEndpointCapabilityGrokMediaGeneration OpenAIEndpointCapability = "grok_media_generation"
// OpenAIEndpointCapabilityResponses 表示上游确实提供 /v1/responses 端点。
// 与其他能力不同:支持状态来自 accounts.extra 的自动探测标记
// openai_responses_supported / openai_responses_mode),而非
// credentials["openai_capabilities"] 配置集。仅用于生图意图的 /v1/responses
// 调度,避免把请求调度到会在 forward 阶段被降级为 Chat Completions 的账号(#4417)。
OpenAIEndpointCapabilityResponses OpenAIEndpointCapability = "responses"
)
const openAIEndpointCapabilitiesCredentialKey = "openai_capabilities"
// GrokMediaEligibleExtraKey is an optional per-account override stored in
// accounts.extra. true forces media routing on, false disables it, and an
// absent/null value uses provider observations.
const GrokMediaEligibleExtraKey = "grok_media_eligible"
const (
OpenAIAuthModePersonalAccessToken = "personalAccessToken"
openAIAuthModeCredentialKey = "auth_mode"
openAIAuthModeLegacyCredentialKey = "openai_auth_mode"
)
func isOpenAIPersonalAccessTokenAuthMode(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "personalaccesstoken", "personal_access_token":
return true
default:
return false
}
}
type TempUnschedulableRule struct {
ErrorCode int `json:"error_code"`
Keywords []string `json:"keywords"`
DurationMinutes int `json:"duration_minutes"`
Description string `json:"description"`
}
func (a *Account) IsActive() bool {
return a.Status == StatusActive
}
// IsSyntheticUITest reports whether the account belongs to an isolated UI load-test
// dataset. Production accounts never receive this marker. It lets the dedicated
// test instance exercise interactive quota and connection-test controls without
// sending fake credentials to an upstream provider.
func (a *Account) IsSyntheticUITest() bool {
if a == nil || a.Extra == nil {
return false
}
enabled, ok := a.Extra["synthetic_ui_test"].(bool)
return ok && enabled
}
// BillingRateMultiplier 返回账号计费倍率。
// - nil 表示未配置/旧缓存缺字段,按 1.0 处理
// - 允许 0,表示该账号计费为 0
// - 负数属于非法数据,出于安全考虑按 1.0 处理
func (a *Account) BillingRateMultiplier() float64 {
if a == nil || a.RateMultiplier == nil {
return 1.0
}
if *a.RateMultiplier < 0 {
return 1.0
}
return *a.RateMultiplier
}
func (a *Account) EffectiveLoadFactor() int {
if a == nil {
return 1
}
if a.LoadFactor != nil && *a.LoadFactor > 0 {
return *a.LoadFactor
}
if a.Concurrency > 0 {
return a.Concurrency
}
return 1
}
func (a *Account) IsSchedulable() bool {
if !a.IsActive() || !a.Schedulable {
return false
}
now := time.Now()
if a.AutoPauseOnExpired && a.ExpiresAt != nil && !now.Before(*a.ExpiresAt) {
return false
}
if a.OverloadUntil != nil && now.Before(*a.OverloadUntil) {
return false
}
if a.RateLimitResetAt != nil && now.Before(*a.RateLimitResetAt) {
return false
}
if a.TempUnschedulableUntil != nil && now.Before(*a.TempUnschedulableUntil) {
return false
}
if a.IsAPIKeyOrBedrock() && a.IsQuotaExceeded() {
return false
}
return true
}
// IsCredentialUsableForShadow 报告本账号(作为某 spark 影子的母账号)的凭据/传输是否可被影子透传使用。
//
// 检查「凭据/账号/传输可用性」:
// - 账号 active(非禁用/删除);
// - OAuth token 未过期(AutoPauseOnExpired+ExpiresAt);
// - 未处于 TempUnschedulableUntil 冷却期 —— 对 OpenAI 账号该字段由 401 鉴权失败 /
// token 刷新耗尽 / transport·proxy 故障写入(ratelimit/token_refresh/upstream_transport),
// 都代表**共享凭据或传输通道坏死**;影子共享母 token+proxy,故母处于该冷却期时影子也不可用。
//
// **刻意排除** global 维度的限流/过载窗口(RateLimitResetAt / OverloadUntil)与母账号自身的
// 手动 Schedulable 开关:spark 影子拥有独立 spark 配额窗口,母账号 global 429(走 RateLimitResetAt)
// 不应连坐 spark(否则重新耦合影子架构本应解耦的两条 429 道)。nil receiver 返回 false。
func (a *Account) IsCredentialUsableForShadow() bool {
if a == nil || !a.IsActive() {
return false
}
now := time.Now()
if a.AutoPauseOnExpired && a.ExpiresAt != nil && !now.Before(*a.ExpiresAt) {
return false
}
if a.TempUnschedulableUntil != nil && now.Before(*a.TempUnschedulableUntil) {
return false
}
return true
}
func (a *Account) IsRateLimited() bool {
if a.RateLimitResetAt == nil {
return false
}
return time.Now().Before(*a.RateLimitResetAt)
}
func (a *Account) IsOverloaded() bool {
if a.OverloadUntil == nil {
return false
}
return time.Now().Before(*a.OverloadUntil)
}
func (a *Account) IsOAuth() bool {
return a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken
}
// IsPrivacySet 检查账号的 privacy 是否已成功设置。
// OpenAI: privacy_mode == "training_off"
// Antigravity: privacy_mode == "privacy_set"
// 其他平台: 无 privacy 概念,始终返回 true
func (a *Account) IsPrivacySet() bool {
switch a.Platform {
case PlatformOpenAI:
return a.getExtraString("privacy_mode") == PrivacyModeTrainingOff
case PlatformAntigravity:
return a.getExtraString("privacy_mode") == AntigravityPrivacySet
default:
return true
}
}
func (a *Account) IsGemini() bool {
return a.Platform == PlatformGemini
}
func (a *Account) IsGrok() bool {
return a.Platform == PlatformGrok
}
func (a *Account) IsGrokOAuth() bool {
return a.IsGrok() && a.Type == AccountTypeOAuth
}
// IsKimi / IsZhipu / IsDeepseek 标识国产 OpenAI 兼容供应商账号。
func (a *Account) IsKimi() bool {
return a.Platform == PlatformKimi
}
func (a *Account) IsZhipu() bool {
return a.Platform == PlatformZhipu
}
func (a *Account) IsDeepseek() bool {
return a.Platform == PlatformDeepseek
}
// IsCNProvider 报告是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)。
func (a *Account) IsCNProvider() bool {
return a != nil && IsCNProvider(a.Platform)
}
// IsOpenAICompatible 报告账号是否走 OpenAI 网关(OpenAI 协议族)。
// openai/grok 原生走 OpenAI 网关;kimi/zhipu/deepseek 同为 OpenAI Chat Completions
// 兼容上游,也经 OpenAI 网关转发。
func (a *Account) IsOpenAICompatible() bool {
return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok ||
a.Platform == PlatformKimi || a.Platform == PlatformZhipu || a.Platform == PlatformDeepseek)
}
func (a *Account) GeminiOAuthType() string {
if a.Platform != PlatformGemini || a.Type != AccountTypeOAuth {
return ""
}
oauthType := strings.TrimSpace(a.GetCredential("oauth_type"))
if oauthType == "" && strings.TrimSpace(a.GetCredential("project_id")) != "" {
return "code_assist"
}
return oauthType
}
func (a *Account) GeminiTierID() string {
tierID := strings.TrimSpace(a.GetCredential("tier_id"))
return tierID
}
func (a *Account) IsGeminiCodeAssist() bool {
if a.Platform != PlatformGemini || a.Type != AccountTypeOAuth {
return false
}
oauthType := a.GeminiOAuthType()
if oauthType == "" {
return strings.TrimSpace(a.GetCredential("project_id")) != ""
}
return oauthType == "code_assist"
}
func (a *Account) CanGetUsage() bool {
return a.Type == AccountTypeOAuth
}
func (a *Account) GetCredential(key string) string {
if a.Credentials == nil {
return ""
}
v, ok := a.Credentials[key]
if !ok || v == nil {
return ""
}
// 支持多种类型(兼容历史数据中 expires_at 等字段可能是数字或字符串)
switch val := v.(type) {
case string:
return val
case json.Number:
// GORM datatypes.JSONMap 使用 UseNumber() 解析,数字类型为 json.Number
return val.String()
case float64:
// JSON 解析后数字默认为 float64
return strconv.FormatInt(int64(val), 10)
case int64:
return strconv.FormatInt(val, 10)
case int:
return strconv.Itoa(val)
default:
return ""
}
}
// GetCredentialAsTime 解析凭证中的时间戳字段,支持多种格式
// 兼容以下格式:
// - RFC3339 字符串: "2025-01-01T00:00:00Z"
// - Unix 时间戳字符串: "1735689600"
// - Unix 时间戳数字: 1735689600 (float64/int64/json.Number)
func (a *Account) GetCredentialAsTime(key string) *time.Time {
s := a.GetCredential(key)
if s == "" {
return nil
}
// 尝试 RFC3339 格式
if t, err := time.Parse(time.RFC3339, s); err == nil {
return &t
}
// 尝试 Unix 时间戳(纯数字字符串)
if ts, err := strconv.ParseInt(s, 10, 64); err == nil {
t := time.Unix(ts, 0)
return &t
}
return nil
}
// GetCredentialAsInt64 解析凭证中的 int64 字段
// 用于读取 _token_version 等内部字段
func (a *Account) GetCredentialAsInt64(key string) int64 {
if a == nil || a.Credentials == nil {
return 0
}
val, ok := a.Credentials[key]
if !ok || val == nil {
return 0
}
switch v := val.(type) {
case int64:
return v
case float64:
return int64(v)
case int:
return int64(v)
case json.Number:
if i, err := v.Int64(); err == nil {
return i
}
case string:
if i, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil {
return i
}
}
return 0
}
func (a *Account) IsTempUnschedulableEnabled() bool {
if a.Credentials == nil {
return false
}
raw, ok := a.Credentials["temp_unschedulable_enabled"]
if !ok || raw == nil {
return false
}
enabled, ok := raw.(bool)
return ok && enabled
}
func (a *Account) GetTempUnschedulableRules() []TempUnschedulableRule {
if a.Credentials == nil {
return nil
}
raw, ok := a.Credentials["temp_unschedulable_rules"]
if !ok || raw == nil {
return nil
}
arr, ok := raw.([]any)
if !ok {
return nil
}
rules := make([]TempUnschedulableRule, 0, len(arr))
for _, item := range arr {
entry, ok := item.(map[string]any)
if !ok || entry == nil {
continue
}
rule := TempUnschedulableRule{
ErrorCode: parseTempUnschedInt(entry["error_code"]),
Keywords: parseTempUnschedStrings(entry["keywords"]),
DurationMinutes: parseTempUnschedInt(entry["duration_minutes"]),
Description: parseTempUnschedString(entry["description"]),
}
if rule.ErrorCode <= 0 || rule.DurationMinutes <= 0 || len(rule.Keywords) == 0 {
continue
}
rules = append(rules, rule)
}
return rules
}
func parseTempUnschedString(value any) string {
s, ok := value.(string)
if !ok {
return ""
}
return strings.TrimSpace(s)
}
func parseTempUnschedStrings(value any) []string {
if value == nil {
return nil
}
var raw []string
switch v := value.(type) {
case []string:
raw = v
case []any:
raw = make([]string, 0, len(v))
for _, item := range v {
if s, ok := item.(string); ok {
raw = append(raw, s)
}
}
default:
return nil
}
out := make([]string, 0, len(raw))
for _, item := range raw {
s := strings.TrimSpace(item)
if s != "" {
out = append(out, s)
}
}
return out
}
func normalizeAccountNotes(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func parseTempUnschedInt(value any) int {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case json.Number:
if i, err := v.Int64(); err == nil {
return int(i)
}
case string:
if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
return i
}
}
return 0
}
const (
// OpenAICompactModeAuto follows compact-probe results when deciding compact eligibility.
OpenAICompactModeAuto = "auto"
// OpenAICompactModeForceOn always treats the account as compact-supported.
OpenAICompactModeForceOn = "force_on"
// OpenAICompactModeForceOff always treats the account as compact-unsupported.
OpenAICompactModeForceOff = "force_off"
)
func normalizeOpenAICompactMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case OpenAICompactModeForceOn:
return OpenAICompactModeForceOn
case OpenAICompactModeForceOff:
return OpenAICompactModeForceOff
default:
return OpenAICompactModeAuto
}
}
func stringMappingFromRaw(raw any) map[string]string {
switch mapping := raw.(type) {
case map[string]any:
if len(mapping) == 0 {
return nil
}
result := make(map[string]string, len(mapping))
for key, value := range mapping {
if str, ok := value.(string); ok {
result[key] = str
}
}
if len(result) == 0 {
return nil
}
return result
case map[string]string:
if len(mapping) == 0 {
return nil
}
result := make(map[string]string, len(mapping))
for key, value := range mapping {
result[key] = value
}
return result
default:
return nil
}
}
func (a *Account) GetModelMapping() map[string]string {
runtimeVersion := xai.RuntimeModelMappingVersion()
credentialsPtr := mapPtr(a.Credentials)
rawMapping, _ := a.Credentials["model_mapping"].(map[string]any)
rawPtr := mapPtr(rawMapping)
rawLen := len(rawMapping)
rawSig := uint64(0)
rawSigReady := false
if a.modelMappingCacheReady &&
a.modelMappingCacheCredentialsPtr == credentialsPtr &&
a.modelMappingCacheRawPtr == rawPtr &&
a.modelMappingCacheRawLen == rawLen &&
a.modelMappingCacheRuntimeVersion == runtimeVersion {
rawSig = modelMappingSignature(rawMapping)
rawSigReady = true
if a.modelMappingCacheRawSig == rawSig {
return a.modelMappingCache
}
}
mapping := a.resolveModelMapping(rawMapping)
if !rawSigReady {
rawSig = modelMappingSignature(rawMapping)
}
a.modelMappingCache = mapping
a.modelMappingCacheReady = true
a.modelMappingCacheCredentialsPtr = credentialsPtr
a.modelMappingCacheRawPtr = rawPtr
a.modelMappingCacheRawLen = rawLen
a.modelMappingCacheRawSig = rawSig
a.modelMappingCacheRuntimeVersion = runtimeVersion
return mapping
}
func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]string {
if a.Credentials == nil {
// Antigravity 平台使用默认映射
if a.Platform == domain.PlatformAntigravity {
return domain.DefaultAntigravityModelMapping
}
if a.Platform == domain.PlatformGrok {
return xai.DefaultModelMapping()
}
// Bedrock 默认映射由 forwardBedrock 统一处理(需配合 region prefix 调整)
return nil
}
if len(rawMapping) == 0 {
// Antigravity 平台使用默认映射
if a.Platform == domain.PlatformAntigravity {
return domain.DefaultAntigravityModelMapping
}
if a.Platform == domain.PlatformGrok {
return xai.DefaultModelMapping()
}
return nil
}
result := make(map[string]string)
for k, v := range rawMapping {
if s, ok := v.(string); ok {
result[k] = s
}
}
if len(result) > 0 {
if a.Platform == domain.PlatformAntigravity {
ensureAntigravityDefaultPassthroughs(result, []string{
"gemini-3-flash",
"gemini-3.1-pro-high",
"gemini-3.1-pro-low",
"gemini-3.6-flash",
"gemini-3.6-flash-high",
"gemini-3.6-flash-low",
"gemini-3.6-flash-medium",
"gemini-3.6-flash-tiered",
})
applyAntigravityGemini31ProAliases(result)
}
return result
}
// Antigravity 平台使用默认映射
if a.Platform == domain.PlatformAntigravity {
return domain.DefaultAntigravityModelMapping
}
if a.Platform == domain.PlatformGrok {
return xai.DefaultModelMapping()
}
return nil
}
func mapPtr(m map[string]any) uintptr {
if m == nil {
return 0
}
return reflect.ValueOf(m).Pointer()
}
func modelMappingSignature(rawMapping map[string]any) uint64 {
if len(rawMapping) == 0 {
return 0
}
keys := make([]string, 0, len(rawMapping))
for k := range rawMapping {
keys = append(keys, k)
}
sort.Strings(keys)
h := fnv.New64a()
for _, k := range keys {
_, _ = h.Write([]byte(k))
_, _ = h.Write([]byte{0})
if v, ok := rawMapping[k].(string); ok {
_, _ = h.Write([]byte(v))
} else {
_, _ = h.Write([]byte{1})
}
_, _ = h.Write([]byte{0xff})
}
return h.Sum64()
}
func ensureAntigravityDefaultPassthrough(mapping map[string]string, model string) {
if mapping == nil || model == "" {
return
}
if _, exists := mapping[model]; exists {
return
}
for pattern := range mapping {
if matchWildcard(pattern, model) {
return
}
}
mapping[model] = model
}
func ensureAntigravityDefaultPassthroughs(mapping map[string]string, models []string) {
for _, model := range models {
ensureAntigravityDefaultPassthrough(mapping, model)
}
}
func applyAntigravityGemini31ProAliases(mapping map[string]string) {
target := strings.TrimSpace(mapping[domain.AntigravityGemini31ProAgentModel])
if target == "" {
return
}
aliases := []struct {
model string
legacyTargets map[string]struct{}
}{
{
model: "gemini-3.1-pro",
legacyTargets: map[string]struct{}{
"gemini-3.1-pro": {},
},
},
{
model: "gemini-3.1-pro-high",
legacyTargets: map[string]struct{}{
"gemini-3.1-pro-high": {},
},
},
{
model: "gemini-3.1-pro-preview",
legacyTargets: map[string]struct{}{
"gemini-3.1-pro-preview": {},
"gemini-3.1-pro-high": {},
},
},
}
for _, alias := range aliases {
current, exists := mapping[alias.model]
if exists {
if _, legacy := alias.legacyTargets[current]; legacy {
mapping[alias.model] = target
}
continue
}
if mappingHasWildcardForModel(mapping, alias.model) {
continue
}
mapping[alias.model] = target
}
}
func mappingHasWildcardForModel(mapping map[string]string, model string) bool {
for pattern := range mapping {
if matchWildcard(pattern, model) {
return true
}
}
return false
}
func normalizeRequestedModelForLookup(platform, requestedModel string) string {
trimmed := strings.TrimSpace(requestedModel)
if trimmed == "" {
return ""
}
if platform != PlatformGemini && platform != PlatformAntigravity {
return trimmed
}
if trimmed == "gemini-3.1-pro-preview-customtools" {
return "gemini-3.1-pro-preview"
}
return trimmed
}
func mappingSupportsRequestedModel(mapping map[string]string, requestedModel string) bool {
if requestedModel == "" {
return false
}
if _, exists := mapping[requestedModel]; exists {
return true
}
for pattern := range mapping {
if matchWildcard(pattern, requestedModel) {
return true
}
}
return false
}
func resolveRequestedModelInMapping(mapping map[string]string, requestedModel string) (mappedModel string, matched bool) {
if requestedModel == "" {
return "", false
}
if mappedModel, exists := mapping[requestedModel]; exists {
return mappedModel, true
}
return matchWildcardMappingResult(mapping, requestedModel)
}
// IsModelSupported 检查模型是否在 model_mapping 中(支持通配符)
// 如果未配置 mapping,返回 true(允许所有模型)。
//
// 例外:OpenAI OAuth 账号(Codex 上游)的空映射会排除明确属于其他厂商
// 家族的模型(deepseek-*/glm-* 等)——转发阶段 normalizeOpenAIModelForUpstream
// 会把未知模型原样透传,Codex 上游对这类模型必然返回不可重试的 400,导致
// 请求卡死在该账号上、无法 failover 到真正支持该模型的 API Key 账号(#3662)。
// 未知/自定义别名仍保持允许(兼容渠道级映射),见 isOpenAIOAuthServableModel。
func (a *Account) IsModelSupported(requestedModel string) bool {
// 透传模式仅替换认证、模型语义完全交由上游决定,因此放行所有模型。
// 该短路必须在 model_mapping 判定之前:账号从"白名单模式"切换到透传后,
// credentials 里常残留旧的非空 model_mapping,若不在此放行,透传账号会被
// model_mapping 白名单错误排除出候选集,导致 no available accounts / 404issue #4936)。
if a.IsOpenAIPassthroughEnabled() {
return true
}
mapping := a.GetModelMapping()
if len(mapping) == 0 {
if a.IsOpenAIOAuth() {
return isOpenAIOAuthServableModel(requestedModel)
}
return true // 无映射 = 允许所有
}
if mappingSupportsRequestedModel(mapping, requestedModel) {
return true
}
normalized := normalizeRequestedModelForLookup(a.Platform, requestedModel)
return normalized != requestedModel && mappingSupportsRequestedModel(mapping, normalized)
}
// GetMappedModel 获取映射后的模型名(支持通配符,最长优先匹配)
// 如果未配置 mapping,返回原始模型名
func (a *Account) GetMappedModel(requestedModel string) string {
mappedModel, _ := a.ResolveMappedModel(requestedModel)
return mappedModel
}
// ResolveMappedModel 获取映射后的模型名,并返回是否命中了账号级映射。
// matched=true 表示命中了精确映射或通配符映射,即使映射结果与原模型名相同。
func (a *Account) ResolveMappedModel(requestedModel string) (mappedModel string, matched bool) {
mapping := a.GetModelMapping()
if len(mapping) == 0 {
return requestedModel, false
}
if mappedModel, matched := resolveRequestedModelInMapping(mapping, requestedModel); matched {
return mappedModel, true
}
normalized := normalizeRequestedModelForLookup(a.Platform, requestedModel)
if normalized != requestedModel {
if mappedModel, matched := resolveRequestedModelInMapping(mapping, normalized); matched {
return mappedModel, true
}
}
return requestedModel, false
}
// GetOpenAICompactMode returns the compact routing mode for an OpenAI account.
// Missing or invalid values fall back to "auto".
func (a *Account) GetOpenAICompactMode() string {
if a == nil || !a.IsOpenAI() || a.Extra == nil {
return OpenAICompactModeAuto
}
mode, _ := a.Extra["openai_compact_mode"].(string)
return normalizeOpenAICompactMode(mode)
}
// OpenAICompactSupportKnown reports whether compact capability is known for this
// account and, when known, whether it is supported.
func (a *Account) OpenAICompactSupportKnown() (supported bool, known bool) {
if a == nil || !a.IsOpenAI() {
return false, false
}
switch a.GetOpenAICompactMode() {
case OpenAICompactModeForceOn:
return true, true
case OpenAICompactModeForceOff:
return false, true
}
if a.Extra == nil {
return false, false
}
supported, ok := a.Extra["openai_compact_supported"].(bool)
if !ok {
return false, false
}
return supported, true
}
// AllowsOpenAICompact reports whether the account may be considered for compact
// requests. Unknown capability remains allowed to avoid breaking older accounts
// before an explicit probe has been run.
func (a *Account) AllowsOpenAICompact() bool {
if a == nil || !a.IsOpenAI() {
return false
}
supported, known := a.OpenAICompactSupportKnown()
if !known {
return true
}
return supported
}
// GetCompactModelMapping returns compact-only model remapping configuration.
// This mapping is intended for /responses/compact only and does not affect
// normal /responses traffic.
func (a *Account) GetCompactModelMapping() map[string]string {
if a == nil || a.Credentials == nil {
return nil
}
return stringMappingFromRaw(a.Credentials["compact_model_mapping"])
}
// ResolveCompactMappedModel resolves compact-only model remapping and reports
// whether a compact-specific mapping rule matched.
func (a *Account) ResolveCompactMappedModel(requestedModel string) (mappedModel string, matched bool) {
mapping := a.GetCompactModelMapping()
if len(mapping) == 0 {
return requestedModel, false
}
if mappedModel, matched := resolveRequestedModelInMapping(mapping, requestedModel); matched {
return mappedModel, true
}
return requestedModel, false
}
func (a *Account) GetBaseURL() string {
if a.Type != AccountTypeAPIKey {
return ""
}
baseURL := a.GetCredential("base_url")
if baseURL == "" {
return "https://api.anthropic.com"
}
if a.Platform == PlatformAntigravity {
return strings.TrimRight(baseURL, "/") + "/antigravity"
}
return baseURL
}
// GetGeminiBaseURL 返回 Gemini 兼容端点的 base URL。
// Antigravity 平台的 APIKey 账号自动拼接 /antigravity。
func (a *Account) GetGeminiBaseURL(defaultBaseURL string) string {
baseURL := strings.TrimSpace(a.GetCredential("base_url"))
if baseURL == "" {
return defaultBaseURL
}
if a.Platform == PlatformAntigravity && a.Type == AccountTypeAPIKey {
return strings.TrimRight(baseURL, "/") + "/antigravity"
}
return baseURL
}
func (a *Account) GetExtraString(key string) string {
if a.Extra == nil {
return ""
}
if v, ok := a.Extra[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func (a *Account) GetClaudeUserID() string {
if v := strings.TrimSpace(a.GetExtraString("claude_user_id")); v != "" {
return v
}
if v := strings.TrimSpace(a.GetExtraString("anthropic_user_id")); v != "" {
return v
}
if v := strings.TrimSpace(a.GetCredential("claude_user_id")); v != "" {
return v
}
if v := strings.TrimSpace(a.GetCredential("anthropic_user_id")); v != "" {
return v
}
return ""
}
// matchAntigravityWildcard 通配符匹配(仅支持末尾 *)
// 用于 model_mapping 的通配符匹配
func matchAntigravityWildcard(pattern, str string) bool {
if strings.HasSuffix(pattern, "*") {
prefix := pattern[:len(pattern)-1]
return strings.HasPrefix(str, prefix)
}
return pattern == str
}
// matchWildcard 通用通配符匹配(仅支持末尾 *)
// 复用 Antigravity 的通配符逻辑,供其他平台使用
func matchWildcard(pattern, str string) bool {
return matchAntigravityWildcard(pattern, str)
}
func matchWildcardMappingResult(mapping map[string]string, requestedModel string) (string, bool) {
// 收集所有匹配的 pattern,按长度降序排序(最长优先)
type patternMatch struct {
pattern string
target string
}
var matches []patternMatch
for pattern, target := range mapping {
if matchWildcard(pattern, requestedModel) {
matches = append(matches, patternMatch{pattern, target})
}
}
if len(matches) == 0 {
return requestedModel, false // 无匹配,返回原始模型名
}
// 按 pattern 长度降序排序
sort.Slice(matches, func(i, j int) bool {
if len(matches[i].pattern) != len(matches[j].pattern) {
return len(matches[i].pattern) > len(matches[j].pattern)
}
return matches[i].pattern < matches[j].pattern
})
return matches[0].target, true
}
func (a *Account) IsCustomErrorCodesEnabled() bool {
if a.Type != AccountTypeAPIKey || a.Credentials == nil {
return false
}
if v, ok := a.Credentials["custom_error_codes_enabled"]; ok {
if enabled, ok := v.(bool); ok {
return enabled
}
}
return false
}
// IsPoolMode 检查 API Key 账号是否启用池模式。
// 池模式下,上游错误不标记本地账号状态,而是在同一账号上重试。
func (a *Account) IsPoolMode() bool {
if !a.IsAPIKeyOrBedrock() || a.Credentials == nil {
return false
}
if v, ok := a.Credentials["pool_mode"]; ok {
if enabled, ok := v.(bool); ok {
return enabled
}
}
return false
}
const (
defaultPoolModeRetryCount = 3
maxPoolModeRetryCount = 10
)
// GetPoolModeRetryCount 返回池模式同账号重试次数。
// 未配置或配置非法时回退为默认值 3;小于 0 按 0 处理;过大则截断到 10。
func (a *Account) GetPoolModeRetryCount() int {
if a == nil || !a.IsPoolMode() || a.Credentials == nil {
return defaultPoolModeRetryCount
}
raw, ok := a.Credentials["pool_mode_retry_count"]
if !ok || raw == nil {
return defaultPoolModeRetryCount
}
count := parsePoolModeRetryCount(raw)
if count < 0 {
return 0
}
if count > maxPoolModeRetryCount {
return maxPoolModeRetryCount
}
return count
}
func parsePoolModeRetryCount(value any) int {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case json.Number:
if i, err := v.Int64(); err == nil {
return int(i)
}
case string:
if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
return i
}
}
return defaultPoolModeRetryCount
}
// defaultPoolModeRetryableStatusCodes 池模式下默认触发同账号重试的状态码。
// 未在 Account.Credentials 中显式配置 pool_mode_retry_status_codes 时使用。
var defaultPoolModeRetryableStatusCodes = []int{401, 403, 429}
// isPoolModeRetryableStatus 池模式下应触发同账号重试的状态码(默认列表)。
func isPoolModeRetryableStatus(statusCode int) bool {
for _, c := range defaultPoolModeRetryableStatusCodes {
if c == statusCode {
return true
}
}
return false
}
// GetPoolModeRetryStatusCodes 返回账号自定义的池模式同账号重试状态码列表。
//
// 返回值语义:
// - nil:未配置 → 调用方应回退到默认值 [401, 403, 429]
// - 长度为 0 的切片:管理员显式置空 → 关闭按状态码触发的同账号重试
// - 非空切片:去重、过滤为合法 HTTP 状态码(100-599)后的覆盖列表
func (a *Account) GetPoolModeRetryStatusCodes() []int {
if a == nil || a.Credentials == nil {
return nil
}
raw, ok := a.Credentials["pool_mode_retry_status_codes"]
if !ok || raw == nil {
return nil
}
arr, ok := raw.([]any)
if !ok {
return nil
}
seen := make(map[int]struct{}, len(arr))
codes := make([]int, 0, len(arr))
for _, v := range arr {
var code int
switch n := v.(type) {
case float64:
code = int(n)
case int:
code = n
case int64:
code = int(n)
case json.Number:
i, err := n.Int64()
if err != nil {
continue
}
code = int(i)
case string:
i, err := strconv.Atoi(strings.TrimSpace(n))
if err != nil {
continue
}
code = i
default:
continue
}
if code < 100 || code > 599 {
continue
}
if _, exists := seen[code]; exists {
continue
}
seen[code] = struct{}{}
codes = append(codes, code)
}
sort.Ints(codes)
return codes
}
// IsPoolModeRetryableStatus 在账号上下文中判断给定状态码是否应触发同账号重试。
// 若账号未配置 pool_mode_retry_status_codes,则回退到默认列表。
func (a *Account) IsPoolModeRetryableStatus(statusCode int) bool {
codes := a.GetPoolModeRetryStatusCodes()
if codes == nil {
return isPoolModeRetryableStatus(statusCode)
}
for _, c := range codes {
if c == statusCode {
return true
}
}
return false
}
func (a *Account) GetCustomErrorCodes() []int {
if a.Credentials == nil {
return nil
}
raw, ok := a.Credentials["custom_error_codes"]
if !ok || raw == nil {
return nil
}
if arr, ok := raw.([]any); ok {
result := make([]int, 0, len(arr))
for _, v := range arr {
if f, ok := v.(float64); ok {
result = append(result, int(f))
}
}
return result
}
return nil
}
func (a *Account) ShouldHandleErrorCode(statusCode int) bool {
if !a.IsCustomErrorCodesEnabled() {
return true
}
codes := a.GetCustomErrorCodes()
if len(codes) == 0 {
return true
}
for _, code := range codes {
if code == statusCode {
return true
}
}
return false
}
func (a *Account) IsInterceptWarmupEnabled() bool {
if a.Credentials == nil {
return false
}
if v, ok := a.Credentials["intercept_warmup_requests"]; ok {
if enabled, ok := v.(bool); ok {
return enabled
}
}
return false
}
func (a *Account) IsBedrock() bool {
return a.Platform == PlatformAnthropic && a.Type == AccountTypeBedrock
}
func (a *Account) IsBedrockAPIKey() bool {
return a.IsBedrock() && a.GetCredential("auth_mode") == "apikey"
}
// IsAPIKeyOrBedrock 返回账号类型是否支持配额和池模式等特性
func (a *Account) IsAPIKeyOrBedrock() bool {
return a.Type == AccountTypeAPIKey || a.Type == AccountTypeBedrock
}
func (a *Account) IsOpenAI() bool {
return a.Platform == PlatformOpenAI
}
func (a *Account) IsOpenAILongContextBillingEnabled() bool {
if a == nil || !a.IsOpenAI() || a.Extra == nil {
return false
}
enabled, ok := a.Extra[openAILongContextBillingEnabledKey].(bool)
return ok && enabled
}
func (a *Account) IsAnthropic() bool {
return a.Platform == PlatformAnthropic
}
func (a *Account) IsOpenAIOAuth() bool {
return a.IsOpenAI() && a.Type == AccountTypeOAuth
}
func (a *Account) IsOpenAIChatGPTSubscription() bool {
if !a.IsOpenAIOAuth() {
return false
}
switch strings.ToLower(strings.TrimSpace(a.GetCredential("plan_type"))) {
case "", "free", "abnormal":
return false
default:
return true
}
}
func (a *Account) IsOpenAIPersonalAccessToken() bool {
if !a.IsOpenAIOAuth() {
return false
}
return isOpenAIPersonalAccessTokenAuthMode(a.GetCredential(openAIAuthModeCredentialKey)) ||
isOpenAIPersonalAccessTokenAuthMode(a.GetCredential(openAIAuthModeLegacyCredentialKey))
}
func (a *Account) IsOpenAIApiKey() bool {
return a.IsOpenAI() && a.Type == AccountTypeAPIKey
}
// GetOpenAIBaseURL 解析 OpenAI 协议族账号的上游 base_url。
// 适用 openai 与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek);grok 走 GetGrokBaseURL
// 此处对 grok 返回 "" 以保持原有行为。
func (a *Account) GetOpenAIBaseURL() string {
if !a.IsOpenAI() && !a.IsCNProvider() {
return ""
}
if a.IsCNProvider() && a.IsAdaptiveAPIProtocol() {
if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok {
if baseURL, ok := baseURLs[APIProtocolChatCompletions].(string); ok && strings.TrimSpace(baseURL) != "" {
return strings.TrimSpace(baseURL)
}
}
}
if a.Type == AccountTypeAPIKey || a.Type == AccountTypeUpstream {
if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" {
return baseURL
}
}
// 平台默认 base_urlCN 供应商按 account_mode 选择 payg / coding 默认值。
switch a.Platform {
case PlatformKimi:
if a.GetAccountMode() == AccountModeCoding {
return DefaultKimiCodingBaseURL
}
return DefaultKimiPayGBaseURL
case PlatformZhipu:
if a.GetAccountMode() == AccountModeCoding {
return DefaultZhipuCodingBaseURL
}
return DefaultZhipuPayGBaseURL
case PlatformDeepseek:
return DefaultDeepseekBaseURL
default:
return "https://api.openai.com"
}
}
// GetAccountMode 返回国产供应商账号的接入模式(payg / coding);非国产供应商或未设置时
// 返回空串。存储于 credentials["account_mode"]。
func (a *Account) GetAccountMode() string {
if a == nil {
return ""
}
mode := strings.TrimSpace(a.GetCredential("account_mode"))
if mode == AccountModePayG || mode == AccountModeCoding {
return mode
}
return ""
}
// IsCodingPlan 报告账号是否为 Coding Plan 模式(用于滚动用量窗口冷却)。
func (a *Account) IsCodingPlan() bool {
return a.GetAccountMode() == AccountModeCoding
}
// GetAPIProtocol 返回国产供应商账号的上游 API 协议。存储于
// credentials["api_protocol"];缺失或与平台不匹配时回退 chat_completions
// (与既有行为完全一致)。responses 协议仅 deepseek 支持(官方原生 /responses
// 端点,适配 Codex);kimi/zhipu 无此端点。
func (a *Account) GetAPIProtocol() string {
if a == nil || !a.IsCNProvider() {
return APIProtocolChatCompletions
}
switch strings.TrimSpace(a.GetCredential("api_protocol")) {
case APIProtocolAdaptive:
return APIProtocolAdaptive
case APIProtocolAnthropic:
return APIProtocolAnthropic
case APIProtocolResponses:
if a.Platform == PlatformDeepseek {
return APIProtocolResponses
}
case APIProtocolChatCompletions:
return APIProtocolChatCompletions
}
return APIProtocolChatCompletions
}
// IsAdaptiveAPIProtocol 报告账号是否按入站协议动态选择供应商原生端点。
func (a *Account) IsAdaptiveAPIProtocol() bool {
return a.GetAPIProtocol() == APIProtocolAdaptive
}
// GetCNProtocolBaseURL 返回国产供应商指定协议的上游 base URL。
// adaptive 账号优先使用 api_base_urls 中的分协议地址,缺失时按平台和
// account_mode 使用官方默认端点。base_url 继续作为 Chat Completions 地址兼容旧字段。
func (a *Account) GetCNProtocolBaseURL(protocol string) string {
if a == nil || !a.IsCNProvider() {
return ""
}
if a.IsAdaptiveAPIProtocol() {
if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok {
if baseURL, ok := baseURLs[protocol].(string); ok && strings.TrimSpace(baseURL) != "" {
return strings.TrimSpace(baseURL)
}
}
if protocol == APIProtocolChatCompletions {
if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" {
return baseURL
}
}
}
return a.defaultCNProtocolBaseURL(protocol)
}
func (a *Account) defaultCNProtocolBaseURL(protocol string) string {
switch protocol {
case APIProtocolAnthropic:
switch a.Platform {
case PlatformKimi:
if a.GetAccountMode() == AccountModeCoding {
return DefaultKimiCodingAnthropicBaseURL
}
return DefaultKimiPayGAnthropicBaseURL
case PlatformZhipu:
return DefaultZhipuAnthropicBaseURL
case PlatformDeepseek:
return DefaultDeepseekAnthropicBaseURL
}
case APIProtocolChatCompletions, APIProtocolResponses:
switch a.Platform {
case PlatformKimi:
if a.GetAccountMode() == AccountModeCoding {
return DefaultKimiCodingBaseURL
}
return DefaultKimiPayGBaseURL
case PlatformZhipu:
if a.GetAccountMode() == AccountModeCoding {
return DefaultZhipuCodingBaseURL
}
return DefaultZhipuPayGBaseURL
case PlatformDeepseek:
return DefaultDeepseekBaseURL
}
}
return ""
}
// IsAnthropicProtocol 报告账号是否以原生 Anthropic 协议接入上游
// /v1/messages 直通,适配 Claude Code 等客户端)。
func (a *Account) IsAnthropicProtocol() bool {
return a.GetAPIProtocol() == APIProtocolAnthropic
}
// GetAnthropicProtocolBaseURL 返回 Anthropic 协议账号的上游 base_url
// (上游路径为 {base}/v1/messages)。优先取凭证 base_url,缺失时按
// 供应商 × 接入模式返回默认端点。非 Anthropic 协议账号返回空串。
func (a *Account) GetAnthropicProtocolBaseURL() string {
if a == nil || (!a.IsAnthropicProtocol() && !a.IsAdaptiveAPIProtocol()) {
return ""
}
if a.IsAdaptiveAPIProtocol() {
return a.GetCNProtocolBaseURL(APIProtocolAnthropic)
}
if a.Type == AccountTypeAPIKey || a.Type == AccountTypeUpstream {
if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" {
return baseURL
}
}
switch a.Platform {
case PlatformKimi:
if a.GetAccountMode() == AccountModeCoding {
return DefaultKimiCodingAnthropicBaseURL
}
return DefaultKimiPayGAnthropicBaseURL
case PlatformZhipu:
return DefaultZhipuAnthropicBaseURL
case PlatformDeepseek:
return DefaultDeepseekAnthropicBaseURL
default:
return ""
}
}
// GetOpenAIFormatBaseURL 返回供 OpenAI 格式端点(/v1/models、/v1/chat/completions
// 等)使用的 base。chat_completions / responses 协议下与 GetOpenAIBaseURL
// 一致(凭证 base_url 或平台默认);anthropic 协议下凭证 base_url 指向 Anthropic
// 端点,不能拿来拼 OpenAI 路径,此时返回该供应商 × 模式的 Chat Completions
// 默认 base(模型同步等协议族共用路径仍可用)。
func (a *Account) GetOpenAIFormatBaseURL() string {
if a == nil || !a.IsAnthropicProtocol() {
return a.GetOpenAIBaseURL()
}
switch a.Platform {
case PlatformKimi:
if a.GetAccountMode() == AccountModeCoding {
return DefaultKimiCodingBaseURL
}
return DefaultKimiPayGBaseURL
case PlatformZhipu:
if a.GetAccountMode() == AccountModeCoding {
return DefaultZhipuCodingBaseURL
}
return DefaultZhipuPayGBaseURL
case PlatformDeepseek:
return DefaultDeepseekBaseURL
default:
return a.GetOpenAIBaseURL()
}
}
// GetCNAPIKey 返回国产 OpenAI 兼容供应商账号的 api_key 凭据(kimi/zhipu/deepseek)。
// 与 openai 的 GetOpenAIApiKey 区分:后者仅对 openai 平台返回。
func (a *Account) GetCNAPIKey() string {
if a == nil || !a.IsCNProvider() {
return ""
}
return a.GetCredential("api_key")
}
// GetCodingPlanProvider 根据 base_url 识别 Coding Plan 供应商(kimi / zhipu),
// 用于路由到对应的额度查询端点。非 coding 模式或无法识别时返回空串。
// 判定规则与 cc-switch coding_plan.rs::detect_provider 保持一致。
func (a *Account) GetCodingPlanProvider() string {
if a == nil || a.GetAccountMode() != AccountModeCoding {
return ""
}
baseURL := strings.ToLower(a.GetOpenAIBaseURL())
switch {
case strings.Contains(baseURL, "api.kimi.com/coding"):
return PlatformKimi
case strings.Contains(baseURL, "bigmodel.cn"), strings.Contains(baseURL, "api.z.ai"):
return PlatformZhipu
default:
return ""
}
}
func (a *Account) GetOpenAIAccessToken() string {
if !a.IsOpenAI() {
return ""
}
return a.GetCredential("access_token")
}
func (a *Account) GetOpenAIRefreshToken() string {
if !a.IsOpenAIOAuth() {
return ""
}
return a.GetCredential("refresh_token")
}
// GetGrokBaseURL selects the upstream used by Grok text and Responses traffic.
// Grok media traffic has a different transport contract and must use
// GetGrokMediaBaseURL instead.
//
// The stored base_url only rewrites forwarding endpoints. Credential lifecycle
// traffic (OAuth authorization and token refresh) always uses the official
// auth endpoints regardless of this value.
func (a *Account) GetGrokBaseURL() string {
if a == nil || !a.IsGrok() {
return ""
}
if a.IsGrokOAuth() {
return a.GetGrokBaseURLOr(xai.DefaultCLIBaseURL)
}
return a.GetGrokBaseURLOr(xai.DefaultBaseURL)
}
// GetGrokBaseURLOr resolves an explicit account endpoint, falling back to the
// supplied default. Official OAuth endpoints are normalized here; custom
// endpoints are retained for the request builder's operator URL policy.
func (a *Account) GetGrokBaseURLOr(defaultBaseURL string) string {
if a == nil || !a.IsGrok() {
return ""
}
defaultBaseURL = strings.TrimRight(strings.TrimSpace(defaultBaseURL), "/")
if defaultBaseURL == "" {
if a.IsGrokOAuth() {
defaultBaseURL = xai.DefaultCLIBaseURL
} else {
defaultBaseURL = xai.DefaultBaseURL
}
}
baseURL := strings.TrimSpace(a.GetCredential("base_url"))
if baseURL == "" {
return defaultBaseURL
}
if !a.IsGrokOAuth() {
return baseURL
}
// Explicit regional/API or custom values remain pinned. Custom endpoints are checked by the
// operator URL policy at the request builder, which has access to config.
if validated, err := xai.ValidateTrustedBaseURL(baseURL); err == nil {
return validated
}
if parsed, err := url.Parse(baseURL); err == nil && parsed.Scheme != "" && parsed.Host != "" &&
parsed.User == nil && parsed.RawQuery == "" && parsed.Fragment == "" {
return strings.TrimRight(baseURL, "/")
}
return defaultBaseURL
}
// GetGrokMediaBaseURL selects the upstream used by Grok Imagine APIs.
// The subscription CLI gateway enforces a small request-body limit that
// rejects large Base64 media payloads, so OAuth media leaves for api.x.ai
// whenever text traffic resolves to the CLI gateway. Every other manually
// selected endpoint (official/regional API hosts or custom relays) serves
// media as-is.
func (a *Account) GetGrokMediaBaseURL() string {
if !a.IsGrok() {
return ""
}
baseURL := a.GetGrokBaseURL()
if a.IsGrokOAuth() && isGrokCLIProxyTarget(baseURL) {
return xai.DefaultBaseURL
}
return baseURL
}
func (a *Account) GetGrokAccessToken() string {
if !a.IsGrok() {
return ""
}
return a.GetCredential("access_token")
}
func (a *Account) GetGrokRefreshToken() string {
if !a.IsGrokOAuth() {
return ""
}
return a.GetCredential("refresh_token")
}
func (a *Account) GetOpenAIIDToken() string {
if !a.IsOpenAIOAuth() {
return ""
}
return a.GetCredential("id_token")
}
func (a *Account) GetOpenAIApiKey() string {
if !a.IsOpenAIApiKey() {
return ""
}
return a.GetCredential("api_key")
}
// GetOpenAIProtocolAPIKey 返回 OpenAI 协议族 APIKey 账号的密钥。
// 覆盖 openai 原生账号与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)账号,
// 供转发鉴权、模型列表同步等协议族共用路径使用。注意 IsOpenAIApiKey 语义上
// 仅指 openai 平台账号,调度倍率/WS 能力门控继续以其为准,不受本方法影响。
func (a *Account) GetOpenAIProtocolAPIKey() string {
if a == nil {
return ""
}
if a.IsCNProvider() {
if a.Type != AccountTypeAPIKey {
return ""
}
return a.GetCredential("api_key")
}
return a.GetOpenAIApiKey()
}
func (a *Account) GetOpenAIUserAgent() string {
if !a.IsOpenAI() {
return ""
}
return a.GetCredential("user_agent")
}
func (a *Account) GetChatGPTAccountID() string {
if !a.IsOpenAIOAuth() {
return ""
}
return a.GetCredential("chatgpt_account_id")
}
func (a *Account) IsChatGPTAccountFedRAMP() bool {
if !a.IsOpenAIOAuth() || a.Credentials == nil {
return false
}
v, ok := a.Credentials["chatgpt_account_is_fedramp"]
if !ok || v == nil {
return false
}
switch value := v.(type) {
case bool:
return value
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed
case json.Number:
parsed, err := strconv.ParseBool(value.String())
return err == nil && parsed
case float64:
return value != 0
case int:
return value != 0
case int64:
return value != 0
default:
return false
}
}
func (a *Account) GetOpenAIDeviceID() string {
if !a.IsOpenAIOAuth() {
return ""
}
return strings.TrimSpace(a.GetExtraString("openai_device_id"))
}
func (a *Account) GetOpenAISessionID() string {
if !a.IsOpenAIOAuth() {
return ""
}
return strings.TrimSpace(a.GetExtraString("openai_session_id"))
}
func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapability) bool {
if a == nil {
return false
}
if capability == "" {
return true
}
if !a.IsOpenAICompatible() {
return false
}
if a.IsGrok() {
switch capability {
case OpenAIEndpointCapabilityChatCompletions:
return true
case OpenAIEndpointCapabilityGrokMediaGeneration:
eligible, reason := a.GrokMediaGenerationEligibility()
// Unobserved OAuth accounts remain scheduler candidates only so the
// request path can run the billing probe before forwarding. The
// forwarding gate itself fails closed if that probe is unavailable or
// cannot produce positive paid-entitlement evidence.
return eligible || reason == "billing_unobserved"
default:
return false
}
}
switch capability {
case OpenAIEndpointCapabilityChatCompletions:
case OpenAIEndpointCapabilityLive:
return a.Platform == PlatformOpenAI &&
a.Type == AccountTypeOAuth &&
!a.IsOpenAIPersonalAccessToken() &&
!a.IsOpenAIAgentIdentity()
case OpenAIEndpointCapabilityResponses:
// Responses 支持状态由 accounts.extra 的自动探测标记决定,而非
// credentials 能力集。已探测确认不支持 /v1/responses 的 APIKey 上游
// 必须排除——否则会在 forward 阶段被静默降级为 Chat Completions
// 无法完成生图(#4417)。未探测/OAuth 账号保留旧行为(不排除)。
if a.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(a.Extra) {
return false
}
// 支持 Responses 的上游同样需具备 chat 能力:复用下方 chat_completions
// 配置集校验。
capability = OpenAIEndpointCapabilityChatCompletions
case OpenAIEndpointCapabilityAlphaSearch:
// alpha/search 的转发按账号类型分流:OAuth/PAT 走
// chatgpt.com/backend-api/codex/alpha/searchAPI key 走
// {base_url}/v1/alpha/search(见 openAIAlphaSearchURL),两类账号
// 都可承接独立搜索请求。上游不支持该端点时由转发层 failover 兜底。
if a.Type != AccountTypeOAuth && a.Type != AccountTypeAPIKey {
return false
}
case OpenAIEndpointCapabilityEmbeddings:
if a.Type != AccountTypeAPIKey {
return false
}
default:
return false
}
configured, found := a.openAIEndpointCapabilitySet()
if !found {
return true
}
if capability == OpenAIEndpointCapabilityAlphaSearch && configured[string(OpenAIEndpointCapabilityChatCompletions)] {
return true
}
return configured[string(capability)]
}
// GrokMediaGenerationEligibility reports whether a Grok account may receive
// new image/video generation requests. OAuth media fails closed unless billing
// observations provide positive paid-entitlement evidence. An explicit
// operator override takes precedence over probe data.
func (a *Account) GrokMediaGenerationEligibility() (bool, string) {
if a == nil || !a.IsGrok() {
return false, "not_grok"
}
if override, ok := grokMediaEligibilityOverride(a.Extra); ok {
if override {
return true, "override_enabled"
}
return false, "override_disabled"
}
if a.Type != AccountTypeOAuth {
return true, "non_oauth"
}
billing, err := grokBillingSnapshotFromExtra(a.Extra)
if err != nil || billing == nil {
return false, "billing_unobserved"
}
if billing.StatusCode == 403 || billing.WeeklyStatusCode == 403 || billing.MonthlyStatusCode == 403 {
return false, "billing_forbidden"
}
if isKnownGrokFreeAccount(a) {
return false, "billing_free_tier"
}
if !grokBillingHasAuthoritativeQuota(billing) {
return false, "billing_inconclusive"
}
return true, "eligible"
}
func grokMediaEligibilityOverride(extra map[string]any) (bool, bool) {
if extra == nil {
return false, false
}
raw, exists := extra[GrokMediaEligibleExtraKey]
if !exists || raw == nil {
return false, false
}
value, ok := raw.(bool)
return value, ok
}
func (a *Account) openAIEndpointCapabilitySet() (map[string]bool, bool) {
if a == nil || a.Credentials == nil {
return nil, false
}
raw, found := a.Credentials[openAIEndpointCapabilitiesCredentialKey]
if !found || raw == nil {
return nil, false
}
result := make(map[string]bool)
add := func(value string) {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return
}
result[value] = true
}
switch capabilities := raw.(type) {
case []any:
for _, item := range capabilities {
if value, ok := item.(string); ok {
add(value)
}
}
case []string:
for _, value := range capabilities {
add(value)
}
case map[string]any:
for key, value := range capabilities {
enabled, ok := value.(bool)
if ok && enabled {
add(key)
}
}
case map[string]bool:
for key, enabled := range capabilities {
if enabled {
add(key)
}
}
}
return result, true
}
func (a *Account) SupportsOpenAIImageCapability(capability OpenAIImagesCapability) bool {
if capability == "" {
return true
}
if !a.IsOpenAI() {
return false
}
switch capability {
case OpenAIImagesCapabilityBasic, OpenAIImagesCapabilityNative:
return a.Type == AccountTypeOAuth || a.Type == AccountTypeAPIKey
default:
return true
}
}
func (a *Account) GetChatGPTUserID() string {
if !a.IsOpenAIOAuth() {
return ""
}
return a.GetCredential("chatgpt_user_id")
}
func (a *Account) GetOpenAIOrganizationID() string {
if !a.IsOpenAIOAuth() {
return ""
}
return a.GetCredential("organization_id")
}
func (a *Account) GetOpenAITokenExpiresAt() *time.Time {
if !a.IsOpenAIOAuth() {
return nil
}
return a.GetCredentialAsTime("expires_at")
}
func (a *Account) IsOpenAITokenExpired() bool {
expiresAt := a.GetOpenAITokenExpiresAt()
if expiresAt == nil {
return false
}
return time.Now().Add(60 * time.Second).After(*expiresAt)
}
// IsMixedSchedulingEnabled 检查 antigravity 账户是否启用混合调度
// 启用后可参与 anthropic/gemini 分组的账户调度
func (a *Account) IsMixedSchedulingEnabled() bool {
if a.Platform != PlatformAntigravity {
return false
}
if a.Extra == nil {
return false
}
if v, ok := a.Extra["mixed_scheduling"]; ok {
if enabled, ok := v.(bool); ok {
return enabled
}
}
return false
}
// IsOveragesEnabled 检查 Antigravity 账号是否启用 AI Credits 超量请求。
func (a *Account) IsOveragesEnabled() bool {
if a.Platform != PlatformAntigravity {
return false
}
if a.Extra == nil {
return false
}
if v, ok := a.Extra["allow_overages"]; ok {
if enabled, ok := v.(bool); ok {
return enabled
}
}
return false
}
// IsOpenAIPassthroughEnabled 返回 OpenAI 账号是否启用"自动透传(仅替换认证)"。
//
// 新字段:accounts.extra.openai_passthrough。
// 兼容字段:accounts.extra.openai_oauth_passthrough(历史 OAuth 开关)。
// 字段缺失或类型不正确时,按 false(关闭)处理。
func (a *Account) IsOpenAIPassthroughEnabled() bool {
if a == nil || !a.IsOpenAI() || a.Extra == nil {
return false
}
if enabled, ok := a.Extra["openai_passthrough"].(bool); ok {
return enabled
}
if enabled, ok := a.Extra["openai_oauth_passthrough"].(bool); ok {
return enabled
}
return false
}
// IsOpenAIResponsesWebSocketV2Enabled 返回 OpenAI 账号是否开启 Responses WebSocket v2。
//
// 分类型新字段:
// - OAuth 账号:accounts.extra.openai_oauth_responses_websockets_v2_enabled
// - API Key 账号:accounts.extra.openai_apikey_responses_websockets_v2_enabled
//
// 兼容字段:
// - accounts.extra.responses_websockets_v2_enabled
// - accounts.extra.openai_ws_enabled(历史开关)
//
// 优先级:
// 1. 按账号类型读取分类型字段
// 2. 分类型字段缺失时,回退兼容字段
func (a *Account) IsOpenAIResponsesWebSocketV2Enabled() bool {
if a == nil || !a.IsOpenAI() || a.Extra == nil {
return false
}
if a.IsOpenAIOAuth() {
if enabled, ok := a.Extra["openai_oauth_responses_websockets_v2_enabled"].(bool); ok {
return enabled
}
}
if a.IsOpenAIApiKey() {
if enabled, ok := a.Extra["openai_apikey_responses_websockets_v2_enabled"].(bool); ok {
return enabled
}
}
if enabled, ok := a.Extra["responses_websockets_v2_enabled"].(bool); ok {
return enabled
}
if enabled, ok := a.Extra["openai_ws_enabled"].(bool); ok {
return enabled
}
return false
}
const (
OpenAIWSIngressModeOff = "off"
OpenAIWSIngressModeShared = "shared"
OpenAIWSIngressModeDedicated = "dedicated"
OpenAIWSIngressModeCtxPool = "ctx_pool"
OpenAIWSIngressModePassthrough = "passthrough"
OpenAIWSIngressModeHTTPBridge = "http_bridge"
)
func normalizeOpenAIWSIngressMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case OpenAIWSIngressModeOff:
return OpenAIWSIngressModeOff
case OpenAIWSIngressModeCtxPool:
return OpenAIWSIngressModeCtxPool
case OpenAIWSIngressModePassthrough:
return OpenAIWSIngressModePassthrough
case OpenAIWSIngressModeHTTPBridge:
return OpenAIWSIngressModeHTTPBridge
case OpenAIWSIngressModeShared:
return OpenAIWSIngressModeShared
case OpenAIWSIngressModeDedicated:
return OpenAIWSIngressModeDedicated
default:
return ""
}
}
func normalizeOpenAIWSIngressDefaultMode(mode string) string {
if normalized := normalizeOpenAIWSIngressMode(mode); normalized != "" {
if normalized == OpenAIWSIngressModeShared || normalized == OpenAIWSIngressModeDedicated {
return OpenAIWSIngressModeCtxPool
}
return normalized
}
return OpenAIWSIngressModeCtxPool
}
// ResolveOpenAIResponsesWebSocketV2Mode 返回账号在 WSv2 ingress 下的有效模式(off/ctx_pool/passthrough)。
//
// 优先级:
// 1. 分类型 mode 新字段(string
// 2. 分类型 enabled 旧字段(bool
// 3. 兼容 enabled 旧字段(bool
// 4. defaultMode(非法时回退 ctx_pool
func (a *Account) ResolveOpenAIResponsesWebSocketV2Mode(defaultMode string) string {
resolvedDefault := normalizeOpenAIWSIngressDefaultMode(defaultMode)
if a == nil || !a.IsOpenAI() {
return OpenAIWSIngressModeOff
}
if a.Extra == nil {
return resolvedDefault
}
resolveModeString := func(key string) (string, bool) {
raw, ok := a.Extra[key]
if !ok {
return "", false
}
mode, ok := raw.(string)
if !ok {
return "", false
}
normalized := normalizeOpenAIWSIngressMode(mode)
if normalized == "" {
return "", false
}
return normalized, true
}
resolveBoolMode := func(key string) (string, bool) {
raw, ok := a.Extra[key]
if !ok {
return "", false
}
enabled, ok := raw.(bool)
if !ok {
return "", false
}
if enabled {
return OpenAIWSIngressModeCtxPool, true
}
return OpenAIWSIngressModeOff, true
}
if a.IsOpenAIOAuth() {
if mode, ok := resolveModeString("openai_oauth_responses_websockets_v2_mode"); ok {
return mode
}
if mode, ok := resolveBoolMode("openai_oauth_responses_websockets_v2_enabled"); ok {
return mode
}
}
if a.IsOpenAIApiKey() {
if mode, ok := resolveModeString("openai_apikey_responses_websockets_v2_mode"); ok {
return mode
}
if mode, ok := resolveBoolMode("openai_apikey_responses_websockets_v2_enabled"); ok {
return mode
}
}
if mode, ok := resolveBoolMode("responses_websockets_v2_enabled"); ok {
return mode
}
if mode, ok := resolveBoolMode("openai_ws_enabled"); ok {
return mode
}
// 兼容旧值:shared/dedicated 语义都归并到 ctx_pool。
if resolvedDefault == OpenAIWSIngressModeShared || resolvedDefault == OpenAIWSIngressModeDedicated {
return OpenAIWSIngressModeCtxPool
}
return resolvedDefault
}
// IsOpenAIWSForceHTTPEnabled 返回账号级"强制 HTTP"开关。
// 字段:accounts.extra.openai_ws_force_http。
func (a *Account) IsOpenAIWSForceHTTPEnabled() bool {
if a == nil || !a.IsOpenAI() || a.Extra == nil {
return false
}
enabled, ok := a.Extra["openai_ws_force_http"].(bool)
return ok && enabled
}
// IsOpenAIResponsesFlattenNamespacesEnabled 返回账号级"摊平 Codex namespace 工具"开关。
// 字段:accounts.extra.openai_responses_flatten_namespaces,缺省 false(原样保留)。
//
// namespace 是 Codex 后端定义的私有扩展,OAuth 出口恒为 chatgpt.com/backend-api/codex
// buildUpstreamRequest 只对 API Key 账号取 base_url),即定义方本身,因此默认保留。
// 该开关只为把流量转发到不认识 namespace 的兼容上游的部署保留退路:打开后恢复
// 0.1.166 及更早版本的摊平行为。仅对 OpenAI OAuth 账号有效——API Key 走 chat
// completions 回退桥时由桥自行摊平,Grok/Anthropic 出口有各自的适配链路。
func (a *Account) IsOpenAIResponsesFlattenNamespacesEnabled() bool {
if a == nil || !a.IsOpenAI() || a.Extra == nil {
return false
}
enabled, ok := a.Extra["openai_responses_flatten_namespaces"].(bool)
return ok && enabled
}
// IsOpenAIWSAllowStoreRecoveryEnabled 返回账号级 store 恢复开关。
// 字段:accounts.extra.openai_ws_allow_store_recovery。
func (a *Account) IsOpenAIWSAllowStoreRecoveryEnabled() bool {
if a == nil || !a.IsOpenAI() || a.Extra == nil {
return false
}
enabled, ok := a.Extra["openai_ws_allow_store_recovery"].(bool)
return ok && enabled
}
// IsOpenAIOAuthPassthroughEnabled 兼容旧接口,等价于 OAuth 账号的 IsOpenAIPassthroughEnabled。
func (a *Account) IsOpenAIOAuthPassthroughEnabled() bool {
return a != nil && a.IsOpenAIOAuth() && a.IsOpenAIPassthroughEnabled()
}
// IsAnthropicAPIKeyPassthroughEnabled 返回 Anthropic API Key 账号是否启用"自动透传(仅替换认证)"。
// 字段:accounts.extra.anthropic_passthrough。
// 字段缺失或类型不正确时,按 false(关闭)处理。
func (a *Account) IsAnthropicAPIKeyPassthroughEnabled() bool {
if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeAPIKey || a.Extra == nil {
return false
}
enabled, ok := a.Extra["anthropic_passthrough"].(bool)
return ok && enabled
}
// WebSearch 模拟三态常量
const (
WebSearchModeDefault = "default" // 跟随渠道配置
WebSearchModeEnabled = "enabled" // 强制开启
WebSearchModeDisabled = "disabled" // 强制关闭
)
// GetWebSearchEmulationMode 返回账号的 WebSearch 模拟模式。
// 三态:default(跟随渠道)/ enabled(强制开启)/ disabled(强制关闭)。
// 兼容旧 bool 值:true→enabled, false→default(并记录 debug 日志)。
func (a *Account) GetWebSearchEmulationMode() string {
if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeAPIKey || a.Extra == nil {
return WebSearchModeDefault
}
raw := a.Extra[featureKeyWebSearchEmulation]
// Tolerant: legacy bool values (pre-migration or stale writes)
if b, ok := raw.(bool); ok {
slog.Debug("legacy bool web_search_emulation value", "account_id", a.ID, "value", b)
if b {
return WebSearchModeEnabled
}
return WebSearchModeDefault
}
mode, ok := raw.(string)
if !ok {
return WebSearchModeDefault
}
switch mode {
case WebSearchModeEnabled, WebSearchModeDisabled:
return mode
default:
return WebSearchModeDefault
}
}
// IsCodexCLIOnlyEnabled 返回 OpenAI OAuth 账号是否启用"仅允许 Codex 官方客户端"。
// 字段:accounts.extra.codex_cli_only。
// 字段缺失或类型不正确时,按 false(关闭)处理。
func (a *Account) IsCodexCLIOnlyEnabled() bool {
if a == nil || !a.IsOpenAIOAuth() || a.Extra == nil {
return false
}
enabled, ok := a.Extra["codex_cli_only"].(bool)
return ok && enabled
}
// IsCodexCLIOnlyAppServerAllowed 返回 codex_cli_only 账号是否额外放行 Codex app-server
// 第三方客户端(运行时与全局 app_server 开关 OR)。字段:accounts.extra.codex_cli_only_allow_app_server。
// 仅在 codex_cli_only 已启用时有意义;字段缺失或类型不符按 false(不放行)处理。
func (a *Account) IsCodexCLIOnlyAppServerAllowed() bool {
if !a.IsCodexCLIOnlyEnabled() {
return false
}
v, ok := a.Extra["codex_cli_only_allow_app_server"].(bool)
return ok && v
}
// WindowCostSchedulability 窗口费用调度状态
type WindowCostSchedulability int
const (
// WindowCostSchedulable 可正常调度
WindowCostSchedulable WindowCostSchedulability = iota
// WindowCostStickyOnly 仅允许粘性会话
WindowCostStickyOnly
// WindowCostNotSchedulable 完全不可调度
WindowCostNotSchedulable
)
// IsAnthropicOAuthOrSetupToken 判断是否为 Anthropic OAuth 或 SetupToken 类型账号
// 仅这两类账号支持 5h 窗口额度控制和会话数量控制
func (a *Account) IsAnthropicOAuthOrSetupToken() bool {
return a.Platform == PlatformAnthropic && (a.Type == AccountTypeOAuth || a.Type == AccountTypeSetupToken)
}
// IsTLSFingerprintEnabled 检查是否启用 TLS 指纹伪装
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
// 启用后将模拟 Claude Code (Node.js) 客户端的 TLS 握手特征
func (a *Account) IsTLSFingerprintEnabled() bool {
// 仅支持 Anthropic OAuth/SetupToken 账号
if !a.IsAnthropicOAuthOrSetupToken() {
return false
}
if a.Extra == nil {
return false
}
if v, ok := a.Extra["enable_tls_fingerprint"]; ok {
if enabled, ok := v.(bool); ok {
return enabled
}
}
return false
}
// GetTLSFingerprintProfileID 获取账号绑定的 TLS 指纹模板 ID
// 返回 0 表示未绑定(使用内置默认 profile)
func (a *Account) GetTLSFingerprintProfileID() int64 {
if a.Extra == nil {
return 0
}
v, ok := a.Extra["tls_fingerprint_profile_id"]
if !ok {
return 0
}
switch id := v.(type) {
case float64:
return int64(id)
case int64:
return id
case int:
return int64(id)
case json.Number:
if i, err := id.Int64(); err == nil {
return i
}
}
return 0
}
// GetUserMsgQueueMode 获取用户消息队列模式
// "serialize" = 串行队列, "throttle" = 软性限速, "" = 未设置(使用全局配置)
func (a *Account) GetUserMsgQueueMode() string {
if a.Extra == nil {
return ""
}
// 优先读取新字段 user_msg_queue_mode(白名单校验,非法值视为未设置)
if mode, ok := a.Extra["user_msg_queue_mode"].(string); ok && mode != "" {
if mode == config.UMQModeSerialize || mode == config.UMQModeThrottle {
return mode
}
return "" // 非法值 fallback 到全局配置
}
// 向后兼容: user_msg_queue_enabled: true → "serialize"
if enabled, ok := a.Extra["user_msg_queue_enabled"].(bool); ok && enabled {
return config.UMQModeSerialize
}
return ""
}
// IsSessionIDMaskingEnabled 检查是否启用会话ID伪装
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
// 启用后将在一段时间内(15分钟)固定 metadata.user_id 中的 session ID
// 使上游认为请求来自同一个会话
func (a *Account) IsSessionIDMaskingEnabled() bool {
if !a.IsAnthropicOAuthOrSetupToken() {
return false
}
if a.Extra == nil {
return false
}
if v, ok := a.Extra["session_id_masking_enabled"]; ok {
if enabled, ok := v.(bool); ok {
return enabled
}
}
return false
}
// IsCustomBaseURLEnabled 检查是否启用自定义 base URL 中继转发
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
func (a *Account) IsCustomBaseURLEnabled() bool {
if !a.IsAnthropicOAuthOrSetupToken() {
return false
}
if a.Extra == nil {
return false
}
if v, ok := a.Extra["custom_base_url_enabled"]; ok {
if enabled, ok := v.(bool); ok {
return enabled
}
}
return false
}
// GetCustomBaseURL 返回自定义中继服务的 base URL
func (a *Account) GetCustomBaseURL() string {
return a.GetExtraString("custom_base_url")
}
// IsCacheTTLOverrideEnabled 检查是否启用缓存 TTL 强制替换
// 仅适用于 Anthropic OAuth/SetupToken 类型账号
// 启用后将所有 cache creation tokens 归入指定的 TTL 类型(5m 或 1h)
func (a *Account) IsCacheTTLOverrideEnabled() bool {
if !a.IsAnthropicOAuthOrSetupToken() {
return false
}
if a.Extra == nil {
return false
}
if v, ok := a.Extra["cache_ttl_override_enabled"]; ok {
if enabled, ok := v.(bool); ok {
return enabled
}
}
return false
}
// GetCacheTTLOverrideTarget 获取缓存 TTL 强制替换的目标类型
// 返回 "5m" 或 "1h",默认 "5m"
func (a *Account) GetCacheTTLOverrideTarget() string {
if a.Extra == nil {
return "5m"
}
if v, ok := a.Extra["cache_ttl_override_target"]; ok {
if target, ok := v.(string); ok && (target == "5m" || target == "1h") {
return target
}
}
return "5m"
}
// GetQuotaLimit 获取 API Key 账号的配额限制(美元)
// 返回 0 表示未启用
func (a *Account) GetQuotaLimit() float64 {
return a.getExtraFloat64("quota_limit")
}
// GetQuotaUsed 获取 API Key 账号的已用配额(美元)
func (a *Account) GetQuotaUsed() float64 {
return a.getExtraFloat64("quota_used")
}
// GetQuotaDailyLimit 获取日额度限制(美元),0 表示未启用
func (a *Account) GetQuotaDailyLimit() float64 {
return a.getExtraFloat64("quota_daily_limit")
}
// GetQuotaDailyUsed 获取当日已用额度(美元)
func (a *Account) GetQuotaDailyUsed() float64 {
return a.getExtraFloat64("quota_daily_used")
}
// GetQuotaWeeklyLimit 获取周额度限制(美元),0 表示未启用
func (a *Account) GetQuotaWeeklyLimit() float64 {
return a.getExtraFloat64("quota_weekly_limit")
}
// GetQuotaWeeklyUsed 获取本周已用额度(美元)
func (a *Account) GetQuotaWeeklyUsed() float64 {
return a.getExtraFloat64("quota_weekly_used")
}
// getExtraFloat64 从 Extra 中读取指定 key 的 float64 值
func (a *Account) getExtraFloat64(key string) float64 {
if a.Extra == nil {
return 0
}
if v, ok := a.Extra[key]; ok {
return parseExtraFloat64(v)
}
return 0
}
// getExtraTime 从 Extra 中读取 RFC3339 时间戳
func (a *Account) getExtraTime(key string) time.Time {
if a.Extra == nil {
return time.Time{}
}
if v, ok := a.Extra[key]; ok {
if s, ok := v.(string); ok {
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t
}
}
}
return time.Time{}
}
// getExtraBool 从 Extra 中读取指定 key 的 bool 值
func (a *Account) getExtraBool(key string) bool {
if a.Extra == nil {
return false
}
if v, ok := a.Extra[key]; ok {
if b, ok := v.(bool); ok {
return b
}
}
return false
}
// getExtraString 从 Extra 中读取指定 key 的字符串值
func (a *Account) getExtraString(key string) string {
if a.Extra == nil {
return ""
}
if v, ok := a.Extra[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// getExtraStringDefault 从 Extra 中读取指定 key 的字符串值,不存在时返回 defaultVal
func (a *Account) getExtraStringDefault(key, defaultVal string) string {
if v := a.getExtraString(key); v != "" {
return v
}
return defaultVal
}
// getExtraInt 从 Extra 中读取指定 key 的 int 值
func (a *Account) getExtraInt(key string) int {
if a.Extra == nil {
return 0
}
if v, ok := a.Extra[key]; ok {
return int(parseExtraFloat64(v))
}
return 0
}
// GetQuotaDailyResetMode 获取日额度重置模式:"rolling"(默认)或 "fixed"
func (a *Account) GetQuotaDailyResetMode() string {
if m := a.getExtraString("quota_daily_reset_mode"); m == "fixed" {
return "fixed"
}
return "rolling"
}
// GetQuotaDailyResetHour 获取固定重置的小时(0-23),默认 0
func (a *Account) GetQuotaDailyResetHour() int {
return a.getExtraInt("quota_daily_reset_hour")
}
// GetQuotaWeeklyResetMode 获取周额度重置模式:"rolling"(默认)或 "fixed"
func (a *Account) GetQuotaWeeklyResetMode() string {
if m := a.getExtraString("quota_weekly_reset_mode"); m == "fixed" {
return "fixed"
}
return "rolling"
}
// GetQuotaWeeklyResetDay 获取固定重置的星期几(0=周日, 1=周一, ..., 6=周六),默认 1(周一)
func (a *Account) GetQuotaWeeklyResetDay() int {
if a.Extra == nil {
return 1
}
if _, ok := a.Extra["quota_weekly_reset_day"]; !ok {
return 1
}
return a.getExtraInt("quota_weekly_reset_day")
}
// GetQuotaWeeklyResetHour 获取周配额固定重置的小时(0-23),默认 0
func (a *Account) GetQuotaWeeklyResetHour() int {
return a.getExtraInt("quota_weekly_reset_hour")
}
// GetQuotaResetTimezone 获取固定重置的时区名(IANA),默认 "UTC"
func (a *Account) GetQuotaResetTimezone() string {
if tz := a.getExtraString("quota_reset_timezone"); tz != "" {
return tz
}
return "UTC"
}
// --- Quota Notification Getters ---
// QuotaNotifyConfig returns the notify configuration for a given quota dimension.
// dim must be one of quotaDimDaily, quotaDimWeekly, quotaDimTotal.
func (a *Account) QuotaNotifyConfig(dim string) (enabled bool, threshold float64, thresholdType string) {
enabled = a.getExtraBool("quota_notify_" + dim + "_enabled")
threshold = a.getExtraFloat64("quota_notify_" + dim + "_threshold")
thresholdType = a.getExtraStringDefault("quota_notify_"+dim+"_threshold_type", thresholdTypeFixed)
return
}
func (a *Account) GetQuotaNotifyDailyEnabled() bool {
e, _, _ := a.QuotaNotifyConfig(quotaDimDaily)
return e
}
func (a *Account) GetQuotaNotifyDailyThreshold() float64 {
_, t, _ := a.QuotaNotifyConfig(quotaDimDaily)
return t
}
func (a *Account) GetQuotaNotifyDailyThresholdType() string {
_, _, tt := a.QuotaNotifyConfig(quotaDimDaily)
return tt
}
func (a *Account) GetQuotaNotifyWeeklyEnabled() bool {
e, _, _ := a.QuotaNotifyConfig(quotaDimWeekly)
return e
}
func (a *Account) GetQuotaNotifyWeeklyThreshold() float64 {
_, t, _ := a.QuotaNotifyConfig(quotaDimWeekly)
return t
}
func (a *Account) GetQuotaNotifyWeeklyThresholdType() string {
_, _, tt := a.QuotaNotifyConfig(quotaDimWeekly)
return tt
}
func (a *Account) GetQuotaNotifyTotalEnabled() bool {
e, _, _ := a.QuotaNotifyConfig(quotaDimTotal)
return e
}
func (a *Account) GetQuotaNotifyTotalThreshold() float64 {
_, t, _ := a.QuotaNotifyConfig(quotaDimTotal)
return t
}
func (a *Account) GetQuotaNotifyTotalThresholdType() string {
_, _, tt := a.QuotaNotifyConfig(quotaDimTotal)
return tt
}
// nextFixedDailyReset 计算在 after 之后的下一个每日固定重置时间点
func nextFixedDailyReset(hour int, tz *time.Location, after time.Time) time.Time {
t := after.In(tz)
today := time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, tz)
if !after.Before(today) {
return today.AddDate(0, 0, 1)
}
return today
}
// lastFixedDailyReset 计算 now 之前最近一次的每日固定重置时间点
func lastFixedDailyReset(hour int, tz *time.Location, now time.Time) time.Time {
t := now.In(tz)
today := time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, tz)
if now.Before(today) {
return today.AddDate(0, 0, -1)
}
return today
}
// nextFixedWeeklyReset 计算在 after 之后的下一个每周固定重置时间点
// day: 0=Sunday, 1=Monday, ..., 6=Saturday
func nextFixedWeeklyReset(day, hour int, tz *time.Location, after time.Time) time.Time {
t := after.In(tz)
todayReset := time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, tz)
currentDay := int(todayReset.Weekday())
daysForward := (day - currentDay + 7) % 7
if daysForward == 0 && !after.Before(todayReset) {
daysForward = 7
}
return todayReset.AddDate(0, 0, daysForward)
}
// lastFixedWeeklyReset 计算 now 之前最近一次的每周固定重置时间点
func lastFixedWeeklyReset(day, hour int, tz *time.Location, now time.Time) time.Time {
t := now.In(tz)
todayReset := time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, tz)
currentDay := int(todayReset.Weekday())
daysBack := (currentDay - day + 7) % 7
if daysBack == 0 && now.Before(todayReset) {
daysBack = 7
}
return todayReset.AddDate(0, 0, -daysBack)
}
// isFixedDailyPeriodExpired 检查日配额是否在固定时间模式下已过期
func (a *Account) isFixedDailyPeriodExpired(periodStart time.Time) bool {
if periodStart.IsZero() {
return true
}
tz, err := time.LoadLocation(a.GetQuotaResetTimezone())
if err != nil {
tz = time.UTC
}
lastReset := lastFixedDailyReset(a.GetQuotaDailyResetHour(), tz, time.Now())
return periodStart.Before(lastReset)
}
// isFixedWeeklyPeriodExpired 检查周配额是否在固定时间模式下已过期
func (a *Account) isFixedWeeklyPeriodExpired(periodStart time.Time) bool {
if periodStart.IsZero() {
return true
}
tz, err := time.LoadLocation(a.GetQuotaResetTimezone())
if err != nil {
tz = time.UTC
}
lastReset := lastFixedWeeklyReset(a.GetQuotaWeeklyResetDay(), a.GetQuotaWeeklyResetHour(), tz, time.Now())
return periodStart.Before(lastReset)
}
// ComputeQuotaResetAt 根据当前配置计算并填充 extra 中的 quota_daily_reset_at / quota_weekly_reset_at
// 在保存账号配置时调用
func ComputeQuotaResetAt(extra map[string]any) {
now := time.Now()
tzName, _ := extra["quota_reset_timezone"].(string)
if tzName == "" {
tzName = "UTC"
}
tz, err := time.LoadLocation(tzName)
if err != nil {
tz = time.UTC
}
// 日配额固定重置时间
if mode, _ := extra["quota_daily_reset_mode"].(string); mode == "fixed" {
hour := int(parseExtraFloat64(extra["quota_daily_reset_hour"]))
if hour < 0 || hour > 23 {
hour = 0
}
resetAt := nextFixedDailyReset(hour, tz, now)
extra["quota_daily_reset_at"] = resetAt.UTC().Format(time.RFC3339)
} else {
delete(extra, "quota_daily_reset_at")
}
// 周配额固定重置时间
if mode, _ := extra["quota_weekly_reset_mode"].(string); mode == "fixed" {
day := 1 // 默认周一
if d, ok := extra["quota_weekly_reset_day"]; ok {
day = int(parseExtraFloat64(d))
}
if day < 0 || day > 6 {
day = 1
}
hour := int(parseExtraFloat64(extra["quota_weekly_reset_hour"]))
if hour < 0 || hour > 23 {
hour = 0
}
resetAt := nextFixedWeeklyReset(day, hour, tz, now)
extra["quota_weekly_reset_at"] = resetAt.UTC().Format(time.RFC3339)
} else {
delete(extra, "quota_weekly_reset_at")
}
}
// NormalizeFixedQuotaWindows aligns preserved quota usage with the active fixed reset window.
//
// Editing an existing account can switch a daily/weekly quota from rolling to fixed reset
// while preserving quota_*_used and quota_*_start. If the preserved start belongs to the
// old rolling window, response mapping treats the usage as expired and the dashboard shows
// 0 until the next reset. Normalize those stale starts before persisting the edited account.
func NormalizeFixedQuotaWindows(extra map[string]any) {
if extra == nil {
return
}
now := time.Now()
tzName, _ := extra["quota_reset_timezone"].(string)
if tzName == "" {
tzName = "UTC"
}
tz, err := time.LoadLocation(tzName)
if err != nil {
tz = time.UTC
}
if mode, _ := extra["quota_daily_reset_mode"].(string); mode == "fixed" && parseExtraFloat64(extra["quota_daily_limit"]) > 0 {
hour := int(parseExtraFloat64(extra["quota_daily_reset_hour"]))
if hour < 0 || hour > 23 {
hour = 0
}
lastReset := lastFixedDailyReset(hour, tz, now)
start := parseExtraTime(extra["quota_daily_start"])
if start.IsZero() || start.Before(lastReset) {
extra["quota_daily_used"] = 0.0
extra["quota_daily_start"] = lastReset.UTC().Format(time.RFC3339)
}
}
if mode, _ := extra["quota_weekly_reset_mode"].(string); mode == "fixed" && parseExtraFloat64(extra["quota_weekly_limit"]) > 0 {
day := 1
if rawDay, ok := extra["quota_weekly_reset_day"]; ok {
day = int(parseExtraFloat64(rawDay))
}
if day < 0 || day > 6 {
day = 1
}
hour := int(parseExtraFloat64(extra["quota_weekly_reset_hour"]))
if hour < 0 || hour > 23 {
hour = 0
}
lastReset := lastFixedWeeklyReset(day, hour, tz, now)
start := parseExtraTime(extra["quota_weekly_start"])
if start.IsZero() || start.Before(lastReset) {
extra["quota_weekly_used"] = 0.0
extra["quota_weekly_start"] = lastReset.UTC().Format(time.RFC3339)
}
}
}
// ValidateQuotaResetConfig 校验配额固定重置时间配置的合法性
func ValidateQuotaResetConfig(extra map[string]any) error {
if extra == nil {
return nil
}
// 校验时区
if tz, ok := extra["quota_reset_timezone"].(string); ok && tz != "" {
if _, err := time.LoadLocation(tz); err != nil {
return errors.New("invalid quota_reset_timezone: must be a valid IANA timezone name")
}
}
// 日配额重置模式
if mode, ok := extra["quota_daily_reset_mode"].(string); ok {
if mode != "rolling" && mode != "fixed" {
return errors.New("quota_daily_reset_mode must be 'rolling' or 'fixed'")
}
}
// 日配额重置小时
if v, ok := extra["quota_daily_reset_hour"]; ok {
hour := int(parseExtraFloat64(v))
if hour < 0 || hour > 23 {
return errors.New("quota_daily_reset_hour must be between 0 and 23")
}
}
// 周配额重置模式
if mode, ok := extra["quota_weekly_reset_mode"].(string); ok {
if mode != "rolling" && mode != "fixed" {
return errors.New("quota_weekly_reset_mode must be 'rolling' or 'fixed'")
}
}
// 周配额重置星期几
if v, ok := extra["quota_weekly_reset_day"]; ok {
day := int(parseExtraFloat64(v))
if day < 0 || day > 6 {
return errors.New("quota_weekly_reset_day must be between 0 (Sunday) and 6 (Saturday)")
}
}
// 周配额重置小时
if v, ok := extra["quota_weekly_reset_hour"]; ok {
hour := int(parseExtraFloat64(v))
if hour < 0 || hour > 23 {
return errors.New("quota_weekly_reset_hour must be between 0 and 23")
}
}
return nil
}
// HasAnyQuotaLimit 检查是否配置了任一维度的配额限制
func (a *Account) HasAnyQuotaLimit() bool {
return a.GetQuotaLimit() > 0 || a.GetQuotaDailyLimit() > 0 || a.GetQuotaWeeklyLimit() > 0
}
// isPeriodExpired 检查指定周期(自 periodStart 起经过 dur)是否已过期
func isPeriodExpired(periodStart time.Time, dur time.Duration) bool {
if periodStart.IsZero() {
return true // 从未使用过,视为过期(下次 increment 会初始化)
}
return time.Since(periodStart) >= dur
}
// IsDailyQuotaPeriodExpired 检查日配额周期是否已过期(用于显示层判断是否需要将 used 归零)
func (a *Account) IsDailyQuotaPeriodExpired() bool {
start := a.getExtraTime("quota_daily_start")
if a.GetQuotaDailyResetMode() == "fixed" {
return a.isFixedDailyPeriodExpired(start)
}
return isPeriodExpired(start, 24*time.Hour)
}
// IsWeeklyQuotaPeriodExpired 检查周配额周期是否已过期(用于显示层判断是否需要将 used 归零)
func (a *Account) IsWeeklyQuotaPeriodExpired() bool {
start := a.getExtraTime("quota_weekly_start")
if a.GetQuotaWeeklyResetMode() == "fixed" {
return a.isFixedWeeklyPeriodExpired(start)
}
return isPeriodExpired(start, 7*24*time.Hour)
}
// IsQuotaExceeded 检查 API Key 账号配额是否已超限(任一维度超限即返回 true)
func (a *Account) IsQuotaExceeded() bool {
// 总额度
if limit := a.GetQuotaLimit(); limit > 0 && a.GetQuotaUsed() >= limit {
return true
}
// 日额度(周期过期视为未超限,下次 increment 会重置)
if limit := a.GetQuotaDailyLimit(); limit > 0 {
start := a.getExtraTime("quota_daily_start")
var expired bool
if a.GetQuotaDailyResetMode() == "fixed" {
expired = a.isFixedDailyPeriodExpired(start)
} else {
expired = isPeriodExpired(start, 24*time.Hour)
}
if !expired && a.GetQuotaDailyUsed() >= limit {
return true
}
}
// 周额度
if limit := a.GetQuotaWeeklyLimit(); limit > 0 {
start := a.getExtraTime("quota_weekly_start")
var expired bool
if a.GetQuotaWeeklyResetMode() == "fixed" {
expired = a.isFixedWeeklyPeriodExpired(start)
} else {
expired = isPeriodExpired(start, 7*24*time.Hour)
}
if !expired && a.GetQuotaWeeklyUsed() >= limit {
return true
}
}
return false
}
// GetWindowCostLimit 获取 5h 窗口费用阈值(美元)
// 返回 0 表示未启用
func (a *Account) GetWindowCostLimit() float64 {
if a.Extra == nil {
return 0
}
if v, ok := a.Extra["window_cost_limit"]; ok {
return parseExtraFloat64(v)
}
return 0
}
// GetWindowCostStickyReserve 获取粘性会话预留额度(美元)
// 默认值为 10
func (a *Account) GetWindowCostStickyReserve() float64 {
if a.Extra == nil {
return 10.0
}
if v, ok := a.Extra["window_cost_sticky_reserve"]; ok {
val := parseExtraFloat64(v)
if val > 0 {
return val
}
}
return 10.0
}
// GetMaxSessions 获取最大并发会话数
// 返回 0 表示未启用
func (a *Account) GetMaxSessions() int {
if a.Extra == nil {
return 0
}
if v, ok := a.Extra["max_sessions"]; ok {
return parseExtraInt(v)
}
return 0
}
// GetSessionIdleTimeoutMinutes 获取会话空闲超时分钟数
// 默认值为 5 分钟
func (a *Account) GetSessionIdleTimeoutMinutes() int {
if a.Extra == nil {
return 5
}
if v, ok := a.Extra["session_idle_timeout_minutes"]; ok {
val := parseExtraInt(v)
if val > 0 {
return val
}
}
return 5
}
// GetBaseRPM 获取基础 RPM 限制
// 返回 0 表示未启用(负数视为无效配置,按 0 处理)
func (a *Account) GetBaseRPM() int {
if a.Extra == nil {
return 0
}
if v, ok := a.Extra["base_rpm"]; ok {
val := parseExtraInt(v)
if val > 0 {
return val
}
}
return 0
}
// GetRPMStrategy 获取 RPM 策略
// "tiered" = 三区模型(默认), "sticky_exempt" = 粘性豁免
func (a *Account) GetRPMStrategy() string {
if a.Extra == nil {
return "tiered"
}
if v, ok := a.Extra["rpm_strategy"]; ok {
if s, ok := v.(string); ok && s == "sticky_exempt" {
return "sticky_exempt"
}
}
return "tiered"
}
// GetRPMStickyBuffer 获取 RPM 粘性缓冲数量
// Cache-driven: buffer = concurrency + maxSessions(覆盖幽灵窗口 + 稳态会话需求)
// floor = baseRPM / 5(向后兼容 maxSessions=0 且 concurrency=0 场景)
func (a *Account) GetRPMStickyBuffer() int {
if a.Extra == nil {
return 0
}
// 手动 override 最高优先级
if v, ok := a.Extra["rpm_sticky_buffer"]; ok {
val := parseExtraInt(v)
if val > 0 {
return val
}
}
base := a.GetBaseRPM()
if base <= 0 {
return 0
}
// Cache-driven buffer = concurrency + maxSessions
conc := a.Concurrency
if conc < 0 {
conc = 0
}
sess := a.GetMaxSessions()
if sess < 0 {
sess = 0
}
buffer := conc + sess
// floor: 向后兼容
floor := base / 5
if floor < 1 {
floor = 1
}
if buffer < floor {
buffer = floor
}
return buffer
}
// CheckRPMSchedulability 根据当前 RPM 计数检查调度状态
// 复用 WindowCostSchedulability 三态:Schedulable / StickyOnly / NotSchedulable
func (a *Account) CheckRPMSchedulability(currentRPM int) WindowCostSchedulability {
baseRPM := a.GetBaseRPM()
if baseRPM <= 0 {
return WindowCostSchedulable
}
if currentRPM < baseRPM {
return WindowCostSchedulable
}
strategy := a.GetRPMStrategy()
if strategy == "sticky_exempt" {
return WindowCostStickyOnly // 粘性豁免无红区
}
// tiered: 黄区 + 红区
buffer := a.GetRPMStickyBuffer()
if currentRPM < baseRPM+buffer {
return WindowCostStickyOnly
}
return WindowCostNotSchedulable
}
// CheckWindowCostSchedulability 根据当前窗口费用检查调度状态
// - 费用 < 阈值: WindowCostSchedulable(可正常调度)
// - 费用 >= 阈值 且 < 阈值+预留: WindowCostStickyOnly(仅粘性会话)
// - 费用 >= 阈值+预留: WindowCostNotSchedulable(不可调度)
func (a *Account) CheckWindowCostSchedulability(currentWindowCost float64) WindowCostSchedulability {
limit := a.GetWindowCostLimit()
if limit <= 0 {
return WindowCostSchedulable
}
if currentWindowCost < limit {
return WindowCostSchedulable
}
stickyReserve := a.GetWindowCostStickyReserve()
if currentWindowCost < limit+stickyReserve {
return WindowCostStickyOnly
}
return WindowCostNotSchedulable
}
// GetCurrentWindowStartTime 获取当前有效的窗口开始时间
// 逻辑:
// 1. 如果窗口未过期(SessionWindowEnd 存在且在当前时间之后),使用记录的 SessionWindowStart
// 2. 否则(窗口过期或未设置),使用新的预测窗口开始时间(从当前整点开始)
func (a *Account) GetCurrentWindowStartTime() time.Time {
now := time.Now()
// 窗口未过期,使用记录的窗口开始时间
if a.SessionWindowStart != nil && a.SessionWindowEnd != nil && now.Before(*a.SessionWindowEnd) {
return *a.SessionWindowStart
}
// 窗口已过期或未设置,预测新的窗口开始时间(从当前整点开始)
// 与 ratelimit_service.go 中 UpdateSessionWindow 的预测逻辑保持一致
return time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
}
// parseExtraFloat64 从 extra 字段解析 float64 值
func parseExtraFloat64(value any) float64 {
switch v := value.(type) {
case float64:
return v
case float32:
return float64(v)
case int:
return float64(v)
case int64:
return float64(v)
case json.Number:
if f, err := v.Float64(); err == nil {
return f
}
case string:
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
return f
}
}
return 0
}
func parseExtraTime(value any) time.Time {
if s, ok := value.(string); ok {
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t
}
}
return time.Time{}
}
// parseExtraInt 从 extra 字段解析 int 值
// ParseExtraInt 从 extra 字段的 any 值解析为 int。
// 支持 int, int64, float64, json.Number, string 类型,无法解析时返回 0。
func ParseExtraInt(value any) int {
return parseExtraInt(value)
}
func parseExtraInt(value any) int {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case json.Number:
if i, err := v.Int64(); err == nil {
return int(i)
}
case string:
if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
return i
}
}
return 0
}
// IsShadow 报告账号是否为影子账号(parent_account_id 非空;当前唯一预设是 spark 维度)。
func (a *Account) IsShadow() bool { return a != nil && a.ParentAccountID != nil }
// IsCredentialShadow 语义别名,供「凭据消费者跳过影子」处使用(管理/后台 OAuth 路径)。
func (a *Account) IsCredentialShadow() bool { return a.IsShadow() }
// QuotaDimensionOrDefault 返回账号的用量维度,未设置时回退 "global"。
func (a *Account) QuotaDimensionOrDefault() string {
if a == nil || strings.TrimSpace(a.QuotaDimension) == "" {
return QuotaDimensionGlobal
}
return a.QuotaDimension
}