diff --git a/Makefile b/Makefile index b0bc1b99f6..86cdaa1711 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ .PHONY: build build-backend build-frontend test test-backend test-frontend test-frontend-critical FRONTEND_CRITICAL_VITEST := \ + src/i18n/__tests__/localeKeyCompleteness.spec.ts \ src/api/__tests__/client.spec.ts \ src/api/__tests__/tokenRefresh.spec.ts \ src/api/__tests__/channelMonitorV2.spec.ts \ diff --git a/README.md b/README.md index 62ab427214..4ae1590ab2 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,16 @@ Please read the following carefully before using this project: Thanks to AxisNow for sponsoring this project! AxisNow protects and accelerates websites and APIs, delivering an optimal access experience across mainland China and globally, while extending acceleration and security capabilities to native/mobile apps through client SDKs — self-hosted private-deployment CDN | subscription-based DDoS-protected CDN | autonomous, flexibly composable CDN network. + +PP.dog +PP.dog is a source-level API gateway with its own massive account pool, purpose-built as an upstream relay for downstream transit stations and high-frequency developers — saving you the hassle of building and maintaining your own account pool. ✅ Direct from source: self-operated account pool with no middleman markup; 🧧 Cost crusher: combined rate multiplier as low as 0.03x, just 0.35% of official pricing; 🚀 Blazing fast: first-token latency < 1s, on par with the official native API. Get started with PP.dog now + + + +ColaProxy +ColaProxy provides high-quality residential proxies built for web scraping, automation, and multi-account management. Get a free trial with non-expiring traffic, pricing starting as low as $0.3/GB, unlimited concurrent connections, and intelligent IP rotation for a smoother, more stable proxy experience. Use discount code COLA10 to get 10% off and start scaling your projects with reliable residential proxies today. Get started with ColaProxy + + ## Overview @@ -816,7 +826,7 @@ xAI quota is passive. Sub2API does not invent subscription quota values; it reco `401` responses temporarily remove accounts with invalid credentials from scheduling. `403` responses are treated as access or entitlement failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling. -New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts require positive paid-entitlement evidence from the xAI billing probe; Free, forbidden, missing, malformed, and inconclusive billing observations are excluded from new media generation. Unobserved OAuth accounts are probed before the first media request is forwarded, and imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`. +New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts with explicit Free or forbidden billing evidence are excluded from new media generation. Missing or malformed observations are probed before dispatch; a successful but incomplete billing response is treated as `billing_inconclusive` and remains eligible for backwards compatibility, because an unknown billing schema is not proof that the account lacks media entitlement. Operators can quarantine a known-bad account with `extra.grok_media_eligible=false` or force-enable a verified account with `true`. Imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`. Administrators can override automatic media eligibility through the account create/update API by setting `extra.grok_media_eligible` to `false` (exclude) or `true` (force eligible). On update, set it to `null` to remove the override and return to automatic probe-based behavior; omitting the field preserves the current override. A weekly allowance period alone is not treated as a paid tier signal. Successful image responses must contain at least one actual image output; empty HTTP `200` responses trigger account failover instead of being counted and returned as successful generations. diff --git a/README_CN.md b/README_CN.md index 04e1e83b8b..c1734bcb84 100644 --- a/README_CN.md +++ b/README_CN.md @@ -169,6 +169,16 @@ 感谢 AxisNow 赞助了本项目!AxisNow 保护并加速网站与 API,兼顾中国大陆及全球的访问体验,并通过客户端 SDK,将加速与安全能力延伸至原生/移动 App — 自建私有部署 CDN|订阅式高防 CDN|自主可控、灵活组合的 CDN 网络。 + +PP.dog +PP.dog 是自建账号池的源头 API 网关,专注为下游中转站与高频开发者提供 API 网关中继服务,帮您省去自建号池的一切麻烦——✅ 源头直供:自持海量账号池,无中间商赚差价;🧧 成本屠夫:综合倍率低至 0.03x,成本仅为官方的千分之3.5;🚀 极速体验:首 Token 延迟 < 1s,流畅媲美官方原生 API。立即接入PP.dog + + + +ColaProxy +ColaProxy 提供专为网页抓取、自动化和多账号管理打造的高质量住宅代理。免费试用,流量永不过期,价格低至 $0.3/GB,支持无限并发连接和智能 IP 轮换,带来更流畅、更稳定的代理体验。使用优惠码 COLA10 立享 9 折优惠,立即开始使用可靠的住宅代理扩展您的项目。立即开始使用 ColaProxy + + ## 项目概述 diff --git a/README_JA.md b/README_JA.md index 6282f58b31..91ec052653 100644 --- a/README_JA.md +++ b/README_JA.md @@ -168,6 +168,16 @@ AxisNow のご支援に感謝します!AxisNow はウェブサイトと API を保護・高速化し、中国本土および世界中で快適なアクセス体験を実現します。クライアント SDK により、ネイティブ/モバイルアプリにも高速化・セキュリティ機能を拡張できます — 自社構築・プライベートデプロイ CDN|サブスクリプション型高防御 CDN|自律制御で柔軟に組み合わせられる CDN ネットワーク。 + +PP.dog +PP.dog は自社運用の大規模アカウントプールを持つソースレベルの API ゲートウェイで、下流の中継ステーションや高頻度開発者向けに API ゲートウェイリレーサービスを提供し、アカウントプールの自主構築にかかるすべての手間を省きます。✅ ソース直供:自社運用の大規模アカウントプール、中間マージンなし;🧧 コスト最強:総合レート倍率わずか 0.03x、コストは公式の 0.35% のみ;🚀 超高速体験:ファーストトークンレイテンシ 1 秒未満、公式ネイティブ API に匹敵するスムーズさ。今すぐ PP.dog に接続 + + + +ColaProxy +ColaProxy は、Web スクレイピング、自動化、マルチアカウント管理向けに構築された高品質な住宅プロキシを提供します。有効期限なしのトラフィックで無料トライアルが利用でき、料金は $0.3/GB から、無制限の同時接続とインテリジェントな IP ローテーションにより、よりスムーズで安定したプロキシ体験を実現します。割引コード COLA10 で 10% オフ、信頼性の高い住宅プロキシでプロジェクトを今すぐスケールしましょう。ColaProxy を始める + + ## 概要 diff --git a/assets/partners/logos/cola-proxy.jpg b/assets/partners/logos/cola-proxy.jpg new file mode 100644 index 0000000000..52139c344b Binary files /dev/null and b/assets/partners/logos/cola-proxy.jpg differ diff --git a/assets/partners/logos/ppdog.png b/assets/partners/logos/ppdog.png new file mode 100644 index 0000000000..d222ff96da Binary files /dev/null and b/assets/partners/logos/ppdog.png differ diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 0ea3a944b3..7179039691 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.2.0 +0.2.3 diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 5ec4892e16..b68a06ac54 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -137,7 +137,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient) tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache) channelRepository := repository.NewChannelRepository(db) - channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService) + channelCachePubSub := repository.NewChannelCache(redisClient) + channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService, channelCachePubSub) modelPricingResolver := service.NewModelPricingResolver(channelService, billingService) compositeModelRouteRepository := repository.NewCompositeModelRouteRepository(client) compositeRouteResolver := service.NewCompositeRouteResolver(compositeModelRouteRepository) @@ -189,10 +190,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache) proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig) proxyLatencyCache := repository.NewProxyLatencyCache(redisClient) - adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService) + adminService := service.NewAdminService(configConfig, userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService) adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService, settingService) groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache) - groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService) + groupHandler := admin.NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, configConfig) claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream) antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig) grokQuotaFetcher := service.NewGrokQuotaFetcher() @@ -205,12 +206,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo) accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig) - accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService) + accountHandler := admin.ProvideAccountHandler(configConfig, adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService) adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService) dataManagementService := service.NewDataManagementService() dataManagementHandler := admin.NewDataManagementHandler(dataManagementService) backupObjectStoreFactory := repository.NewS3BackupStoreFactory() - dbDumper := repository.NewPgDumper(configConfig) + dbDumper := repository.NewPgDumper(configConfig, db) backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper, leaderLockCache, db) imageStorageFactory := repository.ProvideImageStorageFactory() imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig) diff --git a/backend/ent/channelmonitor/channelmonitor.go b/backend/ent/channelmonitor/channelmonitor.go index 3224c6eb04..f7dcac62cc 100644 --- a/backend/ent/channelmonitor/channelmonitor.go +++ b/backend/ent/channelmonitor/channelmonitor.go @@ -182,6 +182,7 @@ const ( ProviderKimi Provider = "kimi" ProviderZhipu Provider = "zhipu" ProviderDeepseek Provider = "deepseek" + ProviderMinimax Provider = "minimax" ) func (pr Provider) String() string { @@ -191,7 +192,7 @@ func (pr Provider) String() string { // ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save. func ProviderValidator(pr Provider) error { switch pr { - case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek: + case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax: return nil default: return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr) diff --git a/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go b/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go index f0e9dbf16e..2913cf8da9 100644 --- a/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go +++ b/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go @@ -108,6 +108,7 @@ const ( ProviderKimi Provider = "kimi" ProviderZhipu Provider = "zhipu" ProviderDeepseek Provider = "deepseek" + ProviderMinimax Provider = "minimax" ) func (pr Provider) String() string { @@ -117,7 +118,7 @@ func (pr Provider) String() string { // ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save. func ProviderValidator(pr Provider) error { switch pr { - case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek: + case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax: return nil default: return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr) diff --git a/backend/ent/client.go b/backend/ent/client.go index 7cdb7394fd..0e8efec58e 100644 --- a/backend/ent/client.go +++ b/backend/ent/client.go @@ -4595,6 +4595,22 @@ func (c *ProxyClient) QueryAccounts(_m *Proxy) *AccountQuery { return query } +// QueryPrimaryProxies queries the primary_proxies edge of a Proxy. +func (c *ProxyClient) QueryPrimaryProxies(_m *Proxy) *ProxyQuery { + query := (&ProxyClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(proxy.Table, proxy.FieldID, id), + sqlgraph.To(proxy.Table, proxy.FieldID), + sqlgraph.Edge(sqlgraph.O2M, true, proxy.PrimaryProxiesTable, proxy.PrimaryProxiesColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + // QueryBackupProxy queries the backup_proxy edge of a Proxy. func (c *ProxyClient) QueryBackupProxy(_m *Proxy) *ProxyQuery { query := (&ProxyClient{config: c.config}).Query() @@ -4603,7 +4619,7 @@ func (c *ProxyClient) QueryBackupProxy(_m *Proxy) *ProxyQuery { step := sqlgraph.NewStep( sqlgraph.From(proxy.Table, proxy.FieldID, id), sqlgraph.To(proxy.Table, proxy.FieldID), - sqlgraph.Edge(sqlgraph.O2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn), + sqlgraph.Edge(sqlgraph.M2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn), ) fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) return fromV, nil diff --git a/backend/ent/group.go b/backend/ent/group.go index bb49f43820..5510c5aa1a 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -134,15 +134,17 @@ type Group struct { DefaultMappedModel string `json:"default_mapped_model,omitempty"` // OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型 MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"` - // 自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度 - ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config,omitempty"` + // 分组模型白名单:同时约束模型列表接口与请求准入 + ModelAllowlist domain.GroupModelAllowlist `json:"model_allowlist,omitempty"` + // 固定账号获取 Codex Model Manifest 配置;开启后 /models 请求只用选定账号拉取(仅 openai 平台) + CodexModelsManifestConfig domain.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config,omitempty"` // 分组 RPM 上限,0 表示不限制;设置后接管该分组用户的限流 RpmLimit int `json:"rpm_limit,omitempty"` // OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"` // 超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问 MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"` - // OpenAI reasoning effort 自定义精确映射;先映射再应用上限 + // OpenAI reasoning effort 自定义映射;可按模型精确名、前缀或后缀限定,先映射再应用上限 ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings,omitempty"` // 是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池 ProfitControlEnabled bool `json:"profit_control_enabled,omitempty"` @@ -256,7 +258,7 @@ func (*Group) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) for i := range columns { switch columns[i] { - case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings: + case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelAllowlist, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings: values[i] = new([]byte) case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled: values[i] = new(sql.NullBool) @@ -660,12 +662,20 @@ func (_m *Group) assignValues(columns []string, values []any) error { return fmt.Errorf("unmarshal field messages_dispatch_model_config: %w", err) } } - case group.FieldModelsListConfig: + case group.FieldModelAllowlist: if value, ok := values[i].(*[]byte); !ok { - return fmt.Errorf("unexpected type %T for field models_list_config", values[i]) + return fmt.Errorf("unexpected type %T for field model_allowlist", values[i]) } else if value != nil && len(*value) > 0 { - if err := json.Unmarshal(*value, &_m.ModelsListConfig); err != nil { - return fmt.Errorf("unmarshal field models_list_config: %w", err) + if err := json.Unmarshal(*value, &_m.ModelAllowlist); err != nil { + return fmt.Errorf("unmarshal field model_allowlist: %w", err) + } + } + case group.FieldCodexModelsManifestConfig: + if value, ok := values[i].(*[]byte); !ok { + return fmt.Errorf("unexpected type %T for field codex_models_manifest_config", values[i]) + } else if value != nil && len(*value) > 0 { + if err := json.Unmarshal(*value, &_m.CodexModelsManifestConfig); err != nil { + return fmt.Errorf("unmarshal field codex_models_manifest_config: %w", err) } } case group.FieldRpmLimit: @@ -997,8 +1007,11 @@ func (_m *Group) String() string { builder.WriteString("messages_dispatch_model_config=") builder.WriteString(fmt.Sprintf("%v", _m.MessagesDispatchModelConfig)) builder.WriteString(", ") - builder.WriteString("models_list_config=") - builder.WriteString(fmt.Sprintf("%v", _m.ModelsListConfig)) + builder.WriteString("model_allowlist=") + builder.WriteString(fmt.Sprintf("%v", _m.ModelAllowlist)) + builder.WriteString(", ") + builder.WriteString("codex_models_manifest_config=") + builder.WriteString(fmt.Sprintf("%v", _m.CodexModelsManifestConfig)) builder.WriteString(", ") builder.WriteString("rpm_limit=") builder.WriteString(fmt.Sprintf("%v", _m.RpmLimit)) diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 2b634acee5..66dbdc412d 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -130,8 +130,10 @@ const ( FieldDefaultMappedModel = "default_mapped_model" // FieldMessagesDispatchModelConfig holds the string denoting the messages_dispatch_model_config field in the database. FieldMessagesDispatchModelConfig = "messages_dispatch_model_config" - // FieldModelsListConfig holds the string denoting the models_list_config field in the database. - FieldModelsListConfig = "models_list_config" + // FieldModelAllowlist holds the string denoting the model_allowlist field in the database. + FieldModelAllowlist = "model_allowlist" + // FieldCodexModelsManifestConfig holds the string denoting the codex_models_manifest_config field in the database. + FieldCodexModelsManifestConfig = "codex_models_manifest_config" // FieldRpmLimit holds the string denoting the rpm_limit field in the database. FieldRpmLimit = "rpm_limit" // FieldMaxReasoningEffort holds the string denoting the max_reasoning_effort field in the database. @@ -278,7 +280,8 @@ var Columns = []string{ FieldRequirePrivacySet, FieldDefaultMappedModel, FieldMessagesDispatchModelConfig, - FieldModelsListConfig, + FieldModelAllowlist, + FieldCodexModelsManifestConfig, FieldRpmLimit, FieldMaxReasoningEffort, FieldMaxReasoningEffortOverLimit, @@ -409,8 +412,10 @@ var ( DefaultMappedModelValidator func(string) error // DefaultMessagesDispatchModelConfig holds the default value on creation for the "messages_dispatch_model_config" field. DefaultMessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig - // DefaultModelsListConfig holds the default value on creation for the "models_list_config" field. - DefaultModelsListConfig domain.GroupModelsListConfig + // DefaultModelAllowlist holds the default value on creation for the "model_allowlist" field. + DefaultModelAllowlist domain.GroupModelAllowlist + // DefaultCodexModelsManifestConfig holds the default value on creation for the "codex_models_manifest_config" field. + DefaultCodexModelsManifestConfig domain.GroupCodexModelsManifestConfig // DefaultRpmLimit holds the default value on creation for the "rpm_limit" field. DefaultRpmLimit int // DefaultMaxReasoningEffort holds the default value on creation for the "max_reasoning_effort" field. diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 75b6ed1151..7f4c650708 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -788,16 +788,30 @@ func (_c *GroupCreate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMe return _c } -// SetModelsListConfig sets the "models_list_config" field. -func (_c *GroupCreate) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupCreate { - _c.mutation.SetModelsListConfig(v) +// SetModelAllowlist sets the "model_allowlist" field. +func (_c *GroupCreate) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupCreate { + _c.mutation.SetModelAllowlist(v) return _c } -// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil. -func (_c *GroupCreate) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupCreate { +// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil. +func (_c *GroupCreate) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupCreate { if v != nil { - _c.SetModelsListConfig(*v) + _c.SetModelAllowlist(*v) + } + return _c +} + +// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field. +func (_c *GroupCreate) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupCreate { + _c.mutation.SetCodexModelsManifestConfig(v) + return _c +} + +// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil. +func (_c *GroupCreate) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupCreate { + if v != nil { + _c.SetCodexModelsManifestConfig(*v) } return _c } @@ -1161,9 +1175,13 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultMessagesDispatchModelConfig _c.mutation.SetMessagesDispatchModelConfig(v) } - if _, ok := _c.mutation.ModelsListConfig(); !ok { - v := group.DefaultModelsListConfig - _c.mutation.SetModelsListConfig(v) + if _, ok := _c.mutation.ModelAllowlist(); !ok { + v := group.DefaultModelAllowlist + _c.mutation.SetModelAllowlist(v) + } + if _, ok := _c.mutation.CodexModelsManifestConfig(); !ok { + v := group.DefaultCodexModelsManifestConfig + _c.mutation.SetCodexModelsManifestConfig(v) } if _, ok := _c.mutation.RpmLimit(); !ok { v := group.DefaultRpmLimit @@ -1363,8 +1381,11 @@ func (_c *GroupCreate) check() error { if _, ok := _c.mutation.MessagesDispatchModelConfig(); !ok { return &ValidationError{Name: "messages_dispatch_model_config", err: errors.New(`ent: missing required field "Group.messages_dispatch_model_config"`)} } - if _, ok := _c.mutation.ModelsListConfig(); !ok { - return &ValidationError{Name: "models_list_config", err: errors.New(`ent: missing required field "Group.models_list_config"`)} + if _, ok := _c.mutation.ModelAllowlist(); !ok { + return &ValidationError{Name: "model_allowlist", err: errors.New(`ent: missing required field "Group.model_allowlist"`)} + } + if _, ok := _c.mutation.CodexModelsManifestConfig(); !ok { + return &ValidationError{Name: "codex_models_manifest_config", err: errors.New(`ent: missing required field "Group.codex_models_manifest_config"`)} } if _, ok := _c.mutation.RpmLimit(); !ok { return &ValidationError{Name: "rpm_limit", err: errors.New(`ent: missing required field "Group.rpm_limit"`)} @@ -1652,9 +1673,13 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value) _node.MessagesDispatchModelConfig = value } - if value, ok := _c.mutation.ModelsListConfig(); ok { - _spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value) - _node.ModelsListConfig = value + if value, ok := _c.mutation.ModelAllowlist(); ok { + _spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value) + _node.ModelAllowlist = value + } + if value, ok := _c.mutation.CodexModelsManifestConfig(); ok { + _spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value) + _node.CodexModelsManifestConfig = value } if value, ok := _c.mutation.RpmLimit(); ok { _spec.SetField(group.FieldRpmLimit, field.TypeInt, value) @@ -2770,15 +2795,27 @@ func (u *GroupUpsert) UpdateMessagesDispatchModelConfig() *GroupUpsert { return u } -// SetModelsListConfig sets the "models_list_config" field. -func (u *GroupUpsert) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsert { - u.Set(group.FieldModelsListConfig, v) +// SetModelAllowlist sets the "model_allowlist" field. +func (u *GroupUpsert) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsert { + u.Set(group.FieldModelAllowlist, v) return u } -// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create. -func (u *GroupUpsert) UpdateModelsListConfig() *GroupUpsert { - u.SetExcluded(group.FieldModelsListConfig) +// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create. +func (u *GroupUpsert) UpdateModelAllowlist() *GroupUpsert { + u.SetExcluded(group.FieldModelAllowlist) + return u +} + +// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field. +func (u *GroupUpsert) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsert { + u.Set(group.FieldCodexModelsManifestConfig, v) + return u +} + +// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create. +func (u *GroupUpsert) UpdateCodexModelsManifestConfig() *GroupUpsert { + u.SetExcluded(group.FieldCodexModelsManifestConfig) return u } @@ -4017,17 +4054,31 @@ func (u *GroupUpsertOne) UpdateMessagesDispatchModelConfig() *GroupUpsertOne { }) } -// SetModelsListConfig sets the "models_list_config" field. -func (u *GroupUpsertOne) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsertOne { +// SetModelAllowlist sets the "model_allowlist" field. +func (u *GroupUpsertOne) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { - s.SetModelsListConfig(v) + s.SetModelAllowlist(v) }) } -// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create. -func (u *GroupUpsertOne) UpdateModelsListConfig() *GroupUpsertOne { +// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateModelAllowlist() *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { - s.UpdateModelsListConfig() + s.UpdateModelAllowlist() + }) +} + +// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field. +func (u *GroupUpsertOne) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetCodexModelsManifestConfig(v) + }) +} + +// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateCodexModelsManifestConfig() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateCodexModelsManifestConfig() }) } @@ -5449,17 +5500,31 @@ func (u *GroupUpsertBulk) UpdateMessagesDispatchModelConfig() *GroupUpsertBulk { }) } -// SetModelsListConfig sets the "models_list_config" field. -func (u *GroupUpsertBulk) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsertBulk { +// SetModelAllowlist sets the "model_allowlist" field. +func (u *GroupUpsertBulk) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { - s.SetModelsListConfig(v) + s.SetModelAllowlist(v) }) } -// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create. -func (u *GroupUpsertBulk) UpdateModelsListConfig() *GroupUpsertBulk { +// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateModelAllowlist() *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { - s.UpdateModelsListConfig() + s.UpdateModelAllowlist() + }) +} + +// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field. +func (u *GroupUpsertBulk) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetCodexModelsManifestConfig(v) + }) +} + +// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateCodexModelsManifestConfig() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateCodexModelsManifestConfig() }) } diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index fac039fa95..091466452f 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -1073,16 +1073,30 @@ func (_u *GroupUpdate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMe return _u } -// SetModelsListConfig sets the "models_list_config" field. -func (_u *GroupUpdate) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpdate { - _u.mutation.SetModelsListConfig(v) +// SetModelAllowlist sets the "model_allowlist" field. +func (_u *GroupUpdate) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpdate { + _u.mutation.SetModelAllowlist(v) return _u } -// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil. -func (_u *GroupUpdate) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupUpdate { +// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupUpdate { if v != nil { - _u.SetModelsListConfig(*v) + _u.SetModelAllowlist(*v) + } + return _u +} + +// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field. +func (_u *GroupUpdate) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpdate { + _u.mutation.SetCodexModelsManifestConfig(v) + return _u +} + +// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupUpdate { + if v != nil { + _u.SetCodexModelsManifestConfig(*v) } return _u } @@ -1862,8 +1876,11 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok { _spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value) } - if value, ok := _u.mutation.ModelsListConfig(); ok { - _spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value) + if value, ok := _u.mutation.ModelAllowlist(); ok { + _spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value) + } + if value, ok := _u.mutation.CodexModelsManifestConfig(); ok { + _spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value) } if value, ok := _u.mutation.RpmLimit(); ok { _spec.SetField(group.FieldRpmLimit, field.TypeInt, value) @@ -3250,16 +3267,30 @@ func (_u *GroupUpdateOne) SetNillableMessagesDispatchModelConfig(v *domain.OpenA return _u } -// SetModelsListConfig sets the "models_list_config" field. -func (_u *GroupUpdateOne) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpdateOne { - _u.mutation.SetModelsListConfig(v) +// SetModelAllowlist sets the "model_allowlist" field. +func (_u *GroupUpdateOne) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpdateOne { + _u.mutation.SetModelAllowlist(v) return _u } -// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil. -func (_u *GroupUpdateOne) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupUpdateOne { +// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupUpdateOne { if v != nil { - _u.SetModelsListConfig(*v) + _u.SetModelAllowlist(*v) + } + return _u +} + +// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field. +func (_u *GroupUpdateOne) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpdateOne { + _u.mutation.SetCodexModelsManifestConfig(v) + return _u +} + +// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupUpdateOne { + if v != nil { + _u.SetCodexModelsManifestConfig(*v) } return _u } @@ -4069,8 +4100,11 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok { _spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value) } - if value, ok := _u.mutation.ModelsListConfig(); ok { - _spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value) + if value, ok := _u.mutation.ModelAllowlist(); ok { + _spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value) + } + if value, ok := _u.mutation.CodexModelsManifestConfig(); ok { + _spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value) } if value, ok := _u.mutation.RpmLimit(); ok { _spec.SetField(group.FieldRpmLimit, field.TypeInt, value) diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 59aba5be05..6167725598 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -623,7 +623,7 @@ var ( {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "name", Type: field.TypeString, Size: 100}, - {Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}}, + {Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax"}}, {Name: "check_mode", Type: field.TypeString, Size: 32, Default: "probe"}, {Name: "account_id", Type: field.TypeInt64, Nullable: true}, {Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"}, @@ -776,7 +776,7 @@ var ( {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "name", Type: field.TypeString, Size: 100}, - {Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}}, + {Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax"}}, {Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"}, {Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""}, {Name: "extra_headers", Type: field.TypeJSON}, @@ -960,7 +960,8 @@ var ( {Name: "require_privacy_set", Type: field.TypeBool, Default: false}, {Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""}, {Name: "messages_dispatch_model_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, - {Name: "models_list_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, + {Name: "model_allowlist", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, + {Name: "codex_models_manifest_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}}, {Name: "rpm_limit", Type: field.TypeInt, Default: 0}, {Name: "max_reasoning_effort", Type: field.TypeString, Size: 20, Default: ""}, {Name: "max_reasoning_effort_over_limit", Type: field.TypeString, Size: 20, Default: "downgrade"}, @@ -1412,7 +1413,7 @@ var ( {Name: "expires_at", Type: field.TypeTime, Nullable: true}, {Name: "fallback_mode", Type: field.TypeString, Size: 20, Default: "none"}, {Name: "expiry_warn_days", Type: field.TypeInt, Default: 7}, - {Name: "backup_proxy_id", Type: field.TypeInt64, Unique: true, Nullable: true}, + {Name: "backup_proxy_id", Type: field.TypeInt64, Nullable: true}, } // ProxiesTable holds the schema information for the "proxies" table. ProxiesTable = &schema.Table{ diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 2e166a6a84..3367fab567 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -22164,7 +22164,8 @@ type GroupMutation struct { require_privacy_set *bool default_mapped_model *string messages_dispatch_model_config *domain.OpenAIMessagesDispatchModelConfig - models_list_config *domain.GroupModelsListConfig + model_allowlist *domain.GroupModelAllowlist + codex_models_manifest_config *domain.GroupCodexModelsManifestConfig rpm_limit *int addrpm_limit *int max_reasoning_effort *string @@ -25163,40 +25164,76 @@ func (m *GroupMutation) ResetMessagesDispatchModelConfig() { m.messages_dispatch_model_config = nil } -// SetModelsListConfig sets the "models_list_config" field. -func (m *GroupMutation) SetModelsListConfig(dmlc domain.GroupModelsListConfig) { - m.models_list_config = &dmlc +// SetModelAllowlist sets the "model_allowlist" field. +func (m *GroupMutation) SetModelAllowlist(dma domain.GroupModelAllowlist) { + m.model_allowlist = &dma } -// ModelsListConfig returns the value of the "models_list_config" field in the mutation. -func (m *GroupMutation) ModelsListConfig() (r domain.GroupModelsListConfig, exists bool) { - v := m.models_list_config +// ModelAllowlist returns the value of the "model_allowlist" field in the mutation. +func (m *GroupMutation) ModelAllowlist() (r domain.GroupModelAllowlist, exists bool) { + v := m.model_allowlist if v == nil { return } return *v, true } -// OldModelsListConfig returns the old "models_list_config" field's value of the Group entity. +// OldModelAllowlist returns the old "model_allowlist" field's value of the Group entity. // If the Group object wasn't provided to the builder, the object is fetched from the database. // An error is returned if the mutation operation is not UpdateOne, or the database query fails. -func (m *GroupMutation) OldModelsListConfig(ctx context.Context) (v domain.GroupModelsListConfig, err error) { +func (m *GroupMutation) OldModelAllowlist(ctx context.Context) (v domain.GroupModelAllowlist, err error) { if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldModelsListConfig is only allowed on UpdateOne operations") + return v, errors.New("OldModelAllowlist is only allowed on UpdateOne operations") } if m.id == nil || m.oldValue == nil { - return v, errors.New("OldModelsListConfig requires an ID field in the mutation") + return v, errors.New("OldModelAllowlist requires an ID field in the mutation") } oldValue, err := m.oldValue(ctx) if err != nil { - return v, fmt.Errorf("querying old value for OldModelsListConfig: %w", err) + return v, fmt.Errorf("querying old value for OldModelAllowlist: %w", err) } - return oldValue.ModelsListConfig, nil + return oldValue.ModelAllowlist, nil } -// ResetModelsListConfig resets all changes to the "models_list_config" field. -func (m *GroupMutation) ResetModelsListConfig() { - m.models_list_config = nil +// ResetModelAllowlist resets all changes to the "model_allowlist" field. +func (m *GroupMutation) ResetModelAllowlist() { + m.model_allowlist = nil +} + +// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field. +func (m *GroupMutation) SetCodexModelsManifestConfig(dcmmc domain.GroupCodexModelsManifestConfig) { + m.codex_models_manifest_config = &dcmmc +} + +// CodexModelsManifestConfig returns the value of the "codex_models_manifest_config" field in the mutation. +func (m *GroupMutation) CodexModelsManifestConfig() (r domain.GroupCodexModelsManifestConfig, exists bool) { + v := m.codex_models_manifest_config + if v == nil { + return + } + return *v, true +} + +// OldCodexModelsManifestConfig returns the old "codex_models_manifest_config" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldCodexModelsManifestConfig(ctx context.Context) (v domain.GroupCodexModelsManifestConfig, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCodexModelsManifestConfig is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCodexModelsManifestConfig requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCodexModelsManifestConfig: %w", err) + } + return oldValue.CodexModelsManifestConfig, nil +} + +// ResetCodexModelsManifestConfig resets all changes to the "codex_models_manifest_config" field. +func (m *GroupMutation) ResetCodexModelsManifestConfig() { + m.codex_models_manifest_config = nil } // SetRpmLimit sets the "rpm_limit" field. @@ -25884,7 +25921,7 @@ func (m *GroupMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GroupMutation) Fields() []string { - fields := make([]string, 0, 65) + fields := make([]string, 0, 66) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -26056,8 +26093,11 @@ func (m *GroupMutation) Fields() []string { if m.messages_dispatch_model_config != nil { fields = append(fields, group.FieldMessagesDispatchModelConfig) } - if m.models_list_config != nil { - fields = append(fields, group.FieldModelsListConfig) + if m.model_allowlist != nil { + fields = append(fields, group.FieldModelAllowlist) + } + if m.codex_models_manifest_config != nil { + fields = append(fields, group.FieldCodexModelsManifestConfig) } if m.rpm_limit != nil { fields = append(fields, group.FieldRpmLimit) @@ -26202,8 +26242,10 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.DefaultMappedModel() case group.FieldMessagesDispatchModelConfig: return m.MessagesDispatchModelConfig() - case group.FieldModelsListConfig: - return m.ModelsListConfig() + case group.FieldModelAllowlist: + return m.ModelAllowlist() + case group.FieldCodexModelsManifestConfig: + return m.CodexModelsManifestConfig() case group.FieldRpmLimit: return m.RpmLimit() case group.FieldMaxReasoningEffort: @@ -26341,8 +26383,10 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldDefaultMappedModel(ctx) case group.FieldMessagesDispatchModelConfig: return m.OldMessagesDispatchModelConfig(ctx) - case group.FieldModelsListConfig: - return m.OldModelsListConfig(ctx) + case group.FieldModelAllowlist: + return m.OldModelAllowlist(ctx) + case group.FieldCodexModelsManifestConfig: + return m.OldCodexModelsManifestConfig(ctx) case group.FieldRpmLimit: return m.OldRpmLimit(ctx) case group.FieldMaxReasoningEffort: @@ -26765,12 +26809,19 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetMessagesDispatchModelConfig(v) return nil - case group.FieldModelsListConfig: - v, ok := value.(domain.GroupModelsListConfig) + case group.FieldModelAllowlist: + v, ok := value.(domain.GroupModelAllowlist) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } - m.SetModelsListConfig(v) + m.SetModelAllowlist(v) + return nil + case group.FieldCodexModelsManifestConfig: + v, ok := value.(domain.GroupCodexModelsManifestConfig) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCodexModelsManifestConfig(v) return nil case group.FieldRpmLimit: v, ok := value.(int) @@ -27503,8 +27554,11 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldMessagesDispatchModelConfig: m.ResetMessagesDispatchModelConfig() return nil - case group.FieldModelsListConfig: - m.ResetModelsListConfig() + case group.FieldModelAllowlist: + m.ResetModelAllowlist() + return nil + case group.FieldCodexModelsManifestConfig: + m.ResetCodexModelsManifestConfig() return nil case group.FieldRpmLimit: m.ResetRpmLimit() @@ -37312,33 +37366,36 @@ func (m *PromoCodeUsageMutation) ResetEdge(name string) error { // ProxyMutation represents an operation that mutates the Proxy nodes in the graph. type ProxyMutation struct { config - op Op - typ string - id *int64 - created_at *time.Time - updated_at *time.Time - deleted_at *time.Time - name *string - protocol *string - host *string - port *int - addport *int - username *string - password *string - status *string - expires_at *time.Time - fallback_mode *string - expiry_warn_days *int - addexpiry_warn_days *int - clearedFields map[string]struct{} - accounts map[int64]struct{} - removedaccounts map[int64]struct{} - clearedaccounts bool - backup_proxy *int64 - clearedbackup_proxy bool - done bool - oldValue func(context.Context) (*Proxy, error) - predicates []predicate.Proxy + op Op + typ string + id *int64 + created_at *time.Time + updated_at *time.Time + deleted_at *time.Time + name *string + protocol *string + host *string + port *int + addport *int + username *string + password *string + status *string + expires_at *time.Time + fallback_mode *string + expiry_warn_days *int + addexpiry_warn_days *int + clearedFields map[string]struct{} + accounts map[int64]struct{} + removedaccounts map[int64]struct{} + clearedaccounts bool + primary_proxies map[int64]struct{} + removedprimary_proxies map[int64]struct{} + clearedprimary_proxies bool + backup_proxy *int64 + clearedbackup_proxy bool + done bool + oldValue func(context.Context) (*Proxy, error) + predicates []predicate.Proxy } var _ ent.Mutation = (*ProxyMutation)(nil) @@ -38102,6 +38159,60 @@ func (m *ProxyMutation) ResetAccounts() { m.removedaccounts = nil } +// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by ids. +func (m *ProxyMutation) AddPrimaryProxyIDs(ids ...int64) { + if m.primary_proxies == nil { + m.primary_proxies = make(map[int64]struct{}) + } + for i := range ids { + m.primary_proxies[ids[i]] = struct{}{} + } +} + +// ClearPrimaryProxies clears the "primary_proxies" edge to the Proxy entity. +func (m *ProxyMutation) ClearPrimaryProxies() { + m.clearedprimary_proxies = true +} + +// PrimaryProxiesCleared reports if the "primary_proxies" edge to the Proxy entity was cleared. +func (m *ProxyMutation) PrimaryProxiesCleared() bool { + return m.clearedprimary_proxies +} + +// RemovePrimaryProxyIDs removes the "primary_proxies" edge to the Proxy entity by IDs. +func (m *ProxyMutation) RemovePrimaryProxyIDs(ids ...int64) { + if m.removedprimary_proxies == nil { + m.removedprimary_proxies = make(map[int64]struct{}) + } + for i := range ids { + delete(m.primary_proxies, ids[i]) + m.removedprimary_proxies[ids[i]] = struct{}{} + } +} + +// RemovedPrimaryProxies returns the removed IDs of the "primary_proxies" edge to the Proxy entity. +func (m *ProxyMutation) RemovedPrimaryProxiesIDs() (ids []int64) { + for id := range m.removedprimary_proxies { + ids = append(ids, id) + } + return +} + +// PrimaryProxiesIDs returns the "primary_proxies" edge IDs in the mutation. +func (m *ProxyMutation) PrimaryProxiesIDs() (ids []int64) { + for id := range m.primary_proxies { + ids = append(ids, id) + } + return +} + +// ResetPrimaryProxies resets all changes to the "primary_proxies" edge. +func (m *ProxyMutation) ResetPrimaryProxies() { + m.primary_proxies = nil + m.clearedprimary_proxies = false + m.removedprimary_proxies = nil +} + // ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity. func (m *ProxyMutation) ClearBackupProxy() { m.clearedbackup_proxy = true @@ -38543,10 +38654,13 @@ func (m *ProxyMutation) ResetField(name string) error { // AddedEdges returns all edge names that were set/added in this mutation. func (m *ProxyMutation) AddedEdges() []string { - edges := make([]string, 0, 2) + edges := make([]string, 0, 3) if m.accounts != nil { edges = append(edges, proxy.EdgeAccounts) } + if m.primary_proxies != nil { + edges = append(edges, proxy.EdgePrimaryProxies) + } if m.backup_proxy != nil { edges = append(edges, proxy.EdgeBackupProxy) } @@ -38563,6 +38677,12 @@ func (m *ProxyMutation) AddedIDs(name string) []ent.Value { ids = append(ids, id) } return ids + case proxy.EdgePrimaryProxies: + ids := make([]ent.Value, 0, len(m.primary_proxies)) + for id := range m.primary_proxies { + ids = append(ids, id) + } + return ids case proxy.EdgeBackupProxy: if id := m.backup_proxy; id != nil { return []ent.Value{*id} @@ -38573,10 +38693,13 @@ func (m *ProxyMutation) AddedIDs(name string) []ent.Value { // RemovedEdges returns all edge names that were removed in this mutation. func (m *ProxyMutation) RemovedEdges() []string { - edges := make([]string, 0, 2) + edges := make([]string, 0, 3) if m.removedaccounts != nil { edges = append(edges, proxy.EdgeAccounts) } + if m.removedprimary_proxies != nil { + edges = append(edges, proxy.EdgePrimaryProxies) + } return edges } @@ -38590,16 +38713,25 @@ func (m *ProxyMutation) RemovedIDs(name string) []ent.Value { ids = append(ids, id) } return ids + case proxy.EdgePrimaryProxies: + ids := make([]ent.Value, 0, len(m.removedprimary_proxies)) + for id := range m.removedprimary_proxies { + ids = append(ids, id) + } + return ids } return nil } // ClearedEdges returns all edge names that were cleared in this mutation. func (m *ProxyMutation) ClearedEdges() []string { - edges := make([]string, 0, 2) + edges := make([]string, 0, 3) if m.clearedaccounts { edges = append(edges, proxy.EdgeAccounts) } + if m.clearedprimary_proxies { + edges = append(edges, proxy.EdgePrimaryProxies) + } if m.clearedbackup_proxy { edges = append(edges, proxy.EdgeBackupProxy) } @@ -38612,6 +38744,8 @@ func (m *ProxyMutation) EdgeCleared(name string) bool { switch name { case proxy.EdgeAccounts: return m.clearedaccounts + case proxy.EdgePrimaryProxies: + return m.clearedprimary_proxies case proxy.EdgeBackupProxy: return m.clearedbackup_proxy } @@ -38636,6 +38770,9 @@ func (m *ProxyMutation) ResetEdge(name string) error { case proxy.EdgeAccounts: m.ResetAccounts() return nil + case proxy.EdgePrimaryProxies: + m.ResetPrimaryProxies() + return nil case proxy.EdgeBackupProxy: m.ResetBackupProxy() return nil diff --git a/backend/ent/proxy.go b/backend/ent/proxy.go index 3dc4491bd1..cc0f3f656b 100644 --- a/backend/ent/proxy.go +++ b/backend/ent/proxy.go @@ -55,11 +55,13 @@ type Proxy struct { type ProxyEdges struct { // Accounts holds the value of the accounts edge. Accounts []*Account `json:"accounts,omitempty"` + // PrimaryProxies holds the value of the primary_proxies edge. + PrimaryProxies []*Proxy `json:"primary_proxies,omitempty"` // BackupProxy holds the value of the backup_proxy edge. BackupProxy *Proxy `json:"backup_proxy,omitempty"` // loadedTypes holds the information for reporting if a // type was loaded (or requested) in eager-loading or not. - loadedTypes [2]bool + loadedTypes [3]bool } // AccountsOrErr returns the Accounts value or an error if the edge @@ -71,12 +73,21 @@ func (e ProxyEdges) AccountsOrErr() ([]*Account, error) { return nil, &NotLoadedError{edge: "accounts"} } +// PrimaryProxiesOrErr returns the PrimaryProxies value or an error if the edge +// was not loaded in eager-loading. +func (e ProxyEdges) PrimaryProxiesOrErr() ([]*Proxy, error) { + if e.loadedTypes[1] { + return e.PrimaryProxies, nil + } + return nil, &NotLoadedError{edge: "primary_proxies"} +} + // BackupProxyOrErr returns the BackupProxy value or an error if the edge // was not loaded in eager-loading, or loaded but was not found. func (e ProxyEdges) BackupProxyOrErr() (*Proxy, error) { if e.BackupProxy != nil { return e.BackupProxy, nil - } else if e.loadedTypes[1] { + } else if e.loadedTypes[2] { return nil, &NotFoundError{label: proxy.Label} } return nil, &NotLoadedError{edge: "backup_proxy"} @@ -221,6 +232,11 @@ func (_m *Proxy) QueryAccounts() *AccountQuery { return NewProxyClient(_m.config).QueryAccounts(_m) } +// QueryPrimaryProxies queries the "primary_proxies" edge of the Proxy entity. +func (_m *Proxy) QueryPrimaryProxies() *ProxyQuery { + return NewProxyClient(_m.config).QueryPrimaryProxies(_m) +} + // QueryBackupProxy queries the "backup_proxy" edge of the Proxy entity. func (_m *Proxy) QueryBackupProxy() *ProxyQuery { return NewProxyClient(_m.config).QueryBackupProxy(_m) diff --git a/backend/ent/proxy/proxy.go b/backend/ent/proxy/proxy.go index afd45e3af5..e7647436c9 100644 --- a/backend/ent/proxy/proxy.go +++ b/backend/ent/proxy/proxy.go @@ -45,6 +45,8 @@ const ( FieldExpiryWarnDays = "expiry_warn_days" // EdgeAccounts holds the string denoting the accounts edge name in mutations. EdgeAccounts = "accounts" + // EdgePrimaryProxies holds the string denoting the primary_proxies edge name in mutations. + EdgePrimaryProxies = "primary_proxies" // EdgeBackupProxy holds the string denoting the backup_proxy edge name in mutations. EdgeBackupProxy = "backup_proxy" // Table holds the table name of the proxy in the database. @@ -56,6 +58,10 @@ const ( AccountsInverseTable = "accounts" // AccountsColumn is the table column denoting the accounts relation/edge. AccountsColumn = "proxy_id" + // PrimaryProxiesTable is the table that holds the primary_proxies relation/edge. + PrimaryProxiesTable = "proxies" + // PrimaryProxiesColumn is the table column denoting the primary_proxies relation/edge. + PrimaryProxiesColumn = "backup_proxy_id" // BackupProxyTable is the table that holds the backup_proxy relation/edge. BackupProxyTable = "proxies" // BackupProxyColumn is the table column denoting the backup_proxy relation/edge. @@ -219,6 +225,20 @@ func ByAccounts(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { } } +// ByPrimaryProxiesCount orders the results by primary_proxies count. +func ByPrimaryProxiesCount(opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborsCount(s, newPrimaryProxiesStep(), opts...) + } +} + +// ByPrimaryProxies orders the results by primary_proxies terms. +func ByPrimaryProxies(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newPrimaryProxiesStep(), append([]sql.OrderTerm{term}, terms...)...) + } +} + // ByBackupProxyField orders the results by backup_proxy field. func ByBackupProxyField(field string, opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { @@ -232,10 +252,17 @@ func newAccountsStep() *sqlgraph.Step { sqlgraph.Edge(sqlgraph.O2M, true, AccountsTable, AccountsColumn), ) } +func newPrimaryProxiesStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(Table, FieldID), + sqlgraph.Edge(sqlgraph.O2M, true, PrimaryProxiesTable, PrimaryProxiesColumn), + ) +} func newBackupProxyStep() *sqlgraph.Step { return sqlgraph.NewStep( sqlgraph.From(Table, FieldID), sqlgraph.To(Table, FieldID), - sqlgraph.Edge(sqlgraph.O2O, false, BackupProxyTable, BackupProxyColumn), + sqlgraph.Edge(sqlgraph.M2O, false, BackupProxyTable, BackupProxyColumn), ) } diff --git a/backend/ent/proxy/where.go b/backend/ent/proxy/where.go index 5150196a41..51830f8acc 100644 --- a/backend/ent/proxy/where.go +++ b/backend/ent/proxy/where.go @@ -913,12 +913,35 @@ func HasAccountsWith(preds ...predicate.Account) predicate.Proxy { }) } +// HasPrimaryProxies applies the HasEdge predicate on the "primary_proxies" edge. +func HasPrimaryProxies() predicate.Proxy { + return predicate.Proxy(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.O2M, true, PrimaryProxiesTable, PrimaryProxiesColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasPrimaryProxiesWith applies the HasEdge predicate on the "primary_proxies" edge with a given conditions (other predicates). +func HasPrimaryProxiesWith(preds ...predicate.Proxy) predicate.Proxy { + return predicate.Proxy(func(s *sql.Selector) { + step := newPrimaryProxiesStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + // HasBackupProxy applies the HasEdge predicate on the "backup_proxy" edge. func HasBackupProxy() predicate.Proxy { return predicate.Proxy(func(s *sql.Selector) { step := sqlgraph.NewStep( sqlgraph.From(Table, FieldID), - sqlgraph.Edge(sqlgraph.O2O, false, BackupProxyTable, BackupProxyColumn), + sqlgraph.Edge(sqlgraph.M2O, false, BackupProxyTable, BackupProxyColumn), ) sqlgraph.HasNeighbors(s, step) }) diff --git a/backend/ent/proxy_create.go b/backend/ent/proxy_create.go index 925ce9cb31..0db9f20e77 100644 --- a/backend/ent/proxy_create.go +++ b/backend/ent/proxy_create.go @@ -202,6 +202,21 @@ func (_c *ProxyCreate) AddAccounts(v ...*Account) *ProxyCreate { return _c.AddAccountIDs(ids...) } +// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs. +func (_c *ProxyCreate) AddPrimaryProxyIDs(ids ...int64) *ProxyCreate { + _c.mutation.AddPrimaryProxyIDs(ids...) + return _c +} + +// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity. +func (_c *ProxyCreate) AddPrimaryProxies(v ...*Proxy) *ProxyCreate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _c.AddPrimaryProxyIDs(ids...) +} + // SetBackupProxy sets the "backup_proxy" edge to the Proxy entity. func (_c *ProxyCreate) SetBackupProxy(v *Proxy) *ProxyCreate { return _c.SetBackupProxyID(v.ID) @@ -432,13 +447,29 @@ func (_c *ProxyCreate) createSpec() (*Proxy, *sqlgraph.CreateSpec) { } _spec.Edges = append(_spec.Edges, edge) } + if nodes := _c.mutation.PrimaryProxiesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: true, + Table: proxy.PrimaryProxiesTable, + Columns: []string{proxy.PrimaryProxiesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges = append(_spec.Edges, edge) + } if nodes := _c.mutation.BackupProxyIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ - Rel: sqlgraph.O2O, + Rel: sqlgraph.M2O, Inverse: false, Table: proxy.BackupProxyTable, Columns: []string{proxy.BackupProxyColumn}, - Bidi: true, + Bidi: false, Target: &sqlgraph.EdgeTarget{ IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64), }, diff --git a/backend/ent/proxy_query.go b/backend/ent/proxy_query.go index 93caf34665..0418f7e04d 100644 --- a/backend/ent/proxy_query.go +++ b/backend/ent/proxy_query.go @@ -21,13 +21,14 @@ import ( // ProxyQuery is the builder for querying Proxy entities. type ProxyQuery struct { config - ctx *QueryContext - order []proxy.OrderOption - inters []Interceptor - predicates []predicate.Proxy - withAccounts *AccountQuery - withBackupProxy *ProxyQuery - modifiers []func(*sql.Selector) + ctx *QueryContext + order []proxy.OrderOption + inters []Interceptor + predicates []predicate.Proxy + withAccounts *AccountQuery + withPrimaryProxies *ProxyQuery + withBackupProxy *ProxyQuery + modifiers []func(*sql.Selector) // intermediate query (i.e. traversal path). sql *sql.Selector path func(context.Context) (*sql.Selector, error) @@ -86,6 +87,28 @@ func (_q *ProxyQuery) QueryAccounts() *AccountQuery { return query } +// QueryPrimaryProxies chains the current query on the "primary_proxies" edge. +func (_q *ProxyQuery) QueryPrimaryProxies() *ProxyQuery { + query := (&ProxyClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(proxy.Table, proxy.FieldID, selector), + sqlgraph.To(proxy.Table, proxy.FieldID), + sqlgraph.Edge(sqlgraph.O2M, true, proxy.PrimaryProxiesTable, proxy.PrimaryProxiesColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + // QueryBackupProxy chains the current query on the "backup_proxy" edge. func (_q *ProxyQuery) QueryBackupProxy() *ProxyQuery { query := (&ProxyClient{config: _q.config}).Query() @@ -100,7 +123,7 @@ func (_q *ProxyQuery) QueryBackupProxy() *ProxyQuery { step := sqlgraph.NewStep( sqlgraph.From(proxy.Table, proxy.FieldID, selector), sqlgraph.To(proxy.Table, proxy.FieldID), - sqlgraph.Edge(sqlgraph.O2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn), + sqlgraph.Edge(sqlgraph.M2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn), ) fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) return fromU, nil @@ -295,13 +318,14 @@ func (_q *ProxyQuery) Clone() *ProxyQuery { return nil } return &ProxyQuery{ - config: _q.config, - ctx: _q.ctx.Clone(), - order: append([]proxy.OrderOption{}, _q.order...), - inters: append([]Interceptor{}, _q.inters...), - predicates: append([]predicate.Proxy{}, _q.predicates...), - withAccounts: _q.withAccounts.Clone(), - withBackupProxy: _q.withBackupProxy.Clone(), + config: _q.config, + ctx: _q.ctx.Clone(), + order: append([]proxy.OrderOption{}, _q.order...), + inters: append([]Interceptor{}, _q.inters...), + predicates: append([]predicate.Proxy{}, _q.predicates...), + withAccounts: _q.withAccounts.Clone(), + withPrimaryProxies: _q.withPrimaryProxies.Clone(), + withBackupProxy: _q.withBackupProxy.Clone(), // clone intermediate query. sql: _q.sql.Clone(), path: _q.path, @@ -319,6 +343,17 @@ func (_q *ProxyQuery) WithAccounts(opts ...func(*AccountQuery)) *ProxyQuery { return _q } +// WithPrimaryProxies tells the query-builder to eager-load the nodes that are connected to +// the "primary_proxies" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *ProxyQuery) WithPrimaryProxies(opts ...func(*ProxyQuery)) *ProxyQuery { + query := (&ProxyClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withPrimaryProxies = query + return _q +} + // WithBackupProxy tells the query-builder to eager-load the nodes that are connected to // the "backup_proxy" edge. The optional arguments are used to configure the query builder of the edge. func (_q *ProxyQuery) WithBackupProxy(opts ...func(*ProxyQuery)) *ProxyQuery { @@ -408,8 +443,9 @@ func (_q *ProxyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Proxy, var ( nodes = []*Proxy{} _spec = _q.querySpec() - loadedTypes = [2]bool{ + loadedTypes = [3]bool{ _q.withAccounts != nil, + _q.withPrimaryProxies != nil, _q.withBackupProxy != nil, } ) @@ -441,6 +477,13 @@ func (_q *ProxyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Proxy, return nil, err } } + if query := _q.withPrimaryProxies; query != nil { + if err := _q.loadPrimaryProxies(ctx, query, nodes, + func(n *Proxy) { n.Edges.PrimaryProxies = []*Proxy{} }, + func(n *Proxy, e *Proxy) { n.Edges.PrimaryProxies = append(n.Edges.PrimaryProxies, e) }); err != nil { + return nil, err + } + } if query := _q.withBackupProxy; query != nil { if err := _q.loadBackupProxy(ctx, query, nodes, nil, func(n *Proxy, e *Proxy) { n.Edges.BackupProxy = e }); err != nil { @@ -483,6 +526,39 @@ func (_q *ProxyQuery) loadAccounts(ctx context.Context, query *AccountQuery, nod } return nil } +func (_q *ProxyQuery) loadPrimaryProxies(ctx context.Context, query *ProxyQuery, nodes []*Proxy, init func(*Proxy), assign func(*Proxy, *Proxy)) error { + fks := make([]driver.Value, 0, len(nodes)) + nodeids := make(map[int64]*Proxy) + for i := range nodes { + fks = append(fks, nodes[i].ID) + nodeids[nodes[i].ID] = nodes[i] + if init != nil { + init(nodes[i]) + } + } + if len(query.ctx.Fields) > 0 { + query.ctx.AppendFieldOnce(proxy.FieldBackupProxyID) + } + query.Where(predicate.Proxy(func(s *sql.Selector) { + s.Where(sql.InValues(s.C(proxy.PrimaryProxiesColumn), fks...)) + })) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + fk := n.BackupProxyID + if fk == nil { + return fmt.Errorf(`foreign-key "backup_proxy_id" is nil for node %v`, n.ID) + } + node, ok := nodeids[*fk] + if !ok { + return fmt.Errorf(`unexpected referenced foreign-key "backup_proxy_id" returned %v for node %v`, *fk, n.ID) + } + assign(node, n) + } + return nil +} func (_q *ProxyQuery) loadBackupProxy(ctx context.Context, query *ProxyQuery, nodes []*Proxy, init func(*Proxy), assign func(*Proxy, *Proxy)) error { ids := make([]int64, 0, len(nodes)) nodeids := make(map[int64][]*Proxy) diff --git a/backend/ent/proxy_update.go b/backend/ent/proxy_update.go index 36b49ced6d..f4617c89c2 100644 --- a/backend/ent/proxy_update.go +++ b/backend/ent/proxy_update.go @@ -262,6 +262,21 @@ func (_u *ProxyUpdate) AddAccounts(v ...*Account) *ProxyUpdate { return _u.AddAccountIDs(ids...) } +// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs. +func (_u *ProxyUpdate) AddPrimaryProxyIDs(ids ...int64) *ProxyUpdate { + _u.mutation.AddPrimaryProxyIDs(ids...) + return _u +} + +// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity. +func (_u *ProxyUpdate) AddPrimaryProxies(v ...*Proxy) *ProxyUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddPrimaryProxyIDs(ids...) +} + // SetBackupProxy sets the "backup_proxy" edge to the Proxy entity. func (_u *ProxyUpdate) SetBackupProxy(v *Proxy) *ProxyUpdate { return _u.SetBackupProxyID(v.ID) @@ -293,6 +308,27 @@ func (_u *ProxyUpdate) RemoveAccounts(v ...*Account) *ProxyUpdate { return _u.RemoveAccountIDs(ids...) } +// ClearPrimaryProxies clears all "primary_proxies" edges to the Proxy entity. +func (_u *ProxyUpdate) ClearPrimaryProxies() *ProxyUpdate { + _u.mutation.ClearPrimaryProxies() + return _u +} + +// RemovePrimaryProxyIDs removes the "primary_proxies" edge to Proxy entities by IDs. +func (_u *ProxyUpdate) RemovePrimaryProxyIDs(ids ...int64) *ProxyUpdate { + _u.mutation.RemovePrimaryProxyIDs(ids...) + return _u +} + +// RemovePrimaryProxies removes "primary_proxies" edges to Proxy entities. +func (_u *ProxyUpdate) RemovePrimaryProxies(v ...*Proxy) *ProxyUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemovePrimaryProxyIDs(ids...) +} + // ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity. func (_u *ProxyUpdate) ClearBackupProxy() *ProxyUpdate { _u.mutation.ClearBackupProxy() @@ -492,13 +528,58 @@ func (_u *ProxyUpdate) sqlSave(ctx context.Context) (_node int, err error) { } _spec.Edges.Add = append(_spec.Edges.Add, edge) } + if _u.mutation.PrimaryProxiesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: true, + Table: proxy.PrimaryProxiesTable, + Columns: []string{proxy.PrimaryProxiesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedPrimaryProxiesIDs(); len(nodes) > 0 && !_u.mutation.PrimaryProxiesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: true, + Table: proxy.PrimaryProxiesTable, + Columns: []string{proxy.PrimaryProxiesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.PrimaryProxiesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: true, + Table: proxy.PrimaryProxiesTable, + Columns: []string{proxy.PrimaryProxiesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } if _u.mutation.BackupProxyCleared() { edge := &sqlgraph.EdgeSpec{ - Rel: sqlgraph.O2O, + Rel: sqlgraph.M2O, Inverse: false, Table: proxy.BackupProxyTable, Columns: []string{proxy.BackupProxyColumn}, - Bidi: true, + Bidi: false, Target: &sqlgraph.EdgeTarget{ IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64), }, @@ -507,11 +588,11 @@ func (_u *ProxyUpdate) sqlSave(ctx context.Context) (_node int, err error) { } if nodes := _u.mutation.BackupProxyIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ - Rel: sqlgraph.O2O, + Rel: sqlgraph.M2O, Inverse: false, Table: proxy.BackupProxyTable, Columns: []string{proxy.BackupProxyColumn}, - Bidi: true, + Bidi: false, Target: &sqlgraph.EdgeTarget{ IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64), }, @@ -774,6 +855,21 @@ func (_u *ProxyUpdateOne) AddAccounts(v ...*Account) *ProxyUpdateOne { return _u.AddAccountIDs(ids...) } +// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs. +func (_u *ProxyUpdateOne) AddPrimaryProxyIDs(ids ...int64) *ProxyUpdateOne { + _u.mutation.AddPrimaryProxyIDs(ids...) + return _u +} + +// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity. +func (_u *ProxyUpdateOne) AddPrimaryProxies(v ...*Proxy) *ProxyUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddPrimaryProxyIDs(ids...) +} + // SetBackupProxy sets the "backup_proxy" edge to the Proxy entity. func (_u *ProxyUpdateOne) SetBackupProxy(v *Proxy) *ProxyUpdateOne { return _u.SetBackupProxyID(v.ID) @@ -805,6 +901,27 @@ func (_u *ProxyUpdateOne) RemoveAccounts(v ...*Account) *ProxyUpdateOne { return _u.RemoveAccountIDs(ids...) } +// ClearPrimaryProxies clears all "primary_proxies" edges to the Proxy entity. +func (_u *ProxyUpdateOne) ClearPrimaryProxies() *ProxyUpdateOne { + _u.mutation.ClearPrimaryProxies() + return _u +} + +// RemovePrimaryProxyIDs removes the "primary_proxies" edge to Proxy entities by IDs. +func (_u *ProxyUpdateOne) RemovePrimaryProxyIDs(ids ...int64) *ProxyUpdateOne { + _u.mutation.RemovePrimaryProxyIDs(ids...) + return _u +} + +// RemovePrimaryProxies removes "primary_proxies" edges to Proxy entities. +func (_u *ProxyUpdateOne) RemovePrimaryProxies(v ...*Proxy) *ProxyUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemovePrimaryProxyIDs(ids...) +} + // ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity. func (_u *ProxyUpdateOne) ClearBackupProxy() *ProxyUpdateOne { _u.mutation.ClearBackupProxy() @@ -1034,13 +1151,58 @@ func (_u *ProxyUpdateOne) sqlSave(ctx context.Context) (_node *Proxy, err error) } _spec.Edges.Add = append(_spec.Edges.Add, edge) } + if _u.mutation.PrimaryProxiesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: true, + Table: proxy.PrimaryProxiesTable, + Columns: []string{proxy.PrimaryProxiesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedPrimaryProxiesIDs(); len(nodes) > 0 && !_u.mutation.PrimaryProxiesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: true, + Table: proxy.PrimaryProxiesTable, + Columns: []string{proxy.PrimaryProxiesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.PrimaryProxiesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: true, + Table: proxy.PrimaryProxiesTable, + Columns: []string{proxy.PrimaryProxiesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } if _u.mutation.BackupProxyCleared() { edge := &sqlgraph.EdgeSpec{ - Rel: sqlgraph.O2O, + Rel: sqlgraph.M2O, Inverse: false, Table: proxy.BackupProxyTable, Columns: []string{proxy.BackupProxyColumn}, - Bidi: true, + Bidi: false, Target: &sqlgraph.EdgeTarget{ IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64), }, @@ -1049,11 +1211,11 @@ func (_u *ProxyUpdateOne) sqlSave(ctx context.Context) (_node *Proxy, err error) } if nodes := _u.mutation.BackupProxyIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ - Rel: sqlgraph.O2O, + Rel: sqlgraph.M2O, Inverse: false, Table: proxy.BackupProxyTable, Columns: []string{proxy.BackupProxyColumn}, - Bidi: true, + Bidi: false, Target: &sqlgraph.EdgeTarget{ IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64), }, diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 1f84b42da9..5a718fabca 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1183,40 +1183,44 @@ func init() { groupDescMessagesDispatchModelConfig := groupFields[53].Descriptor() // group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field. group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig) - // groupDescModelsListConfig is the schema descriptor for models_list_config field. - groupDescModelsListConfig := groupFields[54].Descriptor() - // group.DefaultModelsListConfig holds the default value on creation for the models_list_config field. - group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig) + // groupDescModelAllowlist is the schema descriptor for model_allowlist field. + groupDescModelAllowlist := groupFields[54].Descriptor() + // group.DefaultModelAllowlist holds the default value on creation for the model_allowlist field. + group.DefaultModelAllowlist = groupDescModelAllowlist.Default.(domain.GroupModelAllowlist) + // groupDescCodexModelsManifestConfig is the schema descriptor for codex_models_manifest_config field. + groupDescCodexModelsManifestConfig := groupFields[55].Descriptor() + // group.DefaultCodexModelsManifestConfig holds the default value on creation for the codex_models_manifest_config field. + group.DefaultCodexModelsManifestConfig = groupDescCodexModelsManifestConfig.Default.(domain.GroupCodexModelsManifestConfig) // groupDescRpmLimit is the schema descriptor for rpm_limit field. - groupDescRpmLimit := groupFields[55].Descriptor() + groupDescRpmLimit := groupFields[56].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) // groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field. - groupDescMaxReasoningEffort := groupFields[56].Descriptor() + groupDescMaxReasoningEffort := groupFields[57].Descriptor() // group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field. group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string) // group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save. group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error) // groupDescMaxReasoningEffortOverLimit is the schema descriptor for max_reasoning_effort_over_limit field. - groupDescMaxReasoningEffortOverLimit := groupFields[57].Descriptor() + groupDescMaxReasoningEffortOverLimit := groupFields[58].Descriptor() // group.DefaultMaxReasoningEffortOverLimit holds the default value on creation for the max_reasoning_effort_over_limit field. group.DefaultMaxReasoningEffortOverLimit = groupDescMaxReasoningEffortOverLimit.Default.(string) // group.MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save. group.MaxReasoningEffortOverLimitValidator = groupDescMaxReasoningEffortOverLimit.Validators[0].(func(string) error) // groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field. - groupDescReasoningEffortMappings := groupFields[58].Descriptor() + groupDescReasoningEffortMappings := groupFields[59].Descriptor() // group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field. group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping) // groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field. - groupDescProfitControlEnabled := groupFields[59].Descriptor() + groupDescProfitControlEnabled := groupFields[60].Descriptor() // group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field. group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool) // groupDescProfitMinMargin is the schema descriptor for profit_min_margin field. - groupDescProfitMinMargin := groupFields[60].Descriptor() + groupDescProfitMinMargin := groupFields[61].Descriptor() // group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field. group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64) // groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field. - groupDescProfitSafetyBuffer := groupFields[61].Descriptor() + groupDescProfitSafetyBuffer := groupFields[62].Descriptor() // group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field. group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/channel_monitor.go b/backend/ent/schema/channel_monitor.go index e14416b765..1f0b2713ce 100644 --- a/backend/ent/schema/channel_monitor.go +++ b/backend/ent/schema/channel_monitor.go @@ -36,7 +36,7 @@ func (ChannelMonitor) Fields() []ent.Field { MaxLen(100), field.Enum("provider"). Values("openai", "anthropic", "gemini", "grok", - "antigravity", "kimi", "zhipu", "deepseek"), + "antigravity", "kimi", "zhipu", "deepseek", "minimax"), // check_mode: 'probe' | 'quota' | 'quota_probe' // probe - LLM 探活(默认,原有行为) // quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空) diff --git a/backend/ent/schema/channel_monitor_request_template.go b/backend/ent/schema/channel_monitor_request_template.go index 332a01007c..80e41c6259 100644 --- a/backend/ent/schema/channel_monitor_request_template.go +++ b/backend/ent/schema/channel_monitor_request_template.go @@ -40,7 +40,7 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field { MaxLen(100), field.Enum("provider"). Values("openai", "anthropic", "gemini", "grok", - "antigravity", "kimi", "zhipu", "deepseek"), + "antigravity", "kimi", "zhipu", "deepseek", "minimax"), field.String("api_mode"). Default("chat_completions"). MaxLen(32). diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 38f48b4fdf..2d39e5724a 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -262,10 +262,14 @@ func (Group) Fields() []ent.Field { Default(domain.OpenAIMessagesDispatchModelConfig{}). SchemaType(map[string]string{dialect.Postgres: "jsonb"}). Comment("OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型"), - field.JSON("models_list_config", domain.GroupModelsListConfig{}). - Default(domain.GroupModelsListConfig{}). + field.JSON("model_allowlist", domain.GroupModelAllowlist{}). + Default(domain.GroupModelAllowlist{}). SchemaType(map[string]string{dialect.Postgres: "jsonb"}). - Comment("自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度"), + Comment("分组模型白名单:同时约束模型列表接口与请求准入"), + field.JSON("codex_models_manifest_config", domain.GroupCodexModelsManifestConfig{}). + Default(domain.GroupCodexModelsManifestConfig{}). + SchemaType(map[string]string{dialect.Postgres: "jsonb"}). + Comment("固定账号获取 Codex Model Manifest 配置;开启后 /models 请求只用选定账号拉取(仅 openai 平台)"), // 分组级每分钟请求数上限(0 = 不限制)。设置后优先于用户级兜底生效。 field.Int("rpm_limit"). diff --git a/backend/ent/schema/proxy.go b/backend/ent/schema/proxy.go index f544811c5c..46ab1ffcb2 100644 --- a/backend/ent/schema/proxy.go +++ b/backend/ent/schema/proxy.go @@ -73,6 +73,10 @@ func (Proxy) Edges() []ent.Edge { // accounts: 使用此代理的账户(反向边) edge.From("accounts", Account.Type). Ref("proxy"), + // Directed many-to-one: a backup can serve multiple primary proxies. + // The inverse edge prevents Ent from treating this self-reference as symmetric. + edge.From("primary_proxies", Proxy.Type). + Ref("backup_proxy"), edge.To("backup_proxy", Proxy.Type). Field("backup_proxy_id"). Unique(), diff --git a/backend/ent/schema/user_platform_quota.go b/backend/ent/schema/user_platform_quota.go index 123d7c0bfa..abe4b2fd8d 100644 --- a/backend/ent/schema/user_platform_quota.go +++ b/backend/ent/schema/user_platform_quota.go @@ -42,7 +42,7 @@ func (UserPlatformQuota) Fields() []ent.Field { // 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。 switch s { case "anthropic", "openai", "gemini", "antigravity", "grok", - "kimi", "zhipu", "deepseek": + "kimi", "zhipu", "deepseek", "minimax": return nil default: return fmt.Errorf("platform %q is not allowed", s) diff --git a/backend/go.mod b/backend/go.mod index b86368117f..3cd44df4d9 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -31,7 +31,7 @@ require ( github.com/lib/pq v1.10.9 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pquerna/otp v1.5.0 - github.com/redis/go-redis/v9 v9.17.2 + github.com/redis/go-redis/v9 v9.22.0 github.com/refraction-networking/utls v1.8.2 github.com/robfig/cron/v3 v3.0.1 github.com/shirou/gopsutil/v4 v4.25.6 @@ -101,7 +101,6 @@ require ( github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2/v2 v2.1.0 // indirect github.com/docker/docker v28.5.1+incompatible // indirect @@ -135,7 +134,7 @@ require ( github.com/hashicorp/yamux v0.1.2 // indirect github.com/icholy/digest v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect @@ -197,7 +196,7 @@ require ( go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect - go.uber.org/atomic v1.10.0 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.3.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index cbb257f12f..f639504856 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -159,8 +159,6 @@ github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dr github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= -github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2/v2 v2.1.0 h1:jHXRmHRZGbuQzDZjMlCAXOvQb75iv3HyLDzXGj5H1AY= @@ -255,6 +253,8 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= @@ -302,6 +302,8 @@ github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxh github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -327,6 +329,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -365,6 +369,8 @@ github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -393,12 +399,14 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= -github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= -github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -434,6 +442,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= @@ -535,6 +545,8 @@ go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeX go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 5d04ab5306..f5853682ac 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1613,10 +1613,10 @@ type OpsCleanupConfig struct { Enabled bool `mapstructure:"enabled"` Schedule string `mapstructure:"schedule"` - // Retention days (0 disables that cleanup target). - // - // vNext requirement: default 30 days across ops datasets. + // Retention days. Error and metrics targets accept 0 as an explicit truncate; + // system logs require a positive value because their runtime setting is bounded. ErrorLogRetentionDays int `mapstructure:"error_log_retention_days"` + SystemLogRetentionDays int `mapstructure:"system_log_retention_days"` MinuteMetricsRetentionDays int `mapstructure:"minute_metrics_retention_days"` HourlyMetricsRetentionDays int `mapstructure:"hourly_metrics_retention_days"` } @@ -2046,7 +2046,8 @@ func setDefaults() { "api.moonshot.ai", "api.moonshot.cn", "open.bigmodel.cn", - "api.minimaxi.com", + "api.minimaxi.com", // MiniMax CN quota + inference + "api.minimax.io", // MiniMax intl; frozen allowlists must add this host to use the intl site "generativelanguage.googleapis.com", "cloudcode-pa.googleapis.com", "*.openai.azure.com", @@ -2250,6 +2251,7 @@ func setDefaults() { viper.SetDefault("ops.cleanup.schedule", "0 2 * * *") // Retention days: vNext defaults to 30 days across ops datasets. viper.SetDefault("ops.cleanup.error_log_retention_days", 30) + viper.SetDefault("ops.cleanup.system_log_retention_days", 30) viper.SetDefault("ops.cleanup.minute_metrics_retention_days", 30) viper.SetDefault("ops.cleanup.hourly_metrics_retention_days", 30) viper.SetDefault("ops.aggregation.enabled", true) @@ -3673,6 +3675,9 @@ func (c *Config) Validate() error { if c.Ops.Cleanup.ErrorLogRetentionDays < 0 { return fmt.Errorf("ops.cleanup.error_log_retention_days must be non-negative") } + if c.Ops.Cleanup.Enabled && c.Ops.Cleanup.SystemLogRetentionDays <= 0 { + return fmt.Errorf("ops.cleanup.system_log_retention_days must be positive when ops cleanup is enabled") + } if c.Ops.Cleanup.MinuteMetricsRetentionDays < 0 { return fmt.Errorf("ops.cleanup.minute_metrics_retention_days must be non-negative") } diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index a8fbba0977..717960a91a 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1191,6 +1191,21 @@ func TestLoadDefaultUsageCleanupConfig(t *testing.T) { } } +func TestLoadDefaultOpsCleanupConfig(t *testing.T) { + resetViperWithJWTSecret(t) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if !cfg.Ops.Cleanup.Enabled { + t.Fatal("Ops.Cleanup.Enabled = false, want true") + } + if cfg.Ops.Cleanup.SystemLogRetentionDays != 30 { + t.Fatalf("Ops.Cleanup.SystemLogRetentionDays = %d, want 30", cfg.Ops.Cleanup.SystemLogRetentionDays) + } +} + func TestValidateUsageCleanupConfigEnabled(t *testing.T) { resetViperWithJWTSecret(t) @@ -2137,6 +2152,11 @@ func TestValidateConfigErrors(t *testing.T) { mutate: func(c *Config) { c.Ops.Cleanup.ErrorLogRetentionDays = -1 }, wantErr: "ops.cleanup.error_log_retention_days", }, + { + name: "ops cleanup system log retention", + mutate: func(c *Config) { c.Ops.Cleanup.SystemLogRetentionDays = 0 }, + wantErr: "ops.cleanup.system_log_retention_days", + }, { name: "ops cleanup minute retention", mutate: func(c *Config) { c.Ops.Cleanup.MinuteMetricsRetentionDays = -1 }, diff --git a/backend/internal/domain/codex_models_manifest_config.go b/backend/internal/domain/codex_models_manifest_config.go new file mode 100644 index 0000000000..22b36d8b46 --- /dev/null +++ b/backend/internal/domain/codex_models_manifest_config.go @@ -0,0 +1,15 @@ +package domain + +// GroupCodexModelsManifestConfig controls which accounts fetch the Codex +// models manifest for an OpenAI group. When enabled, the group's Codex +// /models requests bypass the scheduler and only use the pinned accounts. +type GroupCodexModelsManifestConfig struct { + Enabled bool `json:"enabled"` + // AccountIDs is the ordered list of pinned account IDs. Order decides + // merge precedence for duplicate model slugs. + AccountIDs []int64 `json:"account_ids,omitempty"` + // FallbackToScheduler controls the behavior when no pinned account is + // usable or all upstream fetches fail: fall back to the scheduler loop + // (true) or return the upstream error / 503 (false, default). + FallbackToScheduler bool `json:"fallback_to_scheduler,omitempty"` +} diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 378b05501c..79cea215d6 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -27,6 +27,7 @@ const ( PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot) PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel) PlatformDeepseek = "deepseek" // DeepSeek + PlatformMiniMax = "minimax" // MiniMax (M 系列) PlatformComposite = "composite" ) @@ -43,7 +44,7 @@ const ( const ( APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认) APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code) - APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi 原生端点,适配 Codex) + APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi / minimax 原生端点,适配 Codex) APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点 ) diff --git a/backend/internal/domain/model_allowlist.go b/backend/internal/domain/model_allowlist.go new file mode 100644 index 0000000000..e6069e0a22 --- /dev/null +++ b/backend/internal/domain/model_allowlist.go @@ -0,0 +1,8 @@ +package domain + +// GroupModelAllowlist 是分组级模型白名单:开启后既过滤模型列表类接口的返回内容, +// 也约束分组实际可调用的模型(网关准入在合成路由改写与调度之前完成)。 +type GroupModelAllowlist struct { + Enabled bool `json:"enabled"` + Models []string `json:"models,omitempty"` +} diff --git a/backend/internal/domain/models_list_config.go b/backend/internal/domain/models_list_config.go deleted file mode 100644 index 3f050585db..0000000000 --- a/backend/internal/domain/models_list_config.go +++ /dev/null @@ -1,7 +0,0 @@ -package domain - -// GroupModelsListConfig controls the optional custom /v1/models response list. -type GroupModelsListConfig struct { - Enabled bool `json:"enabled"` - Models []string `json:"models,omitempty"` -} diff --git a/backend/internal/domain/reasoning_effort.go b/backend/internal/domain/reasoning_effort.go index 88f4263fc2..64ecf8ad62 100644 --- a/backend/internal/domain/reasoning_effort.go +++ b/backend/internal/domain/reasoning_effort.go @@ -7,7 +7,8 @@ const ( ) // ReasoningEffortMapping rewrites one explicit OpenAI/Codex reasoning effort -// value to another before the group ceiling is applied. +// value to another before the group ceiling is applied. To may also be "deny" +// to reject the request when the matching source value is present. // // Model and MatchType optionally scope the rewrite to a request model: // exact matches the full model id, prefix/suffix match a model-id affix. diff --git a/backend/internal/handler/admin/account_data.go b/backend/internal/handler/admin/account_data.go index e44d726fd6..86c626b877 100644 --- a/backend/internal/handler/admin/account_data.go +++ b/backend/internal/handler/admin/account_data.go @@ -309,9 +309,11 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest) _, _ = h.adminService.UpdateProxy(ctx, existingID, &service.UpdateProxyInput{ Status: normalizedStatus, ExpiresAt: existingExpiresAt, + ClearExpiresAt: existingExpiresAt == nil, FallbackMode: existingFallbackMode, BackupProxyID: existingBackupProxyID, - ExpiryWarnDays: item.ExpiryWarnDays, + ClearBackupID: existingBackupProxyID == nil, + ExpiryWarnDays: &item.ExpiryWarnDays, Name: proxy.Name, Protocol: proxy.Protocol, Host: proxy.Host, @@ -383,9 +385,11 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest) _, _ = h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{ Status: normalizedStatus, ExpiresAt: expiresAt, + ClearExpiresAt: expiresAt == nil, FallbackMode: fallbackMode, BackupProxyID: backupProxyID, - ExpiryWarnDays: item.ExpiryWarnDays, + ClearBackupID: backupProxyID == nil, + ExpiryWarnDays: &item.ExpiryWarnDays, Name: created.Name, Protocol: created.Protocol, Host: created.Host, diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 599a04a34e..754105c46b 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -17,6 +17,7 @@ import ( "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" @@ -64,6 +65,7 @@ type AccountHandler struct { grokImportProber grokImportProber upstreamBillingProbe *service.UpstreamBillingProbeService ollamaCloudUsage *service.OllamaCloudUsageService + cfg *config.Config } // SetUpstreamBillingProbeService attaches the optional remote billing probe service. @@ -190,6 +192,7 @@ type CheckMixedChannelRequest struct { // AccountWithConcurrency extends Account with real-time concurrency info type AccountWithConcurrency struct { *dto.Account + simpleMode bool `json:"-"` CurrentConcurrency int `json:"current_concurrency"` SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"` SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"` @@ -199,6 +202,123 @@ type AccountWithConcurrency struct { CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数 } +// AccountListItemWithConcurrency is the compact account-list envelope used +// for lite=1. It embeds dto.AccountListItem instead of the full dto.Account, +// so groups/account_groups never appear in the list payload. +type AccountListItemWithConcurrency struct { + *dto.AccountListItem + CurrentConcurrency int `json:"current_concurrency"` + SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"` + SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"` + CurrentWindowCost *float64 `json:"current_window_cost,omitempty"` + ActiveSessions *int `json:"active_sessions,omitempty"` + CurrentRPM *int `json:"current_rpm,omitempty"` +} + +type simpleModeGroupReference struct { + ID int64 `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + Status string `json:"status"` +} + +type simpleModeAccountGroupReference struct { + AccountID int64 `json:"account_id"` + GroupID int64 `json:"group_id"` + Priority int `json:"priority"` + CreatedAt time.Time `json:"created_at"` + Group *simpleModeGroupReference `json:"group,omitempty"` +} + +func simpleModeGroupReferenceFromDTO(group *dto.Group) *simpleModeGroupReference { + if group == nil { + return nil + } + return &simpleModeGroupReference{ID: group.ID, Name: group.Name, Platform: group.Platform, Status: group.Status} +} + +func simpleModeCompositeGroupIDs(account *dto.Account) map[int64]struct{} { + hidden := make(map[int64]struct{}) + if account == nil { + return hidden + } + for _, group := range account.Groups { + if group != nil && group.Platform == service.PlatformComposite { + hidden[group.ID] = struct{}{} + } + } + for _, accountGroup := range account.AccountGroups { + if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite { + hidden[accountGroup.GroupID] = struct{}{} + } + } + return hidden +} + +func filterSimpleModeGroupIDs(groupIDs []int64, hidden map[int64]struct{}) []int64 { + visible := make([]int64, 0, len(groupIDs)) + for _, groupID := range groupIDs { + if _, ok := hidden[groupID]; !ok { + visible = append(visible, groupID) + } + } + return visible +} + +func simpleModeCompositeServiceGroupIDs(account *service.Account) map[int64]struct{} { + hidden := make(map[int64]struct{}) + if account == nil { + return hidden + } + for _, group := range account.Groups { + if group != nil && group.Platform == service.PlatformComposite { + hidden[group.ID] = struct{}{} + } + } + for _, accountGroup := range account.AccountGroups { + if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite { + hidden[accountGroup.GroupID] = struct{}{} + } + } + return hidden +} + +func (a AccountWithConcurrency) MarshalJSON() ([]byte, error) { + type alias AccountWithConcurrency + if !a.simpleMode || a.Account == nil { + return json.Marshal(alias(a)) + } + groups := make([]simpleModeGroupReference, 0, len(a.Groups)) + compositeIDs := simpleModeCompositeGroupIDs(a.Account) + for _, group := range a.Groups { + if group != nil && group.Platform == service.PlatformComposite { + continue + } + if ref := simpleModeGroupReferenceFromDTO(group); ref != nil { + groups = append(groups, *ref) + } + } + accountGroups := make([]simpleModeAccountGroupReference, 0, len(a.AccountGroups)) + for _, accountGroup := range a.AccountGroups { + if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite { + continue + } + if _, hidden := compositeIDs[accountGroup.GroupID]; hidden { + continue + } + accountGroups = append(accountGroups, simpleModeAccountGroupReference{ + AccountID: accountGroup.AccountID, GroupID: accountGroup.GroupID, Priority: accountGroup.Priority, + CreatedAt: accountGroup.CreatedAt, Group: simpleModeGroupReferenceFromDTO(accountGroup.Group), + }) + } + return json.Marshal(struct { + alias + GroupIDs []int64 `json:"group_ids,omitempty"` + Groups []simpleModeGroupReference `json:"groups"` + AccountGroups []simpleModeAccountGroupReference `json:"account_groups"` + }{alias: alias(a), GroupIDs: filterSimpleModeGroupIDs(a.GroupIDs, compositeIDs), Groups: groups, AccountGroups: accountGroups}) +} + type AccountSchedulerScore struct { BaseScore float64 `json:"base_score"` StickyScore float64 `json:"sticky_score"` @@ -223,9 +343,25 @@ func (h *AccountHandler) accountResponseFromService(account *service.Account) *d return out } +func (h *AccountHandler) accountListResponseFromService(account *service.Account) *dto.Account { + out := dto.AccountFromServiceShallow(account) + if out != nil && account != nil { + out.Proxy = dto.ProxyFromService(account.Proxy) + } + if h != nil && h.ollamaCloudUsage != nil && out != nil { + h.ollamaCloudUsage.EnrichState(out.OllamaCloudUsage) + } + return out +} + +func (h *AccountHandler) isSimpleMode() bool { + return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple +} + func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency { item := AccountWithConcurrency{ Account: h.accountResponseFromService(account), + simpleMode: h.isSimpleMode(), CurrentConcurrency: 0, } if account == nil { @@ -650,8 +786,16 @@ func (h *AccountHandler) List(c *gin.Context) { result := make([]AccountWithConcurrency, len(accounts)) for i := range accounts { acc := &accounts[i] + accountResponse := h.accountResponseFromService(acc) + if lite { + accountResponse = h.accountListResponseFromService(acc) + if h.isSimpleMode() { + accountResponse.GroupIDs = filterSimpleModeGroupIDs(accountResponse.GroupIDs, simpleModeCompositeServiceGroupIDs(acc)) + } + } item := AccountWithConcurrency{ - Account: h.accountResponseFromService(acc), + Account: accountResponse, + simpleMode: h.isSimpleMode(), CurrentConcurrency: concurrencyCounts[acc.ID], SchedulerScore: schedulerScores[acc.ID], SchedulerScores: schedulerGroupScores[acc.ID], @@ -683,7 +827,34 @@ func (h *AccountHandler) List(c *gin.Context) { h.enrichShadowParents(c.Request.Context(), result) - etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, lite) + if lite { + compact := make([]AccountListItemWithConcurrency, len(result)) + for i := range result { + item := result[i] + compact[i] = AccountListItemWithConcurrency{ + AccountListItem: dto.AccountListItemFromAccount(item.Account), + CurrentConcurrency: item.CurrentConcurrency, + SchedulerScore: item.SchedulerScore, + SchedulerScores: item.SchedulerScores, + CurrentWindowCost: item.CurrentWindowCost, + ActiveSessions: item.ActiveSessions, + CurrentRPM: item.CurrentRPM, + } + } + etag := buildAccountsListETag(compact, total, page, pageSize, platform, accountType, status, search, true) + if etag != "" { + c.Header("ETag", etag) + c.Header("Vary", "If-None-Match") + if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) { + c.Status(http.StatusNotModified) + return + } + } + response.Paginated(c, compact, total, page, pageSize) + return + } + + etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, false) if etag != "" { c.Header("ETag", etag) c.Header("Vary", "If-None-Match") @@ -696,23 +867,23 @@ func (h *AccountHandler) List(c *gin.Context) { response.Paginated(c, result, total, page, pageSize) } -func buildAccountsListETag( - items []AccountWithConcurrency, +func buildAccountsListETag[T any]( + items []T, total int64, page, pageSize int, platform, accountType, status, search string, lite bool, ) string { payload := struct { - Total int64 `json:"total"` - Page int `json:"page"` - PageSize int `json:"page_size"` - Platform string `json:"platform"` - AccountType string `json:"type"` - Status string `json:"status"` - Search string `json:"search"` - Lite bool `json:"lite"` - Items []AccountWithConcurrency `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Platform string `json:"platform"` + AccountType string `json:"type"` + Status string `json:"status"` + Search string `json:"search"` + Lite bool `json:"lite"` + Items []T `json:"items"` }{ Total: total, Page: page, @@ -837,6 +1008,10 @@ func (h *AccountHandler) Create(c *gin.Context) { } // base_rpm 输入校验:负值归零,超过 10000 截断 sanitizeExtraBaseRPM(req.Extra) + if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil { + response.ErrorFrom(c, err) + return + } // 确定是否跳过混合渠道检查 skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk @@ -970,6 +1145,10 @@ func (h *AccountHandler) Update(c *gin.Context) { } // base_rpm 输入校验:负值归零,超过 10000 截断 sanitizeExtraBaseRPM(req.Extra) + if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil { + response.ErrorFrom(c, err) + return + } // 确定是否跳过混合渠道检查 skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk @@ -1424,6 +1603,10 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) { response.ErrorFrom(c, err) return } + if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil { + response.ErrorFrom(c, err) + return + } // Drop SSO/password residue; re-auth must leave only OAuth tokens on disk. req.Credentials = service.SanitizeStoredCredentials(existing.Platform, req.Credentials) @@ -1875,6 +2058,14 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) { return } } + groupIDs := make([]int64, 0) + for _, item := range req.Accounts { + groupIDs = append(groupIDs, item.GroupIDs...) + } + if err := h.adminService.ValidateAccountGroupBindings(c.Request.Context(), groupIDs); err != nil { + response.ErrorFrom(c, err) + return + } executeAdminIdempotentJSON(c, "admin.accounts.batch_create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { success := 0 @@ -1897,6 +2088,15 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) { // base_rpm 输入校验:负值归零,超过 10000 截断 sanitizeExtraBaseRPM(item.Extra) + if err := service.ValidateUpstreamRequestIDHeaderExtra(item.Extra); err != nil { + failed++ + results = append(results, gin.H{ + "name": item.Name, + "success": false, + "error": err.Error(), + }) + continue + } skipCheck := item.ConfirmMixedChannelRisk != nil && *item.ConfirmMixedChannelRisk @@ -2091,6 +2291,10 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) { } // base_rpm 输入校验:负值归零,超过 10000 截断 sanitizeExtraBaseRPM(req.Extra) + if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil { + response.ErrorFrom(c, err) + return + } // 确定是否跳过混合渠道检查 skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk @@ -2576,6 +2780,14 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) { // Handle OpenAI accounts if account.IsOpenAI() { + // Prefer the shared, account-keyed upstream catalog. If discovery fails, + // retain the legacy local catalog below so the test dialog remains usable. + if h.accountTestService != nil { + if models, fetchErr := h.accountTestService.FetchOpenAIAccountModels(c.Request.Context(), account); fetchErr == nil { + response.Success(c, models) + return + } + } // OpenAI 自动透传会绕过常规模型改写,测试/模型列表也应回落到默认模型集。 if account.IsOpenAIPassthroughEnabled() { response.Success(c, openai.DefaultModels) diff --git a/backend/internal/handler/admin/account_handler_list_test.go b/backend/internal/handler/admin/account_handler_list_test.go index 6a10bb4dcc..f4304eb93d 100644 --- a/backend/internal/handler/admin/account_handler_list_test.go +++ b/backend/internal/handler/admin/account_handler_list_test.go @@ -1,9 +1,12 @@ package admin import ( + "bytes" + "compress/gzip" "encoding/json" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -13,6 +16,101 @@ import ( "github.com/stretchr/testify/require" ) +func TestAccountHandlerListLiteUsesCompactDTOAndETag(t *testing.T) { + router, adminSvc := setupAccountListRouter() + now := time.Now().UTC() + groupID := int64(77) + adminSvc.accounts = []service.Account{{ + ID: 501, Name: "compact-account", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + Credentials: map[string]any{"email": "compact@example.com", "access_token": strings.Repeat("x", 4096)}, + Extra: map[string]any{"privacy_mode": "training_off"}, Status: service.StatusActive, + Schedulable: true, Concurrency: 4, GroupIDs: []int64{groupID}, + Groups: []*service.Group{{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}}, + AccountGroups: []service.AccountGroup{{AccountID: 501, GroupID: groupID, Priority: 2, Group: &service.Group{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}}}, + CreatedAt: now, UpdatedAt: now, + }} + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil) + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.NotEmpty(t, rec.Header().Get("ETag")) + + var litePayload struct { + Data struct { + Items []map[string]any `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &litePayload)) + require.Len(t, litePayload.Data.Items, 1) + liteItem := litePayload.Data.Items[0] + require.Equal(t, float64(501), liteItem["id"]) + require.Equal(t, []any{float64(groupID)}, liteItem["group_ids"]) + require.Equal(t, true, liteItem["schedulable"]) + require.NotContains(t, liteItem, "groups") + require.NotContains(t, liteItem, "account_groups") + credentials, ok := liteItem["credentials"].(map[string]any) + require.True(t, ok) + require.Equal(t, "compact@example.com", credentials["email"]) + require.NotContains(t, credentials, "access_token") + credentialsStatus, ok := liteItem["credentials_status"].(map[string]any) + require.True(t, ok) + require.Equal(t, true, credentialsStatus["has_access_token"]) + + // The ETag must represent the same compact body and return 304 on refresh. + rec304 := httptest.NewRecorder() + req304 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil) + req304.Header.Set("If-None-Match", rec.Header().Get("ETag")) + router.ServeHTTP(rec304, req304) + require.Equal(t, http.StatusNotModified, rec304.Code) + + // Omitting lite preserves the legacy full response shape. + recFull := httptest.NewRecorder() + reqFull := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20", nil) + router.ServeHTTP(recFull, reqFull) + require.Equal(t, http.StatusOK, recFull.Code) + var fullPayload struct { + Data struct { + Items []map[string]any `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recFull.Body.Bytes(), &fullPayload)) + require.Contains(t, fullPayload.Data.Items[0], "groups") + require.Contains(t, fullPayload.Data.Items[0], "account_groups") +} + +func TestAccountHandlerListLiteStaysBelowResponseBudget(t *testing.T) { + router, adminSvc := setupAccountListRouter() + now := time.Now().UTC() + accounts := make([]service.Account, 20) + for i := range accounts { + id := int64(600 + i) + groupID := int64(800 + i) + accounts[i] = service.Account{ + ID: id, Name: "account-" + strconv.Itoa(i), Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, Status: service.StatusActive, Schedulable: true, + Concurrency: 4, GroupIDs: []int64{groupID}, + Groups: []*service.Group{{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}}, + AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID, Group: &service.Group{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}}}, + CreatedAt: now, UpdatedAt: now, + } + } + adminSvc.accounts = accounts + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil) + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code) + require.Less(t, rec.Body.Len(), 80*1024) + + var compressed bytes.Buffer + zw := gzip.NewWriter(&compressed) + _, err := zw.Write(rec.Body.Bytes()) + require.NoError(t, err) + require.NoError(t, zw.Close()) + require.Less(t, compressed.Len(), 15*1024) +} + func setupAccountListRouter() (*gin.Engine, *stubAdminService) { gin.SetMode(gin.TestMode) router := gin.New() diff --git a/backend/internal/handler/admin/account_handler_simple_mode_test.go b/backend/internal/handler/admin/account_handler_simple_mode_test.go new file mode 100644 index 0000000000..4326176d15 --- /dev/null +++ b/backend/internal/handler/admin/account_handler_simple_mode_test.go @@ -0,0 +1,240 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type simpleModeAccountService struct { + *stubAdminService + account service.Account + createCalls int + updateCalls int + bulkCalls int +} + +func (s *simpleModeAccountService) GetAccount(context.Context, int64) (*service.Account, error) { + return &s.account, nil +} + +func (s *simpleModeAccountService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) { + if err := s.ValidateAccountGroupBindings(ctx, input.GroupIDs); err != nil { + return nil, err + } + s.createCalls++ + return &s.account, nil +} + +func (s *simpleModeAccountService) UpdateAccount(ctx context.Context, _ int64, input *service.UpdateAccountInput) (*service.Account, error) { + if input.GroupIDs != nil { + if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil { + return nil, err + } + } + s.updateCalls++ + return &s.account, nil +} + +func (s *simpleModeAccountService) BulkUpdateAccounts(ctx context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) { + if input.GroupIDs != nil { + if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil { + return nil, err + } + } + s.bulkCalls++ + return &service.BulkUpdateAccountsResult{}, nil +} + +func (s *simpleModeAccountService) ValidateAccountGroupBindings(_ context.Context, groupIDs []int64) error { + for _, id := range groupIDs { + for i := range s.groups { + if s.groups[i].ID == id && s.groups[i].Platform == service.PlatformComposite { + return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups cannot be bound in simple mode") + } + } + } + return nil +} + +func TestAccountHandlerSimpleModeUsesMinimalGroupReferences(t *testing.T) { + gin.SetMode(gin.TestMode) + richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9, IsExclusive: true, SubscriptionType: service.SubscriptionTypeSubscription, RPMLimit: 99} + historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive} + account := service.Account{ + ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive, + GroupIDs: []int64{7, 9}, + Groups: []*service.Group{richGroup, historicalComposite}, + AccountGroups: []service.AccountGroup{ + {AccountID: 3, GroupID: 7, Priority: 2, Group: richGroup, Account: &service.Account{ID: 3, Extra: map[string]any{"secret": true}}}, + {AccountID: 3, GroupID: 9, Priority: 3, Group: historicalComposite}, + }, + } + svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account} + svc.accounts = []service.Account{account} + h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + h.cfg = &config.Config{RunMode: config.RunModeSimple} + r := gin.New() + r.GET("/accounts", h.List) + r.GET("/accounts/:id", h.GetByID) + r.POST("/accounts", h.Create) + r.PUT("/accounts/:id", h.Update) + + tests := []struct { + method string + path string + body string + }{ + {http.MethodGet, "/accounts", ""}, + {http.MethodGet, "/accounts/3", ""}, + {http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"}}`}, + {http.MethodPut, "/accounts/3", `{"name":"account"}`}, + } + for _, tt := range tests { + t.Run(tt.method+tt.path, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + r.ServeHTTP(res, req) + require.Equal(t, http.StatusOK, res.Code, res.Body.String()) + var payload map[string]any + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload)) + data, ok := payload["data"].(map[string]any) + require.True(t, ok) + if items, ok := data["items"].([]any); ok { + require.Len(t, items, 1) + data, ok = items[0].(map[string]any) + require.True(t, ok) + } + groups, ok := data["groups"].([]any) + require.True(t, ok) + require.Len(t, groups, 1) + require.Equal(t, []any{float64(7)}, data["group_ids"]) + group, ok := groups[0].(map[string]any) + require.True(t, ok) + require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(group)) + accountGroups, ok := data["account_groups"].([]any) + require.True(t, ok) + require.Len(t, accountGroups, 1) + accountGroup, ok := accountGroups[0].(map[string]any) + require.True(t, ok) + require.ElementsMatch(t, []string{"account_id", "group_id", "priority", "created_at", "group"}, mapKeys(accountGroup)) + nestedGroup, ok := accountGroup["group"].(map[string]any) + require.True(t, ok) + require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(nestedGroup)) + }) + } +} + +func TestAccountHandlerSimpleModeLitePreservesCompactShapeAndETag(t *testing.T) { + richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9} + historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive} + account := service.Account{ + ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive, + GroupIDs: []int64{7, 9}, Groups: []*service.Group{richGroup, historicalComposite}, + AccountGroups: []service.AccountGroup{{AccountID: 3, GroupID: 7, Group: richGroup}, {AccountID: 3, GroupID: 9, Group: historicalComposite}}, + } + svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account} + svc.accounts = []service.Account{account} + h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + h.cfg = &config.Config{RunMode: config.RunModeSimple} + r := gin.New() + r.GET("/accounts", h.List) + + res := httptest.NewRecorder() + r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil)) + require.Equal(t, http.StatusOK, res.Code, res.Body.String()) + require.NotEmpty(t, res.Header().Get("ETag")) + var payload struct { + Data struct { + Items []map[string]any `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload)) + require.Len(t, payload.Data.Items, 1) + require.Equal(t, []any{float64(7)}, payload.Data.Items[0]["group_ids"]) + require.NotContains(t, payload.Data.Items[0], "groups") + require.NotContains(t, payload.Data.Items[0], "account_groups") + + notModified := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil) + req.Header.Set("If-None-Match", res.Header().Get("ETag")) + r.ServeHTTP(notModified, req) + require.Equal(t, http.StatusNotModified, notModified.Code) +} + +func TestAccountHandlerSimpleModeRejectsCompositeGroupBindingsBeforeWrites(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct{ name, method, path, body string }{ + {"create", http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[9]}`}, + {"update", http.MethodPut, "/accounts/3", `{"group_ids":[9]}`}, + {"bulk", http.MethodPost, "/accounts/bulk-update", `{"account_ids":[3],"group_ids":[9]}`}, + } { + t.Run(tt.name, func(t *testing.T) { + svc := &simpleModeAccountService{stubAdminService: newStubAdminService()} + svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}} + h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + h.cfg = &config.Config{RunMode: config.RunModeSimple} + r := gin.New() + r.POST("/accounts", h.Create) + r.PUT("/accounts/:id", h.Update) + r.POST("/accounts/bulk-update", h.BulkUpdate) + res := httptest.NewRecorder() + req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(res, req) + require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String()) + require.Zero(t, svc.createCalls) + require.Zero(t, svc.updateCalls) + require.Zero(t, svc.bulkCalls) + }) + } +} + +func TestAccountHandlerSimpleModePreservesBasicGroupBinding(t *testing.T) { + svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: service.Account{ID: 3}} + svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}} + h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + h.cfg = &config.Config{RunMode: config.RunModeSimple} + r := gin.New() + r.POST("/accounts", h.Create) + res := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/accounts", bytes.NewBufferString(`{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(res, req) + require.Equal(t, http.StatusOK, res.Code, res.Body.String()) + require.Equal(t, 1, svc.createCalls) +} + +func TestAccountHandlerSimpleModeBatchPrevalidatesAllGroupsAtomically(t *testing.T) { + svc := &simpleModeAccountService{stubAdminService: newStubAdminService()} + svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}} + h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + h.cfg = &config.Config{RunMode: config.RunModeSimple} + r := gin.New() + r.POST("/accounts/batch", h.BatchCreate) + res := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/accounts/batch", bytes.NewBufferString(`{"accounts":[{"name":"first","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]},{"name":"second","platform":"anthropic","type":"apikey","credentials":{"key":"y"},"group_ids":[9]}]}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(res, req) + require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String()) + require.Zero(t, svc.createCalls) +} + +func TestAccountHandlerAdvancedModeKeepsFullGroupReferences(t *testing.T) { + group := &service.Group{ID: 7, Name: "advanced", RateMultiplier: 9, SubscriptionType: service.SubscriptionTypeSubscription} + h := NewAccountHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + raw, err := json.Marshal(h.buildAccountResponseWithRuntime(context.Background(), &service.Account{Groups: []*service.Group{group}})) + require.NoError(t, err) + require.Contains(t, string(raw), `"rate_multiplier":9`) + require.Contains(t, string(raw), `"subscription_type":"subscription"`) +} diff --git a/backend/internal/handler/admin/admin_basic_handlers_test.go b/backend/internal/handler/admin/admin_basic_handlers_test.go index fec393314b..66f1d00743 100644 --- a/backend/internal/handler/admin/admin_basic_handlers_test.go +++ b/backend/internal/handler/admin/admin_basic_handlers_test.go @@ -33,7 +33,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) { router.GET("/api/v1/admin/groups", groupHandler.List) router.GET("/api/v1/admin/groups/all", groupHandler.GetAll) - router.GET("/api/v1/admin/groups/:id/models-list-candidates", groupHandler.GetModelsListCandidates) + router.GET("/api/v1/admin/groups/:id/model-allowlist-candidates", groupHandler.GetGroupModelAllowlistCandidates) router.GET("/api/v1/admin/groups/:id/composite-routes", groupHandler.ListCompositeRoutes) router.POST("/api/v1/admin/groups/:id/composite-routes", groupHandler.CreateCompositeRoute) router.POST("/api/v1/admin/groups/:id/composite-routes/preview", groupHandler.PreviewCompositeRoute) @@ -184,7 +184,7 @@ func TestGroupHandlerEndpoints(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code) rec = httptest.NewRecorder() - req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/groups/0/models-list-candidates?platform=openai", nil) + req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/groups/0/model-allowlist-candidates?platform=openai", nil) router.ServeHTTP(rec, req) require.Equal(t, http.StatusOK, rec.Code) require.Contains(t, rec.Body.String(), "gpt-5.5") diff --git a/backend/internal/handler/admin/admin_helpers_test.go b/backend/internal/handler/admin/admin_helpers_test.go index c0775db6b6..980c26bab9 100644 --- a/backend/internal/handler/admin/admin_helpers_test.go +++ b/backend/internal/handler/admin/admin_helpers_test.go @@ -278,13 +278,15 @@ func TestOpenAIFastPolicySettingsFromDTO_NormalizesServiceTier(t *testing.T) { Rules: []dto.OpenAIFastPolicyRule{ {ServiceTier: "priority", Action: "filter", Scope: "all"}, {ServiceTier: "flex", Action: "block", Scope: "oauth"}, + {ServiceTier: "ultrafast", Action: "pass", Scope: "all"}, {ServiceTier: "all", Action: "pass", Scope: "apikey"}, }, } out := openaiFastPolicySettingsFromDTO(in) - require.Len(t, out.Rules, 3) + require.Len(t, out.Rules, 4) require.Equal(t, service.OpenAIFastTierPriority, out.Rules[0].ServiceTier) require.Equal(t, service.OpenAIFastTierFlex, out.Rules[1].ServiceTier) - require.Equal(t, service.OpenAIFastTierAny, out.Rules[2].ServiceTier) + require.Equal(t, service.OpenAIFastTierUltrafast, out.Rules[2].ServiceTier) + require.Equal(t, service.OpenAIFastTierAny, out.Rules[3].ServiceTier) }) } diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 4c3beb9b8a..c808d1a18c 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -24,6 +24,13 @@ type stubAdminService struct { boundAuthIdentity *service.AdminBindAuthIdentityInput boundAuthIdentityFor int64 createdAccounts []*service.CreateAccountInput + createdGroups []*service.CreateGroupInput + updatedGroups []*service.UpdateGroupInput + deletedGroupIDs []int64 + guardedDeletedGroupIDs []int64 + deleteGroupIfEmptyErr error + advancedGroupOperationCalls int + lastListGroupsIsExclusive *bool createdProxies []*service.CreateProxyInput updatedProxyIDs []int64 updatedProxies []*service.UpdateProxyInput @@ -267,6 +274,7 @@ func (s *stubAdminService) BindUserAuthIdentity(ctx context.Context, userID int6 } func (s *stubAdminService) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]service.Group, int64, error) { + s.lastListGroupsIsExclusive = isExclusive return s.groups, int64(len(s.groups)), nil } @@ -295,6 +303,7 @@ func (s *stubAdminService) GetGroupModelsListCandidates(ctx context.Context, id } func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int64) ([]service.CompositeModelRoute, error) { + s.advancedGroupOperationCalls++ return []service.CompositeModelRoute{ { ID: 1, @@ -311,6 +320,7 @@ func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int6 } func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) { + s.advancedGroupOperationCalls++ return &service.CompositeModelRoute{ ID: 1, GroupID: groupID, @@ -326,6 +336,7 @@ func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int } func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) { + s.advancedGroupOperationCalls++ return &service.CompositeModelRoute{ ID: routeID, GroupID: groupID, @@ -341,10 +352,12 @@ func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, ro } func (s *stubAdminService) DeleteCompositeRoute(ctx context.Context, groupID, routeID int64) error { + s.advancedGroupOperationCalls++ return nil } func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRoutePreviewRequest) (*service.CompositeRouteDecision, error) { + s.advancedGroupOperationCalls++ decision, err := service.NewCompositeRouteResolver(nil).Resolve(ctx, groupID, input.Model, input.Endpoint) if err != nil { return nil, err @@ -353,11 +366,13 @@ func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID in } func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.CreateGroupInput) (*service.Group, error) { + s.createdGroups = append(s.createdGroups, input) group := service.Group{ID: 200, Name: input.Name, Status: service.StatusActive} return &group, nil } func (s *stubAdminService) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) { + s.advancedGroupOperationCalls++ group := service.Group{ID: 201, Name: "group (Copy)", Status: "inactive"} return &group, nil } @@ -367,14 +382,21 @@ func (s *stubAdminService) RecoverDuplicateGroup(ctx context.Context, id int64, } func (s *stubAdminService) UpdateGroup(ctx context.Context, id int64, input *service.UpdateGroupInput) (*service.Group, error) { + s.updatedGroups = append(s.updatedGroups, input) group := service.Group{ID: id, Name: input.Name, Status: service.StatusActive} return &group, nil } func (s *stubAdminService) DeleteGroup(ctx context.Context, id int64) error { + s.deletedGroupIDs = append(s.deletedGroupIDs, id) return nil } +func (s *stubAdminService) DeleteGroupIfEmpty(ctx context.Context, id int64) error { + s.guardedDeletedGroupIDs = append(s.guardedDeletedGroupIDs, id) + return s.deleteGroupIfEmptyErr +} + func (s *stubAdminService) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]service.APIKey, int64, error) { return s.apiKeys, int64(len(s.apiKeys)), nil } @@ -384,10 +406,12 @@ func (s *stubAdminService) GetGroupRateMultipliers(_ context.Context, _ int64) ( } func (s *stubAdminService) ClearGroupRateMultipliers(_ context.Context, _ int64) error { + s.advancedGroupOperationCalls++ return nil } func (s *stubAdminService) BatchSetGroupRateMultipliers(_ context.Context, _ int64, _ []service.GroupRateMultiplierInput) error { + s.advancedGroupOperationCalls++ return nil } @@ -396,6 +420,7 @@ func (s *stubAdminService) ClearGroupRPMOverrides(_ context.Context, _ int64) er } func (s *stubAdminService) BatchSetGroupRPMOverrides(_ context.Context, _ int64, _ []service.GroupRPMOverrideInput) error { + s.advancedGroupOperationCalls++ return nil } @@ -491,6 +516,8 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre return &account, nil } +func (s *stubAdminService) ValidateAccountGroupBindings(context.Context, []int64) error { return nil } + func (s *stubAdminService) DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*service.Account, error) { account := service.Account{ID: 301, Name: "account (Copy)", Status: service.StatusActive, Schedulable: false} return &account, nil diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index 9723e3595b..e6612f5a0d 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -57,21 +57,22 @@ type updateChannelRequest struct { } type channelModelPricingRequest struct { - Platform string `json:"platform" binding:"omitempty,max=50"` - Models []string `json:"models" binding:"required,min=1,max=100"` - BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"` - InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"` - OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"` - CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"` - CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"` - FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"` - FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"` - ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"` - ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"` - PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"` - Intervals []pricingIntervalRequest `json:"intervals"` - TimePricing *channelTimePricingRequest `json:"time_pricing"` + Platform string `json:"platform" binding:"omitempty,max=50"` + Models []string `json:"models" binding:"required,min=1,max=100"` + BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"` + InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"` + OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"` + CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"` + CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"` + FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"` + FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"` + MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier" binding:"omitempty,gt=0"` + ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"` + ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"` + PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"` + Intervals []pricingIntervalRequest `json:"intervals"` + TimePricing *channelTimePricingRequest `json:"time_pricing"` } type channelTimePricingRequest struct { @@ -129,22 +130,23 @@ type channelResponse struct { } type channelModelPricingResponse struct { - ID int64 `json:"id"` - Platform string `json:"platform"` - Models []string `json:"models"` - BillingMode string `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - FastMultiplier *float64 `json:"fast_multiplier"` - FlexMultiplier *float64 `json:"flex_multiplier"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []pricingIntervalResponse `json:"intervals"` - TimePricing *channelTimePricingResponse `json:"time_pricing"` + ID int64 `json:"id"` + Platform string `json:"platform"` + Models []string `json:"models"` + BillingMode string `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + FastMultiplier *float64 `json:"fast_multiplier"` + FlexMultiplier *float64 `json:"flex_multiplier"` + MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []pricingIntervalResponse `json:"intervals"` + TimePricing *channelTimePricingResponse `json:"time_pricing"` } type channelTimePricingResponse struct { @@ -258,22 +260,23 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon intervals = append(intervals, intervalToResponse(iv)) } return channelModelPricingResponse{ - ID: p.ID, - Platform: platform, - Models: models, - BillingMode: billingMode, - InputPrice: p.InputPrice, - OutputPrice: p.OutputPrice, - CacheWritePrice: p.CacheWritePrice, - CacheWrite1hPrice: p.CacheWrite1hPrice, - CacheReadPrice: p.CacheReadPrice, - FastMultiplier: p.FastMultiplier, - FlexMultiplier: p.FlexMultiplier, - ImageInputPrice: p.ImageInputPrice, - ImageOutputPrice: p.ImageOutputPrice, - PerRequestPrice: p.PerRequestPrice, - Intervals: intervals, - TimePricing: timePricingToResponse(p.TimePricing), + ID: p.ID, + Platform: platform, + Models: models, + BillingMode: billingMode, + InputPrice: p.InputPrice, + OutputPrice: p.OutputPrice, + CacheWritePrice: p.CacheWritePrice, + CacheWrite1hPrice: p.CacheWrite1hPrice, + CacheReadPrice: p.CacheReadPrice, + FastMultiplier: p.FastMultiplier, + FlexMultiplier: p.FlexMultiplier, + MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier, + ImageInputPrice: p.ImageInputPrice, + ImageOutputPrice: p.ImageOutputPrice, + PerRequestPrice: p.PerRequestPrice, + Intervals: intervals, + TimePricing: timePricingToResponse(p.TimePricing), } } @@ -350,27 +353,29 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult SortOrder: iv.SortOrder, }) } - var fastMultiplier, flexMultiplier *float64 + var fastMultiplier, flexMultiplier, maxReasoningEffortMultiplier *float64 if allowChannelMultipliers { fastMultiplier = r.FastMultiplier flexMultiplier = r.FlexMultiplier + maxReasoningEffortMultiplier = r.MaxReasoningEffortMultiplier } result = append(result, service.ChannelModelPricing{ - Platform: platform, - Models: r.Models, - BillingMode: billingMode, - InputPrice: r.InputPrice, - OutputPrice: r.OutputPrice, - CacheWritePrice: r.CacheWritePrice, - CacheWrite1hPrice: r.CacheWrite1hPrice, - CacheReadPrice: r.CacheReadPrice, - FastMultiplier: fastMultiplier, - FlexMultiplier: flexMultiplier, - ImageInputPrice: r.ImageInputPrice, - ImageOutputPrice: r.ImageOutputPrice, - PerRequestPrice: r.PerRequestPrice, - Intervals: intervals, - TimePricing: timePricingRequestToService(r.TimePricing), + Platform: platform, + Models: r.Models, + BillingMode: billingMode, + InputPrice: r.InputPrice, + OutputPrice: r.OutputPrice, + CacheWritePrice: r.CacheWritePrice, + CacheWrite1hPrice: r.CacheWrite1hPrice, + CacheReadPrice: r.CacheReadPrice, + FastMultiplier: fastMultiplier, + FlexMultiplier: flexMultiplier, + MaxReasoningEffortMultiplier: maxReasoningEffortMultiplier, + ImageInputPrice: r.ImageInputPrice, + ImageOutputPrice: r.ImageOutputPrice, + PerRequestPrice: r.PerRequestPrice, + Intervals: intervals, + TimePricing: timePricingRequestToService(r.TimePricing), }) } return result @@ -615,14 +620,15 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) { } response.Success(c, gin.H{ - "found": true, - "input_price": pricing.InputPricePerToken, - "output_price": pricing.OutputPricePerToken, - "cache_write_price": cacheWritePrice, - "cache_write_1h_price": cacheWrite1hPrice, - "cache_read_price": pricing.CacheReadPricePerToken, - "image_input_price": pricing.ImageInputPricePerToken, - "image_output_price": pricing.ImageOutputPricePerToken, + "found": true, + "input_price": pricing.InputPricePerToken, + "output_price": pricing.OutputPricePerToken, + "cache_write_price": cacheWritePrice, + "cache_write_1h_price": cacheWrite1hPrice, + "cache_read_price": pricing.CacheReadPricePerToken, + "max_reasoning_effort_multiplier": pricing.MaxReasoningEffortMultiplier, + "image_input_price": pricing.ImageInputPricePerToken, + "image_output_price": pricing.ImageOutputPricePerToken, }) } @@ -637,6 +643,7 @@ var platformToLiteLLMProvider = map[string]string{ service.PlatformKimi: "moonshot", service.PlatformZhipu: "zhipu", service.PlatformDeepseek: "deepseek", + service.PlatformMiniMax: "minimax", } // SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表 diff --git a/backend/internal/handler/admin/channel_handler_test.go b/backend/internal/handler/admin/channel_handler_test.go index cca67c9e47..d735c54707 100644 --- a/backend/internal/handler/admin/channel_handler_test.go +++ b/backend/internal/handler/admin/channel_handler_test.go @@ -453,10 +453,11 @@ func TestPricingRequestToService_TimePricingNil(t *testing.T) { // 避免渠道倍率意外污染账号成本口径。 func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { req := channelModelPricingRequest{ - Models: []string{"gpt-5"}, - BillingMode: "token", - FastMultiplier: float64Ptr(2.5), - FlexMultiplier: float64Ptr(0.5), + Models: []string{"gpt-5"}, + BillingMode: "token", + FastMultiplier: float64Ptr(2.5), + FlexMultiplier: float64Ptr(0.5), + MaxReasoningEffortMultiplier: float64Ptr(3), Intervals: []pricingIntervalRequest{{ MinTokens: 272000, InputMultiplier: float64Ptr(2), @@ -469,6 +470,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { allowed := pricingRequestToService([]channelModelPricingRequest{req}, true) require.Equal(t, float64Ptr(2.5), allowed[0].FastMultiplier) require.Equal(t, float64Ptr(0.5), allowed[0].FlexMultiplier) + require.Equal(t, float64Ptr(3), allowed[0].MaxReasoningEffortMultiplier) require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].InputMultiplier) require.Equal(t, float64Ptr(1.5), allowed[0].Intervals[0].OutputMultiplier) require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheWriteMultiplier) @@ -477,6 +479,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) { dropped := pricingRequestToService([]channelModelPricingRequest{req}, false) require.Nil(t, dropped[0].FastMultiplier) require.Nil(t, dropped[0].FlexMultiplier) + require.Nil(t, dropped[0].MaxReasoningEffortMultiplier) require.Nil(t, dropped[0].Intervals[0].InputMultiplier) require.Nil(t, dropped[0].Intervals[0].OutputMultiplier) require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier) @@ -546,7 +549,7 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) { svc := service.NewPricingService(nil, nil) router := setupSyncPricingModelsRouter(svc) - for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek"} { + for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek", "minimax"} { req := httptest.NewRequest(http.MethodGet, "/channels/pricing/sync-models?platform="+platform, nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -580,9 +583,10 @@ func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) { require.Equal(t, http.StatusOK, w.Code) var body struct { Data struct { - Found bool `json:"found"` - CacheWritePrice float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + Found bool `json:"found"` + CacheWritePrice float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"` } `json:"data"` } require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) @@ -590,6 +594,8 @@ func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) { require.InDelta(t, 12.5e-6, body.Data.CacheWritePrice, 1e-12) require.NotNil(t, body.Data.CacheWrite1hPrice) require.InDelta(t, 20e-6, *body.Data.CacheWrite1hPrice, 1e-12) + require.NotNil(t, body.Data.MaxReasoningEffortMultiplier) + require.Equal(t, 3.0, *body.Data.MaxReasoningEffortMultiplier) } func TestGetModelDefaultPricing_OmitsUnsupportedCache1hPrice(t *testing.T) { diff --git a/backend/internal/handler/admin/channel_monitor_handler.go b/backend/internal/handler/admin/channel_monitor_handler.go index 4832be2dec..e0fdb46842 100644 --- a/backend/internal/handler/admin/channel_monitor_handler.go +++ b/backend/internal/handler/admin/channel_monitor_handler.go @@ -40,7 +40,7 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch type channelMonitorCreateRequest struct { Name string `json:"name" binding:"required,max=100"` - Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"` + Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"` APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"` Endpoint string `json:"endpoint" binding:"omitempty,max=500"` APIKey string `json:"api_key" binding:"omitempty,max=2000"` @@ -64,7 +64,7 @@ type channelMonitorCreateRequest struct { type channelMonitorUpdateRequest struct { Name *string `json:"name" binding:"omitempty,max=100"` - Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"` + Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"` APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"` Endpoint *string `json:"endpoint" binding:"omitempty,max=500"` APIKey *string `json:"api_key" binding:"omitempty,max=2000"` diff --git a/backend/internal/handler/admin/grok_import_probe.go b/backend/internal/handler/admin/grok_import_probe.go index da547b82f8..8dad7160c2 100644 --- a/backend/internal/handler/admin/grok_import_probe.go +++ b/backend/internal/handler/admin/grok_import_probe.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/service" ) @@ -194,6 +195,7 @@ func (h *GrokOAuthHandler) scheduleGrokImportProbe(account *service.Account) { // ProvideAccountHandler injects the Grok active prober for production while // keeping NewAccountHandler convenient for focused unit tests. func ProvideAccountHandler( + cfg *config.Config, adminService service.AdminService, oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, @@ -227,5 +229,6 @@ func ProvideAccountHandler( tokenCacheInvalidator, ) handler.grokImportProber = grokQuotaService + handler.cfg = cfg return handler } diff --git a/backend/internal/handler/admin/grok_media_eligibility_handler.go b/backend/internal/handler/admin/grok_media_eligibility_handler.go new file mode 100644 index 0000000000..60ddbe3944 --- /dev/null +++ b/backend/internal/handler/admin/grok_media_eligibility_handler.go @@ -0,0 +1,144 @@ +package admin + +import ( + "strconv" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +const ( + grokMediaEligibilityModeAuto = "auto" + grokMediaEligibilityModeEnabled = "enabled" + grokMediaEligibilityModeDisabled = "disabled" +) + +type grokMediaEligibilityResponse struct { + AccountID int64 `json:"account_id"` + Mode string `json:"mode"` + Eligible bool `json:"eligible"` + Reason string `json:"reason"` +} + +type updateGrokMediaEligibilityRequest struct { + Mode string `json:"mode" binding:"required"` +} + +// GetGrokMediaEligibility returns the current manual override and evaluated +// media-routing decision for a Grok OAuth account. +// GET /api/v1/admin/accounts/:id/grok-media-eligibility +func (h *AccountHandler) GetGrokMediaEligibility(c *gin.Context) { + account, err := h.getGrokOAuthAccount(c) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, buildGrokMediaEligibilityResponse(account)) +} + +// UpdateGrokMediaEligibility updates only the manual media eligibility +// override. It deliberately uses UpdateAccountExtra so unrelated account +// extra fields are preserved. +// PUT /api/v1/admin/accounts/:id/grok-media-eligibility +func (h *AccountHandler) UpdateGrokMediaEligibility(c *gin.Context) { + account, err := h.getGrokOAuthAccount(c) + if err != nil { + response.ErrorFrom(c, err) + return + } + + var req updateGrokMediaEligibilityRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + var value any + switch req.Mode { + case grokMediaEligibilityModeAuto: + value = nil + case grokMediaEligibilityModeEnabled: + value = true + case grokMediaEligibilityModeDisabled: + value = false + default: + response.ErrorFrom(c, infraerrors.BadRequest( + "GROK_MEDIA_ELIGIBILITY_INVALID_MODE", + "mode must be one of auto, enabled, disabled", + )) + return + } + + updates := map[string]any{service.GrokMediaEligibleExtraKey: value} + if err := service.ValidateGrokMediaEligibilityExtra(account.Platform, updates); err != nil { + response.ErrorFrom(c, err) + return + } + if err := h.adminService.UpdateAccountExtra(c.Request.Context(), account.ID, updates); err != nil { + response.ErrorFrom(c, err) + return + } + + updated, err := h.adminService.GetAccount(c.Request.Context(), account.ID) + if err != nil { + response.ErrorFrom(c, err) + return + } + if err := ensureGrokOAuthAccount(updated); err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, buildGrokMediaEligibilityResponse(updated)) +} + +func (h *AccountHandler) getGrokOAuthAccount(c *gin.Context) (*service.Account, error) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + return nil, infraerrors.BadRequest("INVALID_ACCOUNT_ID", "Invalid account ID") + } + account, err := h.adminService.GetAccount(c.Request.Context(), accountID) + if err != nil { + return nil, err + } + if err := ensureGrokOAuthAccount(account); err != nil { + return nil, err + } + return account, nil +} + +func ensureGrokOAuthAccount(account *service.Account) error { + if account == nil { + return infraerrors.NotFound("ACCOUNT_NOT_FOUND", "account not found") + } + if account.Platform != service.PlatformGrok || account.Type != service.AccountTypeOAuth { + return infraerrors.BadRequest( + "GROK_MEDIA_ELIGIBILITY_UNSUPPORTED_ACCOUNT", + "media eligibility is only supported for Grok OAuth accounts", + ) + } + return nil +} + +func buildGrokMediaEligibilityResponse(account *service.Account) grokMediaEligibilityResponse { + eligible, reason := account.GrokMediaGenerationEligibility() + return grokMediaEligibilityResponse{ + AccountID: account.ID, + Mode: grokMediaEligibilityModeFromExtra(account.Extra), + Eligible: eligible, + Reason: reason, + } +} + +func grokMediaEligibilityModeFromExtra(extra map[string]any) string { + if extra != nil { + if enabled, ok := extra[service.GrokMediaEligibleExtraKey].(bool); ok { + if enabled { + return grokMediaEligibilityModeEnabled + } + return grokMediaEligibilityModeDisabled + } + } + return grokMediaEligibilityModeAuto +} diff --git a/backend/internal/handler/admin/grok_media_eligibility_handler_test.go b/backend/internal/handler/admin/grok_media_eligibility_handler_test.go new file mode 100644 index 0000000000..7691d58667 --- /dev/null +++ b/backend/internal/handler/admin/grok_media_eligibility_handler_test.go @@ -0,0 +1,146 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type grokMediaEligibilityAdminServiceStub struct { + *stubAdminService + account *service.Account +} + +func (s *grokMediaEligibilityAdminServiceStub) GetAccount(_ context.Context, _ int64) (*service.Account, error) { + return s.account, nil +} + +func (s *grokMediaEligibilityAdminServiceStub) UpdateAccountExtra(_ context.Context, _ int64, updates map[string]any) error { + if s.account.Extra == nil { + s.account.Extra = map[string]any{} + } + for key, value := range updates { + if value == nil { + delete(s.account.Extra, key) + } else { + s.account.Extra[key] = value + } + } + return nil +} + +func newGrokMediaEligibilityRouter(account *service.Account) *gin.Engine { + stub := &grokMediaEligibilityAdminServiceStub{ + stubAdminService: newStubAdminService(), + account: account, + } + h := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router := gin.New() + router.GET("/accounts/:id/grok-media-eligibility", h.GetGrokMediaEligibility) + router.PUT("/accounts/:id/grok-media-eligibility", h.UpdateGrokMediaEligibility) + return router +} + +func TestGrokMediaEligibilityHandlerGetReturnsModeAndReason(t *testing.T) { + gin.SetMode(gin.TestMode) + router := newGrokMediaEligibilityRouter(&service.Account{ + ID: 12, + Platform: service.PlatformGrok, + Type: service.AccountTypeOAuth, + Extra: map[string]any{ + "grok_billing_snapshot": map[string]any{"status_code": 200, "partial": true}, + "unrelated_setting": "preserve", + }, + }) + + req := httptest.NewRequest(http.MethodGet, "/accounts/12/grok-media-eligibility", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var envelope struct { + Data grokMediaEligibilityResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope)) + require.Equal(t, int64(12), envelope.Data.AccountID) + require.Equal(t, grokMediaEligibilityModeAuto, envelope.Data.Mode) + require.Equal(t, "billing_inconclusive", envelope.Data.Reason) +} + +func TestGrokMediaEligibilityHandlerUpdateModesPreservesExtra(t *testing.T) { + gin.SetMode(gin.TestMode) + account := &service.Account{ + ID: 12, + Platform: service.PlatformGrok, + Type: service.AccountTypeOAuth, + Extra: map[string]any{"unrelated_setting": "preserve"}, + } + router := newGrokMediaEligibilityRouter(account) + + tests := []struct { + mode string + wantMode string + wantExtra any + wantEligible bool + wantReason string + }{ + {mode: "enabled", wantMode: grokMediaEligibilityModeEnabled, wantExtra: true, wantEligible: true, wantReason: "override_enabled"}, + {mode: "disabled", wantMode: grokMediaEligibilityModeDisabled, wantExtra: false, wantEligible: false, wantReason: "override_disabled"}, + {mode: "auto", wantMode: grokMediaEligibilityModeAuto, wantExtra: nil, wantEligible: false, wantReason: "billing_unobserved"}, + } + + for _, tt := range tests { + t.Run(tt.mode, func(t *testing.T) { + body, err := json.Marshal(map[string]string{"mode": tt.mode}) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPut, "/accounts/12/grok-media-eligibility", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var envelope struct { + Data grokMediaEligibilityResponse `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope)) + require.Equal(t, tt.wantMode, envelope.Data.Mode) + require.Equal(t, tt.wantEligible, envelope.Data.Eligible) + require.Equal(t, tt.wantReason, envelope.Data.Reason) + require.Equal(t, "preserve", account.Extra["unrelated_setting"]) + if tt.wantExtra == nil { + _, exists := account.Extra[service.GrokMediaEligibleExtraKey] + require.False(t, exists) + } else { + require.Equal(t, tt.wantExtra, account.Extra[service.GrokMediaEligibleExtraKey]) + } + }) + } +} + +func TestGrokMediaEligibilityHandlerRejectsUnsupportedAndInvalidModes(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tt := range []struct { + name string + account *service.Account + body string + }{ + {name: "unsupported account", account: &service.Account{ID: 1, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, body: `{"mode":"enabled"}`}, + {name: "invalid mode", account: &service.Account{ID: 1, Platform: service.PlatformGrok, Type: service.AccountTypeOAuth}, body: `{"mode":"force"}`}, + } { + t.Run(tt.name, func(t *testing.T) { + router := newGrokMediaEligibilityRouter(tt.account) + req := httptest.NewRequest(http.MethodPut, "/accounts/1/grok-media-eligibility", bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code) + }) + } +} diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index fa6991ee66..638316a324 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler/dto" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/response" @@ -24,10 +25,14 @@ type GroupHandler struct { adminService service.AdminService dashboardService *service.DashboardService groupCapacityService *service.GroupCapacityService + cfg *config.Config } // GetLiveCapability 返回当前服务端是否具备生成 Live attestation 的运行环境。 func (h *GroupHandler) GetLiveCapability(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "live_capability") { + return + } err := liveattestation.NewProvider().Check(c.Request.Context()) result := gin.H{"supported": err == nil} if err != nil { @@ -87,18 +92,99 @@ func (f optionalLimitField) ToServiceInput() *float64 { // NewGroupHandler creates a new admin group handler func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler { + return NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, nil) +} + +func NewGroupHandlerWithConfig(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService, cfg *config.Config) *GroupHandler { return &GroupHandler{ adminService: adminService, dashboardService: dashboardService, groupCapacityService: groupCapacityService, + cfg: cfg, } } +func (h *GroupHandler) isSimpleMode() bool { + return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple +} + +type simpleModeGroupOperation string + +const ( + simpleModeGroupList simpleModeGroupOperation = "list" + simpleModeGroupGetAll simpleModeGroupOperation = "get_all" + simpleModeGroupGet simpleModeGroupOperation = "get" + simpleModeGroupCreate simpleModeGroupOperation = "create" + simpleModeGroupUpdate simpleModeGroupOperation = "update" + simpleModeGroupDelete simpleModeGroupOperation = "delete" +) + +var simpleModeGroupOperations = map[simpleModeGroupOperation]struct{}{ + simpleModeGroupList: {}, simpleModeGroupGetAll: {}, simpleModeGroupGet: {}, + simpleModeGroupCreate: {}, simpleModeGroupUpdate: {}, simpleModeGroupDelete: {}, +} + +func (h *GroupHandler) rejectUnsupportedSimpleModeOperation(c *gin.Context, operation simpleModeGroupOperation) bool { + if _, allowed := simpleModeGroupOperations[operation]; allowed { + return false + } + if err := service.ValidateSimpleModeGroupOperation(h.cfg, service.AdminGroupOperation(operation)); err != nil { + response.ErrorFrom(c, err) + return true + } + return false +} + +type simpleModeGroupResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Platform string `json:"platform"` + Status string `json:"status"` + + AccountCount int64 `json:"account_count,omitempty"` + ActiveAccountCount int64 `json:"active_account_count,omitempty"` + RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func groupForSimpleMode(group *service.Group) *simpleModeGroupResponse { + if group == nil { + return nil + } + return &simpleModeGroupResponse{ + ID: group.ID, Name: group.Name, Description: group.Description, Platform: group.Platform, + Status: group.Status, + AccountCount: group.AccountCount, + ActiveAccountCount: group.ActiveAccountCount, RateLimitedAccountCount: group.RateLimitedAccountCount, + SortOrder: group.SortOrder, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt, + } +} + +func sanitizeCreateGroupRequestForSimpleMode(req *CreateGroupRequest) { + if req == nil { + return + } + allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, Platform: req.Platform} + allowed.RateMultiplier = 1 + allowed.SubscriptionType = service.SubscriptionTypeStandard + *req = allowed +} + +func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) { + if req == nil { + return + } + *req = UpdateGroupRequest{Name: req.Name, Description: req.Description} +} + // CreateGroupRequest represents create group request type CreateGroupRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` - Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"` + Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"` RateMultiplier float64 `json:"rate_multiplier"` IsExclusive bool `json:"is_exclusive"` SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` @@ -153,14 +239,16 @@ type CreateGroupRequest struct { RequirePrivacySet bool `json:"require_privacy_set"` DefaultMappedModel string `json:"default_mapped_model"` MessagesDispatchModelConfig service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"` - ModelsListConfig service.GroupModelsListConfig `json:"models_list_config"` + ModelAllowlist service.GroupModelAllowlist `json:"model_allowlist"` + // 固定账号 manifest 配置;创建路径禁止开启,仅编辑可配置。 + CodexModelsManifestConfig service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"` // 分组 RPM 上限(0 = 不限制) RPMLimit int `json:"rpm_limit"` - // OpenAI/Codex 请求推理强度上限,空字符串表示不限制。 + // Anthropic/OpenAI 请求推理强度上限,空字符串表示不限制。 MaxReasoningEffort string `json:"max_reasoning_effort"` // 超过上限时的访问控制:downgrade(默认)或 deny。 MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"` - // OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。 + // Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。 ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"` // 从指定分组复制账号(创建后自动绑定) CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"` @@ -170,7 +258,7 @@ type CreateGroupRequest struct { type UpdateGroupRequest struct { Name string `json:"name"` Description *string `json:"description"` - Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"` + Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"` RateMultiplier *float64 `json:"rate_multiplier"` IsExclusive *bool `json:"is_exclusive"` Status string `json:"status" binding:"omitempty,oneof=active inactive"` @@ -226,10 +314,12 @@ type UpdateGroupRequest struct { RequirePrivacySet *bool `json:"require_privacy_set"` DefaultMappedModel *string `json:"default_mapped_model"` MessagesDispatchModelConfig *service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"` - ModelsListConfig *service.GroupModelsListConfig `json:"models_list_config"` + ModelAllowlist *service.GroupModelAllowlist `json:"model_allowlist"` + // 固定账号 manifest 配置;nil 表示不修改。 + CodexModelsManifestConfig *service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"` // 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动 RPMLimit *int `json:"rpm_limit"` - // OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。 + // Anthropic/OpenAI 请求推理强度上限;空字符串清除,nil 不修改。 MaxReasoningEffort *string `json:"max_reasoning_effort"` // 超过上限时的访问控制;空字符串视为 downgrade,nil 不修改。 MaxReasoningEffortOverLimit *string `json:"max_reasoning_effort_over_limit"` @@ -242,7 +332,7 @@ type UpdateGroupRequest struct { type CompositeRouteRequest struct { PublicModel string `json:"public_model" binding:"required"` MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"` - TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek"` + TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax"` UpstreamModel string `json:"upstream_model"` Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"` Priority int `json:"priority"` @@ -258,6 +348,9 @@ type CompositeRoutePreviewRequest struct { // List handles listing all groups with pagination // GET /api/v1/admin/groups func (h *GroupHandler) List(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupList) { + return + } page, pageSize := response.ParsePagination(c) platform := c.Query("platform") status := c.Query("status") @@ -272,7 +365,7 @@ func (h *GroupHandler) List(c *gin.Context) { sortOrder := c.DefaultQuery("sort_order", "asc") var isExclusive *bool - if isExclusiveStr != "" { + if !h.isSimpleMode() && isExclusiveStr != "" { val := isExclusiveStr == "true" isExclusive = &val } @@ -283,6 +376,16 @@ func (h *GroupHandler) List(c *gin.Context) { return } + if h.isSimpleMode() { + simpleGroups := make([]simpleModeGroupResponse, 0, len(groups)) + for i := range groups { + if service.IsGroupBindableInSimpleMode(&groups[i]) { + simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i])) + } + } + response.Paginated(c, simpleGroups, total, page, pageSize) + return + } outGroups := make([]dto.AdminGroup, 0, len(groups)) for i := range groups { outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i])) @@ -293,6 +396,9 @@ func (h *GroupHandler) List(c *gin.Context) { // ListCompositeRoutes handles listing composite model routes for one group. // GET /api/v1/admin/groups/:id/composite-routes func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, ok := parsePositiveIDParam(c, "id") if !ok { return @@ -308,6 +414,9 @@ func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) { // CreateCompositeRoute handles creating one composite model route. // POST /api/v1/admin/groups/:id/composite-routes func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, ok := parsePositiveIDParam(c, "id") if !ok { return @@ -328,6 +437,9 @@ func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) { // UpdateCompositeRoute handles replacing one composite model route. // PUT /api/v1/admin/groups/:id/composite-routes/:route_id func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, ok := parsePositiveIDParam(c, "id") if !ok { return @@ -352,6 +464,9 @@ func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) { // DeleteCompositeRoute handles deleting one composite model route. // DELETE /api/v1/admin/groups/:id/composite-routes/:route_id func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, ok := parsePositiveIDParam(c, "id") if !ok { return @@ -370,6 +485,9 @@ func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) { // PreviewCompositeRoute resolves a model without mutating routes. // POST /api/v1/admin/groups/:id/composite-routes/preview func (h *GroupHandler) PreviewCompositeRoute(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, ok := parsePositiveIDParam(c, "id") if !ok { return @@ -423,6 +541,9 @@ func parsePositiveIDParam(c *gin.Context, name string) (int64, bool) { // bound to them even after the group is disabled). // GET /api/v1/admin/groups/all func (h *GroupHandler) GetAll(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGetAll) { + return + } platform := c.Query("platform") includeInactive := c.Query("include_inactive") == "true" @@ -442,6 +563,16 @@ func (h *GroupHandler) GetAll(c *gin.Context) { return } + if h.isSimpleMode() { + simpleGroups := make([]simpleModeGroupResponse, 0, len(groups)) + for i := range groups { + if service.IsGroupBindableInSimpleMode(&groups[i]) { + simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i])) + } + } + response.Success(c, simpleGroups) + return + } outGroups := make([]dto.AdminGroup, 0, len(groups)) for i := range groups { outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i])) @@ -452,6 +583,9 @@ func (h *GroupHandler) GetAll(c *gin.Context) { // GetByID handles getting a group by ID // GET /api/v1/admin/groups/:id func (h *GroupHandler) GetByID(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGet) { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -464,12 +598,19 @@ func (h *GroupHandler) GetByID(c *gin.Context) { return } + if h.isSimpleMode() { + response.Success(c, groupForSimpleMode(group)) + return + } response.Success(c, dto.GroupFromServiceAdmin(group)) } -// GetModelsListCandidates handles getting candidate model IDs for custom /v1/models list. -// GET /api/v1/admin/groups/:id/models-list-candidates -func (h *GroupHandler) GetModelsListCandidates(c *gin.Context) { +// GetGroupModelAllowlistCandidates handles getting candidate model IDs for the group model allowlist. +// GET /api/v1/admin/groups/:id/model-allowlist-candidates +func (h *GroupHandler) GetGroupModelAllowlistCandidates(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil || groupID < 0 { response.BadRequest(c, "Invalid group ID") @@ -492,11 +633,21 @@ func (h *GroupHandler) GetModelsListCandidates(c *gin.Context) { // Create handles creating a new group // POST /api/v1/admin/groups func (h *GroupHandler) Create(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupCreate) { + return + } var req CreateGroupRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } + if h.isSimpleMode() && req.Platform == service.PlatformComposite { + response.BadRequest(c, "Platform is not supported in simple mode") + return + } + if h.isSimpleMode() { + sanitizeCreateGroupRequestForSimpleMode(&req) + } if err := service.ValidatePeakRateConfig(req.SubscriptionType, req.PeakRateEnabled, req.PeakStart, req.PeakEnd, float64ValueOrDefault(req.PeakRateMultiplier, 1.0)); err != nil { response.BadRequest(c, err.Error()) @@ -564,7 +715,8 @@ func (h *GroupHandler) Create(c *gin.Context) { RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, MessagesDispatchModelConfig: req.MessagesDispatchModelConfig, - ModelsListConfig: req.ModelsListConfig, + ModelAllowlist: req.ModelAllowlist, + CodexModelsManifestConfig: req.CodexModelsManifestConfig, RPMLimit: req.RPMLimit, MaxReasoningEffort: req.MaxReasoningEffort, MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit, @@ -576,12 +728,19 @@ func (h *GroupHandler) Create(c *gin.Context) { return } + if h.isSimpleMode() { + response.Success(c, groupForSimpleMode(group)) + return + } response.Success(c, dto.GroupFromServiceAdmin(group)) } // Duplicate handles creating an inactive group copy with the source account bindings. // POST /api/v1/admin/groups/:id/duplicate func (h *GroupHandler) Duplicate(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil || groupID <= 0 { response.BadRequest(c, "Invalid group ID") @@ -629,6 +788,9 @@ func (h *GroupHandler) Duplicate(c *gin.Context) { // Update handles updating a group // PUT /api/v1/admin/groups/:id func (h *GroupHandler) Update(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupUpdate) { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -640,6 +802,9 @@ func (h *GroupHandler) Update(c *gin.Context) { response.BadRequest(c, "Invalid request: "+err.Error()) return } + if h.isSimpleMode() { + sanitizeUpdateGroupRequestForSimpleMode(&req) + } group, err := h.adminService.UpdateGroup(c.Request.Context(), groupID, &service.UpdateGroupInput{ Name: req.Name, @@ -696,7 +861,8 @@ func (h *GroupHandler) Update(c *gin.Context) { RequirePrivacySet: req.RequirePrivacySet, DefaultMappedModel: req.DefaultMappedModel, MessagesDispatchModelConfig: req.MessagesDispatchModelConfig, - ModelsListConfig: req.ModelsListConfig, + ModelAllowlist: req.ModelAllowlist, + CodexModelsManifestConfig: req.CodexModelsManifestConfig, RPMLimit: req.RPMLimit, MaxReasoningEffort: req.MaxReasoningEffort, MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit, @@ -708,19 +874,30 @@ func (h *GroupHandler) Update(c *gin.Context) { return } + if h.isSimpleMode() { + response.Success(c, groupForSimpleMode(group)) + return + } response.Success(c, dto.GroupFromServiceAdmin(group)) } // Delete handles deleting a group // DELETE /api/v1/admin/groups/:id func (h *GroupHandler) Delete(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupDelete) { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") return } - err = h.adminService.DeleteGroup(c.Request.Context(), groupID) + if h.isSimpleMode() { + err = h.adminService.DeleteGroupIfEmpty(c.Request.Context(), groupID) + } else { + err = h.adminService.DeleteGroup(c.Request.Context(), groupID) + } if err != nil { response.ErrorFrom(c, err) return @@ -732,6 +909,9 @@ func (h *GroupHandler) Delete(c *gin.Context) { // GetStats handles getting group statistics // GET /api/v1/admin/groups/:id/stats func (h *GroupHandler) GetStats(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "stats") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -751,6 +931,9 @@ func (h *GroupHandler) GetStats(c *gin.Context) { // GetUsageSummary returns today's, yesterday's, and cumulative cost for all groups. // GET /api/v1/admin/groups/usage-summary func (h *GroupHandler) GetUsageSummary(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } todayStart := service.GroupUsageTodayStart(time.Now()) results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart) @@ -765,6 +948,9 @@ func (h *GroupHandler) GetUsageSummary(c *gin.Context) { // GetCapacitySummary returns aggregated capacity (concurrency/sessions/RPM) for all active groups. // GET /api/v1/admin/groups/capacity-summary func (h *GroupHandler) GetCapacitySummary(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } results, err := h.groupCapacityService.GetAllGroupCapacity(c.Request.Context()) if err != nil { response.Error(c, 500, "Failed to get group capacity summary") @@ -776,6 +962,9 @@ func (h *GroupHandler) GetCapacitySummary(c *gin.Context) { // GetGroupAPIKeys handles getting API keys in a group // GET /api/v1/admin/groups/:id/api-keys func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "api_keys") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -800,6 +989,9 @@ func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) { // GetGroupRateMultipliers handles getting rate multipliers for users in a group // GET /api/v1/admin/groups/:id/rate-multipliers func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -821,6 +1013,9 @@ func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) { // ClearGroupRateMultipliers handles clearing all rate multipliers for a group // DELETE /api/v1/admin/groups/:id/rate-multipliers func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -843,6 +1038,9 @@ type BatchSetGroupRateMultipliersRequest struct { // BatchSetGroupRateMultipliers handles batch setting rate multipliers for a group // PUT /api/v1/admin/groups/:id/rate-multipliers func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -871,6 +1069,9 @@ type BatchSetGroupRPMOverridesRequest struct { // BatchSetGroupRPMOverrides handles batch setting rpm_override for users in a group // PUT /api/v1/admin/groups/:id/rpm-overrides func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -894,6 +1095,9 @@ func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) { // ClearGroupRPMOverrides handles clearing all rpm_override for a group // DELETE /api/v1/admin/groups/:id/rpm-overrides func (h *GroupHandler) ClearGroupRPMOverrides(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) if err != nil { response.BadRequest(c, "Invalid group ID") @@ -919,6 +1123,9 @@ type UpdateSortOrderRequest struct { // UpdateSortOrder handles updating group sort orders // PUT /api/v1/admin/groups/sort-order func (h *GroupHandler) UpdateSortOrder(c *gin.Context) { + if h.rejectUnsupportedSimpleModeOperation(c, "advanced") { + return + } var req UpdateSortOrderRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) diff --git a/backend/internal/handler/admin/group_handler_platform_test.go b/backend/internal/handler/admin/group_handler_platform_test.go index aeba6d1c5a..38452ec286 100644 --- a/backend/internal/handler/admin/group_handler_platform_test.go +++ b/backend/internal/handler/admin/group_handler_platform_test.go @@ -27,7 +27,7 @@ func bindGroupPlatformJSON(t *testing.T, target any, body string) error { func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) { allowed := []string{ "anthropic", "openai", "gemini", "antigravity", "grok", - "kimi", "zhipu", "deepseek", "composite", + "kimi", "zhipu", "deepseek", "minimax", "composite", } for _, platform := range allowed { t.Run("create_"+platform, func(t *testing.T) { @@ -72,7 +72,7 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) { } func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) { - for _, platform := range []string{"kimi", "zhipu", "deepseek"} { + for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax"} { var req CompositeRouteRequest body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform) require.NoError(t, bindGroupPlatformJSON(t, &req, body)) diff --git a/backend/internal/handler/admin/group_handler_simple_mode_test.go b/backend/internal/handler/admin/group_handler_simple_mode_test.go new file mode 100644 index 0000000000..76c3ce321f --- /dev/null +++ b/backend/internal/handler/admin/group_handler_simple_mode_test.go @@ -0,0 +1,309 @@ +package admin + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func newSimpleModeGroupRouter(svc *stubAdminService) *gin.Engine { + h := NewGroupHandlerWithConfig(svc, nil, nil, &config.Config{RunMode: config.RunModeSimple}) + r := gin.New() + r.GET("/groups", h.List) + r.GET("/groups/all", h.GetAll) + r.GET("/groups/live-capability", h.GetLiveCapability) + r.GET("/groups/usage-summary", h.GetUsageSummary) + r.GET("/groups/capacity-summary", h.GetCapacitySummary) + r.PUT("/groups/sort-order", h.UpdateSortOrder) + r.GET("/groups/:id", h.GetByID) + r.GET("/groups/:id/stats", h.GetStats) + r.GET("/groups/:id/api-keys", h.GetGroupAPIKeys) + r.GET("/groups/:id/model-allowlist-candidates", h.GetGroupModelAllowlistCandidates) + r.POST("/groups", h.Create) + r.PUT("/groups/:id", h.Update) + r.POST("/groups/:id/duplicate", h.Duplicate) + r.GET("/groups/:id/composite-routes", h.ListCompositeRoutes) + r.POST("/groups/:id/composite-routes", h.CreateCompositeRoute) + r.POST("/groups/:id/composite-routes/preview", h.PreviewCompositeRoute) + r.PUT("/groups/:id/composite-routes/:route_id", h.UpdateCompositeRoute) + r.DELETE("/groups/:id/composite-routes/:route_id", h.DeleteCompositeRoute) + r.GET("/groups/:id/rate-multipliers", h.GetGroupRateMultipliers) + r.PUT("/groups/:id/rate-multipliers", h.BatchSetGroupRateMultipliers) + r.DELETE("/groups/:id/rate-multipliers", h.ClearGroupRateMultipliers) + r.PUT("/groups/:id/rpm-overrides", h.BatchSetGroupRPMOverrides) + r.DELETE("/groups/:id/rpm-overrides", h.ClearGroupRPMOverrides) + r.DELETE("/groups/:id", h.Delete) + return r +} + +func TestGroupHandlerSimpleModeSanitizesCommercialFields(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newStubAdminService() + r := newSimpleModeGroupRouter(svc) + + create := `{"name":"simple","description":"basic grouping","platform":"anthropic","rate_multiplier":7,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":10,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"rpm_limit":99}` + req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(create)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + r.ServeHTTP(res, req) + require.Equal(t, http.StatusOK, res.Code) + require.Len(t, svc.createdGroups, 1) + created := svc.createdGroups[0] + require.Equal(t, "basic grouping", created.Description) + require.Equal(t, 1.0, created.RateMultiplier) + require.False(t, created.IsExclusive) + require.Equal(t, service.SubscriptionTypeStandard, created.SubscriptionType) + require.Nil(t, created.DailyLimitUSD) + require.False(t, created.AllowImageGeneration) + require.False(t, created.LongContextPricingEnabled) + require.Empty(t, created.ModelPricing) + require.False(t, created.AllowBatchImageGeneration) + require.Nil(t, created.VideoPrice720P) + require.Nil(t, created.WebSearchPricePerCall) + require.Nil(t, created.AudioRealtimePricePerMin) + require.Zero(t, created.RPMLimit) + + update := `{"name":"renamed","description":"still basic","rate_multiplier":9,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":12,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"status":"inactive","rpm_limit":123}` + req = httptest.NewRequest(http.MethodPut, "/groups/2", bytes.NewBufferString(update)) + req.Header.Set("Content-Type", "application/json") + res = httptest.NewRecorder() + r.ServeHTTP(res, req) + require.Equal(t, http.StatusOK, res.Code) + require.Len(t, svc.updatedGroups, 1) + updated := svc.updatedGroups[0] + require.NotNil(t, updated.Description) + require.Equal(t, "still basic", *updated.Description) + require.Nil(t, updated.RateMultiplier) + require.Nil(t, updated.IsExclusive) + require.Empty(t, updated.SubscriptionType) + require.Nil(t, updated.DailyLimitUSD) + require.Nil(t, updated.AllowImageGeneration) + require.Nil(t, updated.LongContextPricingEnabled) + require.Nil(t, updated.ModelPricing) + require.Nil(t, updated.AllowBatchImageGeneration) + require.Nil(t, updated.VideoPrice720P) + require.Nil(t, updated.WebSearchPricePerCall) + require.Nil(t, updated.AudioRealtimePricePerMin) + require.Empty(t, updated.Status) + require.Nil(t, updated.RPMLimit) +} + +func TestGroupHandlerSimpleModeRejectsCompositeBeforeService(t *testing.T) { + svc := newStubAdminService() + r := newSimpleModeGroupRouter(svc) + req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(`{"name":"composite","platform":"composite"}`)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + r.ServeHTTP(res, req) + require.Equal(t, http.StatusBadRequest, res.Code) + require.Empty(t, svc.createdGroups) +} + +func TestGroupHandlerSimpleModeDeleteRejectsNonEmptyGroupBeforeCascade(t *testing.T) { + svc := newStubAdminService() + svc.deleteGroupIfEmptyErr = service.ErrGroupNotEmpty + r := newSimpleModeGroupRouter(svc) + res := httptest.NewRecorder() + + r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/7", nil)) + + require.Equal(t, http.StatusConflict, res.Code) + require.Equal(t, []int64{7}, svc.guardedDeletedGroupIDs) + require.Empty(t, svc.deletedGroupIDs) +} + +func TestGroupHandlerSimpleModeDeletePermitsEmptyGroup(t *testing.T) { + svc := newStubAdminService() + r := newSimpleModeGroupRouter(svc) + res := httptest.NewRecorder() + + r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/8", nil)) + + require.Equal(t, http.StatusOK, res.Code) + require.Equal(t, []int64{8}, svc.guardedDeletedGroupIDs) + require.Empty(t, svc.deletedGroupIDs) +} + +func TestGroupHandlerSimpleModeIgnoresExclusiveFilter(t *testing.T) { + svc := newStubAdminService() + r := newSimpleModeGroupRouter(svc) + res := httptest.NewRecorder() + r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups?is_exclusive=true", nil)) + require.Equal(t, http.StatusOK, res.Code) + require.Nil(t, svc.lastListGroupsIsExclusive) +} + +func float64PtrForSimpleModeTest(value float64) *float64 { return &value } + +func TestGroupHandlerSimpleModeResponseUsesFieldAllowlist(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newStubAdminService() + svc.groups = []service.Group{{ + ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic, + AccountCount: 3, ActiveAccountCount: 2, RateLimitedAccountCount: 1, + Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42, + LongContextPricingEnabled: true, + ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}}, + AllowBatchImageGeneration: true, VideoPrice720P: float64PtrForSimpleModeTest(2), + WebSearchPricePerCall: float64PtrForSimpleModeTest(3), AudioRealtimePricePerMin: float64PtrForSimpleModeTest(4), + ModelRouting: map[string][]int64{"claude": {2}}, + }} + r := newSimpleModeGroupRouter(svc) + res := httptest.NewRecorder() + r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups", nil)) + require.Equal(t, http.StatusOK, res.Code) + + var payload struct { + Data struct { + Items []map[string]any `json:"items"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload)) + require.Len(t, payload.Data.Items, 1) + item := payload.Data.Items[0] + require.Equal(t, "basic", item["name"]) + require.Equal(t, "allowed", item["description"]) + require.Equal(t, "anthropic", item["platform"]) + require.Equal(t, float64(3), item["account_count"]) + require.ElementsMatch(t, []string{ + "id", "name", "description", "platform", "status", "account_count", + "active_account_count", "rate_limited_account_count", "sort_order", "created_at", "updated_at", + }, mapKeys(item)) + for _, forbidden := range []string{ + "rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing", + "allow_batch_image_generation", "video_price_720p", "web_search_price_per_call", + "audio_realtime_price_per_min", "model_routing", + } { + _, exposed := item[forbidden] + require.Falsef(t, exposed, "simple mode exposed %s", forbidden) + } +} + +func TestGroupHandlerSimpleModeListsOnlyBindableGroups(t *testing.T) { + svc := newStubAdminService() + svc.groups = []service.Group{{ID: 1, Name: "basic", Platform: service.PlatformAnthropic}, {ID: 2, Name: "composite", Platform: service.PlatformComposite}} + r := newSimpleModeGroupRouter(svc) + for _, path := range []string{"/groups", "/groups/all"} { + res := httptest.NewRecorder() + r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, path, nil)) + require.Equal(t, http.StatusOK, res.Code) + require.Contains(t, res.Body.String(), `"name":"basic"`) + require.NotContains(t, res.Body.String(), `"name":"composite"`) + } +} + +func mapKeys(values map[string]any) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + return keys +} + +func TestGroupHandlerSimpleModeAllReadAndWriteResponsesUseFieldAllowlist(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := newStubAdminService() + svc.groups = []service.Group{{ + ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic, AccountCount: 3, + Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42, + LongContextPricingEnabled: true, + ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}}, + }} + r := newSimpleModeGroupRouter(svc) + + assertNoAdvancedFields := func(t *testing.T, item map[string]any) { + t.Helper() + for _, forbidden := range []string{ + "rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing", + "allow_batch_image_generation", "video_price_720p", "web_search_price_per_call", + "audio_realtime_price_per_min", "model_routing", + } { + _, exposed := item[forbidden] + require.Falsef(t, exposed, "simple mode exposed %s", forbidden) + } + } + + for _, tt := range []struct { + name string + method string + path string + body string + list bool + }{ + {name: "get all", method: http.MethodGet, path: "/groups/all", list: true}, + {name: "get by id", method: http.MethodGet, path: "/groups/1"}, + {name: "create", method: http.MethodPost, path: "/groups", body: `{"name":"created","platform":"anthropic","model_pricing":[{"models":["claude"]}]}`}, + {name: "update", method: http.MethodPut, path: "/groups/1", body: `{"name":"updated","model_pricing":[{"models":["claude"]}]}`}, + } { + t.Run(tt.name, func(t *testing.T) { + res := httptest.NewRecorder() + req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(res, req) + require.Equal(t, http.StatusOK, res.Code) + + var payload struct { + Data json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload)) + if tt.list { + var items []map[string]any + require.NoError(t, json.Unmarshal(payload.Data, &items)) + require.Len(t, items, 1) + assertNoAdvancedFields(t, items[0]) + return + } + var item map[string]any + require.NoError(t, json.Unmarshal(payload.Data, &item)) + assertNoAdvancedFields(t, item) + }) + } +} + +func TestGroupHandlerSimpleModeBlocksAdvancedOperations(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + method string + path string + body string + }{ + {http.MethodPost, "/groups/1/duplicate", ""}, + {http.MethodGet, "/groups/1/model-allowlist-candidates", ""}, + {http.MethodGet, "/groups/1/composite-routes", ""}, + {http.MethodPost, "/groups/1/composite-routes", `{"public_model":"x","target_platform":"openai"}`}, + {http.MethodPost, "/groups/1/composite-routes/preview", `{"model":"x"}`}, + {http.MethodPut, "/groups/1/composite-routes/2", `{"public_model":"x","target_platform":"openai"}`}, + {http.MethodDelete, "/groups/1/composite-routes/2", ""}, + {http.MethodGet, "/groups/1/rate-multipliers", ""}, + {http.MethodPut, "/groups/1/rate-multipliers", `{"entries":[]}`}, + {http.MethodDelete, "/groups/1/rate-multipliers", ""}, + {http.MethodPut, "/groups/1/rpm-overrides", `{"entries":[]}`}, + {http.MethodDelete, "/groups/1/rpm-overrides", ""}, + {http.MethodGet, "/groups/1/stats", ""}, + {http.MethodGet, "/groups/1/api-keys", ""}, + {http.MethodGet, "/groups/live-capability", ""}, + {http.MethodGet, "/groups/usage-summary", ""}, + {http.MethodGet, "/groups/capacity-summary", ""}, + {http.MethodPut, "/groups/sort-order", `{"updates":[{"id":1,"sort_order":1}]}`}, + } + + for _, tt := range tests { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + svc := newStubAdminService() + r := newSimpleModeGroupRouter(svc) + res := httptest.NewRecorder() + req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(res, req) + require.Equal(t, http.StatusForbidden, res.Code) + require.Zero(t, svc.advancedGroupOperationCalls) + }) + } +} diff --git a/backend/internal/handler/admin/proxy_data.go b/backend/internal/handler/admin/proxy_data.go index 778208c68c..ea59e02ff4 100644 --- a/backend/internal/handler/admin/proxy_data.go +++ b/backend/internal/handler/admin/proxy_data.go @@ -172,9 +172,11 @@ func (h *ProxyHandler) ImportData(c *gin.Context) { updateInput := &service.UpdateProxyInput{ Status: normalizedStatus, ExpiresAt: existingExpiresAt, + ClearExpiresAt: existingExpiresAt == nil, FallbackMode: existingFallbackMode, BackupProxyID: existingBackupProxyID, - ExpiryWarnDays: item.ExpiryWarnDays, + ClearBackupID: existingBackupProxyID == nil, + ExpiryWarnDays: &item.ExpiryWarnDays, // 保留已存在代理的网络配置字段 Name: existing.Name, Protocol: existing.Protocol, @@ -255,9 +257,11 @@ func (h *ProxyHandler) ImportData(c *gin.Context) { if _, err := h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{ Status: normalizedStatus, ExpiresAt: expiresAt, + ClearExpiresAt: expiresAt == nil, FallbackMode: fallbackMode, BackupProxyID: backupProxyID, - ExpiryWarnDays: item.ExpiryWarnDays, + ClearBackupID: backupProxyID == nil, + ExpiryWarnDays: &item.ExpiryWarnDays, Name: created.Name, Protocol: created.Protocol, Host: created.Host, diff --git a/backend/internal/handler/admin/proxy_handler.go b/backend/internal/handler/admin/proxy_handler.go index 93c729107a..6c1961d944 100644 --- a/backend/internal/handler/admin/proxy_handler.go +++ b/backend/internal/handler/admin/proxy_handler.go @@ -41,17 +41,17 @@ type CreateProxyRequest struct { // UpdateProxyRequest represents update proxy request type UpdateProxyRequest struct { - Name string `json:"name"` - Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"` - Host string `json:"host"` - Port int `json:"port" binding:"omitempty,min=1,max=65535"` - Username string `json:"username"` - Password string `json:"password"` - Status string `json:"status" binding:"omitempty,oneof=active inactive"` - ExpiresAt *int64 `json:"expires_at"` - FallbackMode string `json:"fallback_mode" binding:"omitempty,oneof=none proxy direct"` - BackupProxyID *int64 `json:"backup_proxy_id"` - ExpiryWarnDays int `json:"expiry_warn_days" binding:"omitempty,min=0"` + Name string `json:"name"` + Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"` + Host string `json:"host"` + Port int `json:"port" binding:"omitempty,min=1,max=65535"` + Username string `json:"username"` + Password string `json:"password"` + Status string `json:"status" binding:"omitempty,oneof=active inactive"` + ExpiresAt dto.NullableInt64Field `json:"expires_at"` + FallbackMode string `json:"fallback_mode" binding:"omitempty,oneof=none proxy direct"` + BackupProxyID dto.NullableInt64Field `json:"backup_proxy_id"` + ExpiryWarnDays *int `json:"expiry_warn_days" binding:"omitempty,min=0"` } // List handles listing all proxies with pagination @@ -183,8 +183,8 @@ func (h *ProxyHandler) Update(c *gin.Context) { } var expiresAt *time.Time - if req.ExpiresAt != nil && *req.ExpiresAt > 0 { - t := time.Unix(*req.ExpiresAt, 0).UTC() + if req.ExpiresAt.Value != nil && *req.ExpiresAt.Value > 0 { + t := time.Unix(*req.ExpiresAt.Value, 0).UTC() expiresAt = &t } proxy, err := h.adminService.UpdateProxy(c.Request.Context(), proxyID, &service.UpdateProxyInput{ @@ -196,8 +196,10 @@ func (h *ProxyHandler) Update(c *gin.Context) { Password: strings.TrimSpace(req.Password), Status: strings.TrimSpace(req.Status), ExpiresAt: expiresAt, + ClearExpiresAt: req.ExpiresAt.Set && expiresAt == nil, FallbackMode: strings.TrimSpace(req.FallbackMode), - BackupProxyID: req.BackupProxyID, + BackupProxyID: req.BackupProxyID.Value, + ClearBackupID: req.BackupProxyID.Set && req.BackupProxyID.Value == nil, ExpiryWarnDays: req.ExpiryWarnDays, }) if err != nil { diff --git a/backend/internal/handler/admin/proxy_partial_update_test.go b/backend/internal/handler/admin/proxy_partial_update_test.go new file mode 100644 index 0000000000..4f81af575b --- /dev/null +++ b/backend/internal/handler/admin/proxy_partial_update_test.go @@ -0,0 +1,60 @@ +//go:build unit + +package admin + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type proxyPartialUpdateService struct { + service.AdminService + input *service.UpdateProxyInput +} + +func (s *proxyPartialUpdateService) UpdateProxy(_ context.Context, _ int64, input *service.UpdateProxyInput) (*service.Proxy, error) { + s.input = input + return &service.Proxy{ID: 9}, nil +} + +func TestProxyHandlerUpdatePreservesFieldPresence(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, tc := range []struct { + name, body string + set, clear, warn bool + }{ + {name: "omitted", body: `{"status":"inactive"}`}, + {name: "null clears", body: `{"expires_at":null,"backup_proxy_id":null,"fallback_mode":"none","expiry_warn_days":0}`, set: true, clear: true, warn: true}, + {name: "zero clears expiry", body: `{"expires_at":0,"backup_proxy_id":null}`, set: true, clear: true}, + {name: "values", body: `{"expires_at":1800000000,"backup_proxy_id":10,"fallback_mode":"proxy","expiry_warn_days":7}`, set: true, warn: true}, + } { + t.Run(tc.name, func(t *testing.T) { + svc := &proxyPartialUpdateService{} + router := gin.New() + router.PUT("/proxies/:id", NewProxyHandler(svc).Update) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/proxies/9", strings.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.NotNil(t, svc.input) + require.Equal(t, tc.set, svc.input.ExpiresAt != nil || svc.input.ClearExpiresAt) + require.Equal(t, tc.set, svc.input.BackupProxyID != nil || svc.input.ClearBackupID) + require.Equal(t, tc.warn, svc.input.ExpiryWarnDays != nil) + if !tc.set || tc.clear { + require.Nil(t, svc.input.ExpiresAt) + require.Nil(t, svc.input.BackupProxyID) + } else { + require.Equal(t, int64(1800000000), svc.input.ExpiresAt.Unix()) + require.Equal(t, int64(10), *svc.input.BackupProxyID) + } + }) + } +} diff --git a/backend/internal/handler/admin/redeem_handler.go b/backend/internal/handler/admin/redeem_handler.go index 5dca01d5ec..fcf0b6dcdf 100644 --- a/backend/internal/handler/admin/redeem_handler.go +++ b/backend/internal/handler/admin/redeem_handler.go @@ -228,7 +228,7 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) { return nil, createErr } - redeemed, redeemErr := h.redeemService.Redeem(ctx, req.UserID, req.Code) + redeemed, redeemErr := h.redeemService.RedeemForAdminFulfillment(ctx, req.UserID, req.Code) if redeemErr != nil { return nil, redeemErr } @@ -246,7 +246,7 @@ func (h *RedeemHandler) resolveCreateAndRedeemExisting(ctx context.Context, exis return nil, service.ErrRedeemCodeExpired } if existing.CanUse() { - redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code) + redeemed, err := h.redeemService.RedeemForAdminFulfillment(ctx, userID, existing.Code) if err == nil { return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil } diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index e8b2c49a68..ebc2c11dfe 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -376,6 +376,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota, + ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking, GrokDefaultTextModel: settings.GrokDefaultTextModel, GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled, diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index b4840f0ba6..4d9447bd0e 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -334,6 +334,7 @@ type UpdateSettingsRequest struct { ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"` ChannelMonitorShowQuota *bool `json:"channel_monitor_show_quota"` + ChannelMonitorHideUserRanking *bool `json:"channel_monitor_hide_user_ranking"` // Grok model mapping policy GrokDefaultTextModel *string `json:"grok_default_text_model"` @@ -1906,6 +1907,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.ChannelMonitorShowQuota }(), + ChannelMonitorHideUserRanking: func() bool { + if req.ChannelMonitorHideUserRanking != nil { + return *req.ChannelMonitorHideUserRanking + } + return previousSettings.ChannelMonitorHideUserRanking + }(), GrokDefaultTextModel: func() string { if req.GrokDefaultTextModel != nil { return *req.GrokDefaultTextModel @@ -2366,6 +2373,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput, ChannelMonitorShowQuota: updatedSettings.ChannelMonitorShowQuota, + ChannelMonitorHideUserRanking: updatedSettings.ChannelMonitorHideUserRanking, GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel, GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled, diff --git a/backend/internal/handler/auth_current_user_test.go b/backend/internal/handler/auth_current_user_test.go index cb3e4ba596..457aba3144 100644 --- a/backend/internal/handler/auth_current_user_test.go +++ b/backend/internal/handler/auth_current_user_test.go @@ -29,19 +29,19 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T AvatarURL: "https://cdn.example.com/linuxdo.png", AvatarSource: "remote_url", }, - identities: []service.UserAuthIdentityRecord{ - { - ProviderType: "linuxdo", - ProviderKey: "linuxdo", - ProviderSubject: "linuxdo-subject-31", - VerifiedAt: &verifiedAt, - Metadata: map[string]any{ - "username": "linuxdo-handle", - "avatar_url": "https://cdn.example.com/linuxdo.png", - }, + identities: []service.UserAuthIdentityRecord{ + { + ProviderType: "linuxdo", + ProviderKey: "linuxdo", + ProviderSubject: "linuxdo-subject-31", + VerifiedAt: &verifiedAt, + Metadata: map[string]any{ + "username": "linuxdo-handle", + "avatar_url": "https://cdn.example.com/linuxdo.png", }, }, - } + }, + } handler := &AuthHandler{ userService: service.NewUserService(repo, nil, nil, nil), diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index 5d35ff389e..ae01687bdb 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -66,29 +66,34 @@ type userAvailableGroup struct { // userSupportedModelPricing 用户可见的定价字段白名单。 type userSupportedModelPricing struct { - BillingMode string `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []userPricingIntervalDTO `json:"intervals"` + BillingMode string `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier,omitempty"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []userPricingIntervalDTO `json:"intervals"` } // userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。 type userPricingIntervalDTO struct { - MinTokens int `json:"min_tokens"` - MaxTokens *int `json:"max_tokens"` - TierLabel string `json:"tier_label,omitempty"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - PerRequestPrice *float64 `json:"per_request_price"` + MinTokens int `json:"min_tokens"` + MaxTokens *int `json:"max_tokens"` + TierLabel string `json:"tier_label,omitempty"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + InputMultiplier *float64 `json:"input_multiplier"` + OutputMultiplier *float64 `json:"output_multiplier"` + CacheWriteMultiplier *float64 `json:"cache_write_multiplier"` + CacheReadMultiplier *float64 `json:"cache_read_multiplier"` + PerRequestPrice *float64 `json:"per_request_price"` } // userSupportedModel 用户可见的支持模型条目。 @@ -294,15 +299,19 @@ func toUserPricingIntervals(src []service.PricingInterval) []userPricingInterval intervals := make([]userPricingIntervalDTO, 0, len(src)) for _, iv := range src { intervals = append(intervals, userPricingIntervalDTO{ - MinTokens: iv.MinTokens, - MaxTokens: iv.MaxTokens, - TierLabel: iv.TierLabel, - InputPrice: iv.InputPrice, - OutputPrice: iv.OutputPrice, - CacheWritePrice: iv.CacheWritePrice, - CacheWrite1hPrice: iv.CacheWrite1hPrice, - CacheReadPrice: iv.CacheReadPrice, - PerRequestPrice: iv.PerRequestPrice, + MinTokens: iv.MinTokens, + MaxTokens: iv.MaxTokens, + TierLabel: iv.TierLabel, + InputPrice: iv.InputPrice, + OutputPrice: iv.OutputPrice, + CacheWritePrice: iv.CacheWritePrice, + CacheWrite1hPrice: iv.CacheWrite1hPrice, + CacheReadPrice: iv.CacheReadPrice, + InputMultiplier: iv.InputMultiplier, + OutputMultiplier: iv.OutputMultiplier, + CacheWriteMultiplier: iv.CacheWriteMultiplier, + CacheReadMultiplier: iv.CacheReadMultiplier, + PerRequestPrice: iv.PerRequestPrice, }) } return intervals @@ -323,15 +332,16 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing { billingMode = string(service.BillingModeToken) } return &userSupportedModelPricing{ - BillingMode: billingMode, - InputPrice: p.InputPrice, - OutputPrice: p.OutputPrice, - CacheWritePrice: p.CacheWritePrice, - CacheWrite1hPrice: p.CacheWrite1hPrice, - CacheReadPrice: p.CacheReadPrice, - ImageInputPrice: p.ImageInputPrice, - ImageOutputPrice: p.ImageOutputPrice, - PerRequestPrice: p.PerRequestPrice, - Intervals: intervals, + BillingMode: billingMode, + InputPrice: p.InputPrice, + OutputPrice: p.OutputPrice, + CacheWritePrice: p.CacheWritePrice, + CacheWrite1hPrice: p.CacheWrite1hPrice, + CacheReadPrice: p.CacheReadPrice, + MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier, + ImageInputPrice: p.ImageInputPrice, + ImageOutputPrice: p.ImageOutputPrice, + PerRequestPrice: p.PerRequestPrice, + Intervals: intervals, } } diff --git a/backend/internal/handler/available_channel_handler_test.go b/backend/internal/handler/available_channel_handler_test.go index 24d7d1cb45..325f10133b 100644 --- a/backend/internal/handler/available_channel_handler_test.go +++ b/backend/internal/handler/available_channel_handler_test.go @@ -113,10 +113,18 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) { } // pricing interval 白名单:不应暴露 id / sort_order。 + inputMultiplier := 2.0 + outputMultiplier := 1.5 + cacheWriteMultiplier := 2.0 + cacheReadMultiplier := 2.0 pricing := toUserPricing(&service.ChannelModelPricing{ BillingMode: service.BillingModeToken, Intervals: []service.PricingInterval{ - {ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3}, + { + ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3, + InputMultiplier: &inputMultiplier, OutputMultiplier: &outputMultiplier, + CacheWriteMultiplier: &cacheWriteMultiplier, CacheReadMultiplier: &cacheReadMultiplier, + }, }, }) require.NotNil(t, pricing) @@ -129,6 +137,14 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) { _, exists := ivDecoded[key] require.Falsef(t, exists, "user pricing interval must not expose %q", key) } + for key, want := range map[string]float64{ + "input_multiplier": inputMultiplier, "output_multiplier": outputMultiplier, + "cache_write_multiplier": cacheWriteMultiplier, "cache_read_multiplier": cacheReadMultiplier, + } { + got, exists := ivDecoded[key] + require.Truef(t, exists, "user pricing interval must expose %q", key) + require.InDelta(t, want, got.(float64), 1e-12) + } } func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) { diff --git a/backend/internal/handler/batch_image_handler.go b/backend/internal/handler/batch_image_handler.go index 62990d251a..33f024b7db 100644 --- a/backend/internal/handler/batch_image_handler.go +++ b/backend/internal/handler/batch_image_handler.go @@ -139,9 +139,28 @@ func (h *BatchImageHandler) Models(c *gin.Context) { batchImageError(c, err) return } + // 分组级模型白名单:ListModels 已按分组账号枚举,这里再按白名单过滤一次。 + if apiKey, ok := middleware.GetAPIKeyFromContext(c); ok && apiKey != nil && apiKey.Group != nil { + got.Data = filterBatchImageModelsByAllowlist(got.Data, apiKey.Group.ModelAllowlist) + } c.JSON(http.StatusOK, got) } +// filterBatchImageModelsByAllowlist 按分组模型白名单过滤批量生图模型列表。 +// 白名单未开启时原样返回。 +func filterBatchImageModelsByAllowlist(models []service.BatchImagePublicModel, allowlist service.GroupModelAllowlist) []service.BatchImagePublicModel { + if !allowlist.Enabled { + return models + } + filtered := make([]service.BatchImagePublicModel, 0, len(models)) + for _, model := range models { + if allowlist.Allows(model.ID) { + filtered = append(filtered, model) + } + } + return filtered +} + func (h *BatchImageHandler) Items(c *gin.Context) { owner, ok := batchImageOwnerFromContext(c) if !ok { diff --git a/backend/internal/handler/composite_platform.go b/backend/internal/handler/composite_platform.go index 49e87cb332..6e613aa3e4 100644 --- a/backend/internal/handler/composite_platform.go +++ b/backend/internal/handler/composite_platform.go @@ -64,13 +64,45 @@ func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKe if apiKey == nil || apiKey.Group == nil { return "", nil, "", false } - if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite { + if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite { return "", nil, "", false } - if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI { + effectivePlatform := effectiveAPIKeyPlatform(c, apiKey) + if effectivePlatform != service.PlatformAnthropic && effectivePlatform != service.PlatformOpenAI { return "", nil, "", false } - return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, apiKey.Group.MaxReasoningEffortOverLimit, true + maxEffort, mappings := apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings + if effectivePlatform == service.PlatformAnthropic { + maxEffort, mappings = anthropicCompatibleReasoningEffortPolicy(maxEffort, mappings) + } + return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true +} + +func anthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) { + if apiKey == nil || apiKey.Group == nil { + return "", nil, "", false + } + if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformComposite { + return "", nil, "", false + } + if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformAnthropic { + return "", nil, "", false + } + maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy(apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings) + return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true +} + +func anthropicCompatibleReasoningEffortPolicy(maxEffort string, mappings []service.ReasoningEffortMapping) (string, []service.ReasoningEffortMapping) { + if service.NormalizeMaxReasoningEffort(maxEffort) == "minimal" { + maxEffort = "low" + } + normalizedMappings := append([]service.ReasoningEffortMapping(nil), mappings...) + for i := range normalizedMappings { + if service.NormalizeMaxReasoningEffort(normalizedMappings[i].To) == "minimal" { + normalizedMappings[i].To = "low" + } + } + return maxEffort, normalizedMappings } func bindRequestedReasoningEffort(c *gin.Context, body []byte, model string) { @@ -118,6 +150,14 @@ func respondOpenAIReasoningEffortPolicyError(c *gin.Context, err error, write fu write(c, http.StatusForbidden, "permission_error", err.Error()) } +func applyAnthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) { + maxEffort, mappings, overLimit, ok := anthropicReasoningEffortPolicyForRequest(c, apiKey) + if !ok { + return body, false, nil + } + return service.ApplyReasoningEffortPolicy(body, maxEffort, mappings, overLimit) +} + func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) { if c == nil || c.Request == nil { return diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index 73db844e7e..7704a129a0 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -8,6 +8,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) { @@ -34,6 +35,7 @@ func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) { {model: "k3", platform: service.PlatformKimi}, {model: "glm-5.2", platform: service.PlatformZhipu}, {model: "deepseek-v3.2", platform: service.PlatformDeepseek}, + {model: "MiniMax-M3", platform: service.PlatformMiniMax}, } for _, path := range []string{"/v1/messages", "/v1/chat/completions", "/v1/responses", "/v1/responses/input_tokens", "/v1/messages/count_tokens"} { for _, provider := range providers { @@ -55,7 +57,7 @@ func TestResponsesWebSocketCompositePlatformGuardKeepsOpenAIAndGrokOnly(t *testi require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformOpenAI)) require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformGrok)) for _, platform := range []string{ - service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, + service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformAnthropic, service.PlatformGemini, } { require.False(t, isResponsesWebSocketCompositePlatform(platform), "platform=%s", platform) @@ -151,6 +153,23 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) { var overLimit *service.ReasoningEffortOverLimitError require.ErrorAs(t, err, &overLimit) + mappingDenyGroup := *group + mappingDenyGroup.MaxReasoningEffort = "" + mappingDenyGroup.ReasoningEffortMappings = []service.ReasoningEffortMapping{ + {From: "max", To: service.ReasoningEffortMappingDeny}, + } + mappingDenyAPIKey := &service.APIKey{Group: &mappingDenyGroup} + mappingDenyCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + mappingDenyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil) + mappingDenyCtx.Request = mappingDenyCtx.Request.WithContext(service.WithResolvedTargetPlatform(mappingDenyCtx.Request.Context(), service.PlatformOpenAI)) + _, changed, err = applyOpenAIReasoningEffortPolicyForRequest(mappingDenyCtx, mappingDenyAPIKey, body) + require.Error(t, err) + require.False(t, changed) + var mappingDenied *service.ReasoningEffortMappingDeniedError + require.ErrorAs(t, err, &mappingDenied) + require.Equal(t, "max", mappingDenied.Requested) + require.Contains(t, mappingDenied.Error(), "denied by this group's mapping policy") + grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil) grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok)) @@ -160,6 +179,75 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) { require.Equal(t, body, got) } +func TestOpenAIShapedReasoningEffortPolicyForAnthropicGroup(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/responses", nil) + apiKey := &service.APIKey{Group: &service.Group{ + Platform: service.PlatformAnthropic, + MaxReasoningEffort: "xhigh", + }} + + got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, []byte(`{"reasoning":{"effort":"max"}}`)) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "xhigh", gjson.GetBytes(got, "reasoning.effort").String()) +} + +func TestAnthropicReasoningEffortPolicyForConcreteAndCompositeTargets(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"claude-fable-5-1","output_config":{"effort":"max"}}`) + policy := func(platform string) *service.APIKey { + return &service.APIKey{Group: &service.Group{ + Platform: platform, + MaxReasoningEffort: "xhigh", + ReasoningEffortMappings: []service.ReasoningEffortMapping{ + {From: "max", To: "xhigh"}, + }, + }} + } + + concrete, _ := gin.CreateTestContext(httptest.NewRecorder()) + concrete.Request = httptest.NewRequest("POST", "/v1/messages", nil) + got, changed, err := applyAnthropicReasoningEffortPolicyForRequest(concrete, policy(service.PlatformAnthropic), body) + require.NoError(t, err) + require.True(t, changed) + require.JSONEq(t, `{"model":"claude-fable-5-1","output_config":{"effort":"xhigh"}}`, string(got)) + + denyMax := policy(service.PlatformAnthropic) + denyMax.Group.ReasoningEffortMappings = nil + denyMax.Group.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny + _, changed, err = applyAnthropicReasoningEffortPolicyForRequest(concrete, denyMax, body) + require.Error(t, err) + require.False(t, changed) + var overLimit *service.ReasoningEffortOverLimitError + require.ErrorAs(t, err, &overLimit) + require.Equal(t, "max", overLimit.Requested) + require.Equal(t, "xhigh", overLimit.Max) + + composite, _ := gin.CreateTestContext(httptest.NewRecorder()) + composite.Request = httptest.NewRequest("POST", "/v1/messages", nil) + composite.Request = composite.Request.WithContext(service.WithResolvedTargetPlatform(composite.Request.Context(), service.PlatformAnthropic)) + got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(composite, policy(service.PlatformComposite), body) + require.NoError(t, err) + require.True(t, changed) + require.Equal(t, "xhigh", gjson.GetBytes(got, "output_config.effort").String()) + + openAI, _ := gin.CreateTestContext(httptest.NewRecorder()) + openAI.Request = httptest.NewRequest("POST", "/v1/messages", nil) + openAI.Request = openAI.Request.WithContext(service.WithResolvedTargetPlatform(openAI.Request.Context(), service.PlatformOpenAI)) + got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(openAI, policy(service.PlatformComposite), body) + require.NoError(t, err) + require.False(t, changed) + require.Equal(t, body, got) +} + +func TestAnthropicCompositePolicyClampsUnsupportedMinimalToLow(t *testing.T) { + maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy("minimal", []service.ReasoningEffortMapping{{From: "max", To: "minimal"}}) + require.Equal(t, "low", maxEffort) + require.Equal(t, []service.ReasoningEffortMapping{{From: "max", To: "low"}}, mappings) +} + func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(httptest.NewRecorder()) diff --git a/backend/internal/handler/concurrency_error_response.go b/backend/internal/handler/concurrency_error_response.go index 911f5a54d7..cd631a47b4 100644 --- a/backend/internal/handler/concurrency_error_response.go +++ b/backend/internal/handler/concurrency_error_response.go @@ -9,10 +9,15 @@ import ( const statusClientClosedRequest = 499 -func concurrencyErrorResponse(err error, slotType string) (int, string, string) { +const ( + gatewayQueueFullCode = "gateway_queue_full" + gatewayConcurrencyLimitCode = "gateway_concurrency_limit" +) + +func concurrencyErrorResponse(err error, slotType string) (int, string, string, string) { var waitQueueFullErr *WaitQueueFullError if errors.As(err, &waitQueueFullErr) { - return http.StatusTooManyRequests, "rate_limit_error", + return http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later" } @@ -21,13 +26,13 @@ func concurrencyErrorResponse(err error, slotType string) (int, string, string) if concurrencyErr.SlotType != "" { slotType = concurrencyErr.SlotType } - return http.StatusTooManyRequests, "rate_limit_error", + return http.StatusTooManyRequests, "rate_limit_error", gatewayConcurrencyLimitCode, fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType) } if errors.Is(err, context.Canceled) { - return statusClientClosedRequest, "api_error", "context canceled" + return statusClientClosedRequest, "api_error", "", "context canceled" } - return http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable, please retry later" + return http.StatusServiceUnavailable, "api_error", "", "Service temporarily unavailable, please retry later" } diff --git a/backend/internal/handler/concurrency_error_response_test.go b/backend/internal/handler/concurrency_error_response_test.go index a2e6b9ab93..2d1b3d6ad2 100644 --- a/backend/internal/handler/concurrency_error_response_test.go +++ b/backend/internal/handler/concurrency_error_response_test.go @@ -16,6 +16,7 @@ func TestConcurrencyErrorResponse(t *testing.T) { slotType string wantStatus int wantType string + wantCode string wantMessage string }{ { @@ -24,8 +25,18 @@ func TestConcurrencyErrorResponse(t *testing.T) { slotType: "user", wantStatus: http.StatusTooManyRequests, wantType: "rate_limit_error", + wantCode: gatewayConcurrencyLimitCode, wantMessage: "Concurrency limit exceeded for account, please retry later", }, + { + name: "full local wait queue has gateway code", + err: &WaitQueueFullError{SlotType: "account"}, + slotType: "account", + wantStatus: http.StatusTooManyRequests, + wantType: "rate_limit_error", + wantCode: gatewayQueueFullCode, + wantMessage: "Too many pending requests, please retry later", + }, { name: "client cancellation is not classified as concurrency limit", err: context.Canceled, @@ -54,9 +65,10 @@ func TestConcurrencyErrorResponse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - status, errType, message := concurrencyErrorResponse(tt.err, tt.slotType) + status, errType, code, message := concurrencyErrorResponse(tt.err, tt.slotType) require.Equal(t, tt.wantStatus, status) require.Equal(t, tt.wantType, errType) + require.Equal(t, tt.wantCode, code) require.Equal(t, tt.wantMessage, message) }) } diff --git a/backend/internal/handler/dto/group_mapper_codex_manifest_test.go b/backend/internal/handler/dto/group_mapper_codex_manifest_test.go new file mode 100644 index 0000000000..d5f550d6e4 --- /dev/null +++ b/backend/internal/handler/dto/group_mapper_codex_manifest_test.go @@ -0,0 +1,41 @@ +package dto + +import ( + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +// 固定账号 manifest 配置必须在管理端 DTO 完整序列化:漏映射会让编辑对话框回显 +// 关闭状态,管理员保存无关字段时会把已开启的配置静默关掉。用户侧 DTO 不携带该字段。 +func TestGroupMapperRoundTripsCodexModelsManifestConfig(t *testing.T) { + group := &service.Group{ + ID: 31, Name: "codex-manifest-dto", Platform: service.PlatformOpenAI, Status: service.StatusActive, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{101, 202}, + FallbackToScheduler: true, + }, + } + + adminJSON, err := json.Marshal(GroupFromServiceAdmin(group)) + require.NoError(t, err) + var adminEnvelope struct { + CodexModelsManifestConfig *struct { + Enabled bool `json:"enabled"` + AccountIDs []int64 `json:"account_ids"` + FallbackToScheduler bool `json:"fallback_to_scheduler"` + } `json:"codex_models_manifest_config"` + } + require.NoError(t, json.Unmarshal(adminJSON, &adminEnvelope)) + require.NotNil(t, adminEnvelope.CodexModelsManifestConfig, "GroupFromServiceAdmin 漏映射 codex_models_manifest_config") + require.True(t, adminEnvelope.CodexModelsManifestConfig.Enabled) + require.Equal(t, []int64{101, 202}, adminEnvelope.CodexModelsManifestConfig.AccountIDs) + require.True(t, adminEnvelope.CodexModelsManifestConfig.FallbackToScheduler) + + userJSON, err := json.Marshal(GroupFromService(group)) + require.NoError(t, err) + require.NotContains(t, string(userJSON), "codex_models_manifest_config", "用户侧分组 DTO 不得暴露管理端 manifest 配置") +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index e6759a110c..bb29600c67 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -159,7 +159,8 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { MCPXMLInject: g.MCPXMLInject, DefaultMappedModel: g.DefaultMappedModel, MessagesDispatchModelConfig: g.MessagesDispatchModelConfig, - ModelsListConfig: g.ModelsListConfig, + ModelAllowlist: g.ModelAllowlist, + CodexModelsManifestConfig: g.CodexModelsManifestConfig, SupportedModelScopes: g.SupportedModelScopes, AccountCount: g.AccountCount, ActiveAccountCount: g.ActiveAccountCount, @@ -447,6 +448,47 @@ func AccountFromService(a *service.Account) *Account { return out } +// AccountListItemFromAccount projects a full account response into the +// compact shape used by the paginated admin account list. Keeping this +// projection separate from Account preserves the existing detail API. +func AccountListItemFromAccount(a *Account) *AccountListItem { + if a == nil { + return nil + } + return &AccountListItem{ + ID: a.ID, Name: a.Name, Notes: a.Notes, Platform: a.Platform, Type: a.Type, + Credentials: a.Credentials, CredentialsStatus: a.CredentialsStatus, Extra: a.Extra, + OllamaCloudUsage: a.OllamaCloudUsage, + ProxyID: a.ProxyID, ProxyFallbackOriginID: a.ProxyFallbackOriginID, ProxyFallbackOriginName: a.ProxyFallbackOriginName, + Concurrency: a.Concurrency, LoadFactor: a.LoadFactor, Priority: a.Priority, RateMultiplier: a.RateMultiplier, + Status: a.Status, ErrorMessage: a.ErrorMessage, LastUsedAt: a.LastUsedAt, ExpiresAt: a.ExpiresAt, + AutoPauseOnExpired: a.AutoPauseOnExpired, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt, + Schedulable: a.Schedulable, RateLimitedAt: a.RateLimitedAt, RateLimitResetAt: a.RateLimitResetAt, + OverloadUntil: a.OverloadUntil, TempUnschedulableUntil: a.TempUnschedulableUntil, + TempUnschedulableReason: a.TempUnschedulableReason, SessionWindowStart: a.SessionWindowStart, + SessionWindowEnd: a.SessionWindowEnd, SessionWindowStatus: a.SessionWindowStatus, + WindowCostLimit: a.WindowCostLimit, WindowCostStickyReserve: a.WindowCostStickyReserve, + MaxSessions: a.MaxSessions, SessionIdleTimeoutMin: a.SessionIdleTimeoutMin, BaseRPM: a.BaseRPM, + RPMStrategy: a.RPMStrategy, RPMStickyBuffer: a.RPMStickyBuffer, UserMsgQueueMode: a.UserMsgQueueMode, + EnableTLSFingerprint: a.EnableTLSFingerprint, TLSFingerprintProfileID: a.TLSFingerprintProfileID, + EnableSessionIDMasking: a.EnableSessionIDMasking, CacheTTLOverrideEnabled: a.CacheTTLOverrideEnabled, + CacheTTLOverrideTarget: a.CacheTTLOverrideTarget, CustomBaseURLEnabled: a.CustomBaseURLEnabled, + CustomBaseURL: a.CustomBaseURL, QuotaLimit: a.QuotaLimit, QuotaUsed: a.QuotaUsed, + QuotaDailyLimit: a.QuotaDailyLimit, QuotaDailyUsed: a.QuotaDailyUsed, QuotaWeeklyLimit: a.QuotaWeeklyLimit, + QuotaWeeklyUsed: a.QuotaWeeklyUsed, QuotaDailyResetMode: a.QuotaDailyResetMode, + QuotaDailyResetHour: a.QuotaDailyResetHour, QuotaWeeklyResetMode: a.QuotaWeeklyResetMode, + QuotaWeeklyResetDay: a.QuotaWeeklyResetDay, QuotaWeeklyResetHour: a.QuotaWeeklyResetHour, + QuotaResetTimezone: a.QuotaResetTimezone, QuotaDailyResetAt: a.QuotaDailyResetAt, + QuotaWeeklyResetAt: a.QuotaWeeklyResetAt, QuotaNotifyDailyEnabled: a.QuotaNotifyDailyEnabled, + QuotaNotifyDailyThreshold: a.QuotaNotifyDailyThreshold, QuotaNotifyWeeklyEnabled: a.QuotaNotifyWeeklyEnabled, + QuotaNotifyWeeklyThreshold: a.QuotaNotifyWeeklyThreshold, QuotaNotifyTotalEnabled: a.QuotaNotifyTotalEnabled, + QuotaNotifyTotalThreshold: a.QuotaNotifyTotalThreshold, ParentAccountID: a.ParentAccountID, + QuotaDimension: a.QuotaDimension, ParentEmail: a.ParentEmail, ParentPlanType: a.ParentPlanType, + ParentPrivacyMode: a.ParentPrivacyMode, ParentSubscriptionExpiresAt: a.ParentSubscriptionExpiresAt, + ParentChatGPTAccountID: a.ParentChatGPTAccountID, Proxy: a.Proxy, GroupIDs: a.GroupIDs, + } +} + func timeToUnixSeconds(value *time.Time) *int64 { if value == nil { return nil @@ -723,6 +765,7 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog { UpstreamModelMismatch: l.UpstreamModelMismatch, ChannelID: l.ChannelID, ModelMappingChain: l.ModelMappingChain, + UpstreamRequestID: l.UpstreamRequestID, BillingTier: l.BillingTier, AccountRateMultiplier: l.AccountRateMultiplier, AccountStatsCost: l.AccountStatsCost, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index ff9797d86f..eba009a7e6 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -307,6 +307,7 @@ type SystemSettings struct { ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` + ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"` // Grok model mapping policy (admin settings; empty account mapping falls back to these). GrokDefaultTextModel string `json:"grok_default_text_model"` @@ -417,6 +418,7 @@ type PublicSettings struct { ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` + ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"` AvailableChannelsEnabled bool `json:"available_channels_enabled"` diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 7fb8373810..7c952af6d2 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -151,11 +151,11 @@ type Group struct { // RPMLimit 分组级每分钟请求数上限(0 = 不限制),设置后覆盖用户级 rpm_limit。 RPMLimit int `json:"rpm_limit"` - // MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。 + // MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。 MaxReasoningEffort string `json:"max_reasoning_effort"` // MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。 MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"` - // ReasoningEffortMappings OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。 + // ReasoningEffortMappings Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。 ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"` CreatedAt time.Time `json:"created_at"` @@ -189,7 +189,9 @@ type AdminGroup struct { // OpenAI Messages 调度配置(仅 openai 平台使用) DefaultMappedModel string `json:"default_mapped_model"` MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"` - ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config"` + ModelAllowlist service.GroupModelAllowlist `json:"model_allowlist"` + // 固定账号获取 Codex Model Manifest 配置(仅 openai 平台使用)。 + CodexModelsManifestConfig domain.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"` // 支持的模型系列(仅 antigravity 平台使用) SupportedModelScopes []string `json:"supported_model_scopes"` @@ -322,6 +324,102 @@ type Account struct { Groups []*Group `json:"groups,omitempty"` } +// AccountListItem is the compact representation returned by the admin account +// list when the lite query parameter is enabled. It contains the fields used +// by the account table and runtime indicators, but intentionally omits the +// repeated account_groups and groups object graphs. Fetch /admin/accounts/:id +// for the complete Account DTO when editing or inspecting an account. +type AccountListItem struct { + ID int64 `json:"id"` + Name string `json:"name"` + Notes *string `json:"notes"` + Platform string `json:"platform"` + Type string `json:"type"` + + Credentials map[string]any `json:"credentials,omitempty"` + CredentialsStatus map[string]bool `json:"credentials_status,omitempty"` + Extra map[string]any `json:"extra,omitempty"` + OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"` + + ProxyID *int64 `json:"proxy_id"` + ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"` + ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"` + Concurrency int `json:"concurrency"` + LoadFactor *int `json:"load_factor,omitempty"` + Priority int `json:"priority"` + RateMultiplier float64 `json:"rate_multiplier"` + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *int64 `json:"expires_at"` + AutoPauseOnExpired bool `json:"auto_pause_on_expired"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + Schedulable bool `json:"schedulable"` + + RateLimitedAt *time.Time `json:"rate_limited_at"` + RateLimitResetAt *time.Time `json:"rate_limit_reset_at"` + OverloadUntil *time.Time `json:"overload_until"` + + TempUnschedulableUntil *time.Time `json:"temp_unschedulable_until"` + TempUnschedulableReason string `json:"temp_unschedulable_reason"` + + SessionWindowStart *time.Time `json:"session_window_start"` + SessionWindowEnd *time.Time `json:"session_window_end"` + SessionWindowStatus string `json:"session_window_status"` + + WindowCostLimit *float64 `json:"window_cost_limit,omitempty"` + WindowCostStickyReserve *float64 `json:"window_cost_sticky_reserve,omitempty"` + MaxSessions *int `json:"max_sessions,omitempty"` + SessionIdleTimeoutMin *int `json:"session_idle_timeout_minutes,omitempty"` + BaseRPM *int `json:"base_rpm,omitempty"` + RPMStrategy *string `json:"rpm_strategy,omitempty"` + RPMStickyBuffer *int `json:"rpm_sticky_buffer,omitempty"` + UserMsgQueueMode *string `json:"user_msg_queue_mode,omitempty"` + EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"` + TLSFingerprintProfileID *int64 `json:"tls_fingerprint_profile_id,omitempty"` + EnableSessionIDMasking *bool `json:"session_id_masking_enabled,omitempty"` + CacheTTLOverrideEnabled *bool `json:"cache_ttl_override_enabled,omitempty"` + CacheTTLOverrideTarget *string `json:"cache_ttl_override_target,omitempty"` + CustomBaseURLEnabled *bool `json:"custom_base_url_enabled,omitempty"` + CustomBaseURL *string `json:"custom_base_url,omitempty"` + + QuotaLimit *float64 `json:"quota_limit,omitempty"` + QuotaUsed *float64 `json:"quota_used,omitempty"` + QuotaDailyLimit *float64 `json:"quota_daily_limit,omitempty"` + QuotaDailyUsed *float64 `json:"quota_daily_used,omitempty"` + QuotaWeeklyLimit *float64 `json:"quota_weekly_limit,omitempty"` + QuotaWeeklyUsed *float64 `json:"quota_weekly_used,omitempty"` + + QuotaDailyResetMode *string `json:"quota_daily_reset_mode,omitempty"` + QuotaDailyResetHour *int `json:"quota_daily_reset_hour,omitempty"` + QuotaWeeklyResetMode *string `json:"quota_weekly_reset_mode,omitempty"` + QuotaWeeklyResetDay *int `json:"quota_weekly_reset_day,omitempty"` + QuotaWeeklyResetHour *int `json:"quota_weekly_reset_hour,omitempty"` + QuotaResetTimezone *string `json:"quota_reset_timezone,omitempty"` + QuotaDailyResetAt *string `json:"quota_daily_reset_at,omitempty"` + QuotaWeeklyResetAt *string `json:"quota_weekly_reset_at,omitempty"` + + QuotaNotifyDailyEnabled *bool `json:"quota_notify_daily_enabled,omitempty"` + QuotaNotifyDailyThreshold *float64 `json:"quota_notify_daily_threshold,omitempty"` + QuotaNotifyWeeklyEnabled *bool `json:"quota_notify_weekly_enabled,omitempty"` + QuotaNotifyWeeklyThreshold *float64 `json:"quota_notify_weekly_threshold,omitempty"` + QuotaNotifyTotalEnabled *bool `json:"quota_notify_total_enabled,omitempty"` + QuotaNotifyTotalThreshold *float64 `json:"quota_notify_total_threshold,omitempty"` + + ParentAccountID *int64 `json:"parent_account_id,omitempty"` + QuotaDimension string `json:"quota_dimension,omitempty"` + ParentEmail string `json:"parent_email,omitempty"` + ParentPlanType string `json:"parent_plan_type,omitempty"` + ParentPrivacyMode string `json:"parent_privacy_mode,omitempty"` + ParentSubscriptionExpiresAt string `json:"parent_subscription_expires_at,omitempty"` + ParentChatGPTAccountID string `json:"parent_chatgpt_account_id,omitempty"` + + Proxy *Proxy `json:"proxy,omitempty"` + GroupIDs []int64 `json:"group_ids,omitempty"` +} + type AccountGroup struct { AccountID int64 `json:"account_id"` GroupID int64 `json:"group_id"` @@ -588,6 +686,8 @@ type AdminUsageLog struct { ChannelID *int64 `json:"channel_id,omitempty"` // ModelMappingChain 模型映射链,如 "a→b→c" ModelMappingChain *string `json:"model_mapping_chain,omitempty"` + // UpstreamRequestID 是直接上游声明的请求标识,仅管理端可见。 + UpstreamRequestID *string `json:"upstream_request_id,omitempty"` // BillingTier 计费层级标签(per_request/image 模式) BillingTier *string `json:"billing_tier,omitempty"` diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index a4bcda50fa..ecd7bc7e38 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -171,6 +171,19 @@ func (h *GatewayHandler) Messages(c *gin.Context) { reqStream := parsedReq.Stream bindRequestedReasoningEffort(c, body, reqModel) ensureCompositeTargetPlatform(c, apiKey, reqModel) + if policyBody, changed, err := applyAnthropicReasoningEffortPolicyForRequest(c, apiKey, body); err != nil { + respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse) + return + } else if changed { + if err := parsedReq.ReplaceBody(policyBody); err != nil { + reqLog.Warn("gateway.reasoning_effort_policy_parse_failed", zap.Error(err)) + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to apply reasoning effort policy") + return + } + body = parsedReq.Body.Bytes() + reqModel = parsedReq.Model + reqStream = parsedReq.Stream + } reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream)) // 解析渠道级模型映射 @@ -395,7 +408,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { zap.Int64("account_id", account.ID), zap.Int("max_waiting", selection.WaitPlan.MaxWaiting), ) - h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", streamStarted) + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later", streamStarted) return } if err == nil && canWait { @@ -610,6 +623,23 @@ func (h *GatewayHandler) Messages(c *gin.Context) { c.Request = c.Request.WithContext(ctx) } + // 会话槽失败释放:failover 链上每个选中的 Anthropic OAuth 账号都注册过同一会话 + // (checkAndRegisterSession)。若请求最终失败(转发失败/客户端中断/换号耗尽), + // 须立即释放本次会话注册——上游从未真正服务该会话,继续占槽会让 max_sessions + // 受限的账号被失败请求的 session hash 卡满整个空闲窗口,后续新会话全部被拒。 + // 成功请求保持既有空闲超时语义(会话按最后活动时间过期)。 + sessionSlotAccounts := make(map[int64]*service.Account) + upstreamServedSession := false + defer func() { + if upstreamServedSession { + return + } + // 客户端可能已断开、请求 ctx 已取消,用独立 ctx 执行释放 + for _, acc := range sessionSlotAccounts { + h.gatewayService.ReleaseAccountSession(context.Background(), acc, sessionKey) + } + }() + for { fs := NewFailoverState(h.maxAccountSwitches, hasBoundSession) retryWithFallback := false @@ -719,7 +749,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) { zap.Int64("account_id", account.ID), zap.Int("max_waiting", selection.WaitPlan.MaxWaiting), ) - h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", streamStarted) + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later", streamStarted) return } if err == nil && canWait { @@ -763,10 +793,15 @@ func (h *GatewayHandler) Messages(c *gin.Context) { h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted) return } + // 尝试被否决(从未转发),立即释放该账号的会话注册 + h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionKey) + delete(sessionSlotAccounts, account.ID) continue } account = latest selection.Account = latest + // 记录本请求注册过会话槽的账号(profit 准入后账号已定) + sessionSlotAccounts[account.ID] = account // 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已 // 抢槽的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。 if selection.ProfitGateActive() || !selection.Acquired { @@ -983,6 +1018,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) { currentSubscription = nil fallbackUsed = true retryWithFallback = true + // 原分组账号已确定性失败(prompt too long),先释放其会话注册再走兜底分组 + for _, acc := range sessionSlotAccounts { + h.gatewayService.ReleaseAccountSession(context.Background(), acc, sessionKey) + } + sessionSlotAccounts = make(map[int64]*service.Account) break } _ = h.antigravityGatewayService.WriteMappedClaudeError(c, account, promptTooLongErr.StatusCode, promptTooLongErr.RequestID, promptTooLongErr.Body) @@ -998,6 +1038,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) { action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr) switch action { case FailoverContinue: + // 本次尝试已确定性失败,立即释放该账号的会话注册 + h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionKey) + delete(sessionSlotAccounts, account.ID) continue case FailoverExhausted: h.handleFailoverExhausted(c, fs.LastFailoverErr, account.Platform, streamStarted) @@ -1036,6 +1079,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { // 不会走到这里重复计费。 if result != nil { submitForwardUsage(result) + // 上游已接受并计量本次会话(流中断),会话槽保持既有语义 + upstreamServedSession = true } return } @@ -1061,6 +1106,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } submitForwardUsage(result) + // 转发成功,会话槽保持既有空闲超时语义 + upstreamServedSession = true return } if !retryWithFallback { @@ -1087,11 +1134,20 @@ func (h *GatewayHandler) Models(c *gin.Context) { platform = forcedPlatform } + if platform == service.PlatformOpenAI && apiKey != nil && apiKey.Group != nil && + apiKey.Group.Platform == service.PlatformOpenAI && apiKey.Group.CodexModelsManifestConfig.Enabled { + h.pinnedOpenAIModels(c, apiKey.Group) + return + } + if platform == service.PlatformComposite { availableModels := h.compositeAvailableModels(c.Request.Context(), groupID) - if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() { - availableModels = filterModelsByCustomList(availableModels, defaultModelIDsForPlatform(service.PlatformComposite), apiKey.Group.ModelsListConfig.Models) - writeCustomModelsList(c, service.PlatformComposite, availableModels) + if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() { + source := availableModels + if len(source) == 0 { + source = defaultModelIDsForPlatform(service.PlatformComposite) + } + writeAllowlistedModelsList(c, service.PlatformComposite, apiKey.Group.ModelAllowlist.FilterForListing(source)) return } if len(availableModels) > 0 { @@ -1104,10 +1160,9 @@ func (h *GatewayHandler) Models(c *gin.Context) { // Get available models from account configurations for the selected group platform. availableModels := h.gatewayService.GetAvailableModels(c.Request.Context(), groupID, platform) - if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() { - fallbackModels := defaultModelIDsForPlatform(platform) - availableModels = filterModelsByCustomList(customModelsListSource(platform, availableModels, fallbackModels), fallbackModels, apiKey.Group.ModelsListConfig.Models) - writeCustomModelsList(c, platform, availableModels) + if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() { + source := modelListingSource(platform, availableModels, defaultModelIDsForPlatform(platform)) + writeAllowlistedModelsList(c, platform, apiKey.Group.ModelAllowlist.FilterForListing(source)) return } @@ -1192,8 +1247,12 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi if platform == service.PlatformComposite { availableModels := h.compositeAvailableModels(ctx, groupID) fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite) - if group.CustomModelsListEnabled() { - return filterModelsByCustomList(availableModels, fallbackModels, group.ModelsListConfig.Models) + if group.ModelAllowlistEnabled() { + source := availableModels + if len(source) == 0 { + source = fallbackModels + } + return group.ModelAllowlist.FilterForListing(source) } if len(availableModels) > 0 { return availableModels @@ -1203,12 +1262,8 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi availableModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform) fallbackModels := defaultCodexModelIDsForPlatform(platform) - if group.CustomModelsListEnabled() { - return filterModelsByCustomList( - customModelsListSource(platform, availableModels, fallbackModels), - fallbackModels, - group.ModelsListConfig.Models, - ) + if group.ModelAllowlistEnabled() { + return group.ModelAllowlist.FilterForListing(modelListingSource(platform, availableModels, fallbackModels)) } if len(availableModels) > 0 { return availableModels @@ -1223,7 +1278,7 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID * seen := make(map[string]struct{}) models := make([]string, 0) schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID) - for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} { + for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} { platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform) if len(platformModels) == 0 { // CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的 @@ -1248,6 +1303,10 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID * } func writeModelsList(c *gin.Context, platform string, modelIDs []string) { + if platform == service.PlatformOpenAI { + writeOpenAIModelsList(c, modelIDs) + return + } if platform == service.PlatformGrok { writeGrokModelsList(c, modelIDs) return @@ -1267,7 +1326,7 @@ func writeModelsList(c *gin.Context, platform string, modelIDs []string) { }) } -func writeCustomModelsList(c *gin.Context, platform string, modelIDs []string) { +func writeAllowlistedModelsList(c *gin.Context, platform string, modelIDs []string) { if platform == service.PlatformOpenAI { writeOpenAIModelsList(c, modelIDs) return @@ -1365,76 +1424,25 @@ func writeOpenAIModelsList(c *gin.Context, modelIDs []string) { }) } -func customModelsListSource(platform string, availableModels, fallbackModels []string) []string { - if platform == service.PlatformAnthropic && len(availableModels) > 0 { +// modelListingSource 汇总模型列表过滤的候选来源:账号映射键(availableModels) +// 与平台默认列表(fallbackModels)。账号映射为空时回落默认列表;Anthropic +// 平台两者取并集,其余平台以账号映射键为准。 +func modelListingSource(platform string, availableModels, fallbackModels []string) []string { + if len(availableModels) == 0 { + return fallbackModels + } + if platform == service.PlatformAnthropic { return mergeModelIDs(availableModels, fallbackModels) } return availableModels } -func filterModelsByCustomList(availableModels, fallbackModels, selectedModels []string) []string { - if len(selectedModels) == 0 { - return availableModels - } - source := availableModels - if len(source) == 0 { - source = fallbackModels - } - if len(source) == 0 { - return nil - } - - allowed := make([]string, 0, len(source)) - for _, model := range source { - model = strings.TrimSpace(model) - if model != "" { - allowed = append(allowed, model) - } - } - - seen := make(map[string]struct{}, len(selectedModels)) - filtered := make([]string, 0, len(selectedModels)) - for _, model := range selectedModels { - model = strings.TrimSpace(model) - if model == "" { - continue - } - if !customModelsListAllowsModel(allowed, model) { - continue - } - if _, ok := seen[model]; ok { - continue - } - seen[model] = struct{}{} - filtered = append(filtered, model) - } - return filtered -} - -func customModelsListAllowsModel(availablePatterns []string, model string) bool { - for _, pattern := range availablePatterns { - if pattern == model { - return true - } - if strings.HasSuffix(pattern, "*") && strings.HasPrefix(model, strings.TrimSuffix(pattern, "*")) { - return true - } - } - normalizedClaudeModel := claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking")) - if normalizedClaudeModel != model { - for _, pattern := range availablePatterns { - if pattern == normalizedClaudeModel { - return true - } - } - } - return false -} - func defaultCodexModelIDsForPlatform(platform string) []string { switch platform { case service.PlatformDeepseek: return []string{"deepseek-v4-pro", "deepseek-v4-flash"} + case service.PlatformMiniMax: + return []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"} default: return defaultModelIDsForPlatform(platform) } @@ -1464,7 +1472,7 @@ func defaultModelIDsForPlatform(platform string) []string { case service.PlatformComposite: ids := make([]string, 0) seen := make(map[string]struct{}) - for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} { + for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} { for _, id := range defaultModelIDsForPlatform(concretePlatform) { if _, ok := seen[id]; ok { continue @@ -1504,10 +1512,21 @@ func mergeModelIDs(primary, secondary []string) []string { // AntigravityModels 返回 Antigravity 支持的全部模型 // GET /antigravity/models +// 分组级模型白名单开启时按白名单过滤。 func (h *GatewayHandler) AntigravityModels(c *gin.Context) { + models := antigravity.DefaultModels() + if apiKey, ok := middleware2.GetAPIKeyFromContext(c); ok && apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() { + filtered := make([]antigravity.ClaudeModel, 0, len(models)) + for _, model := range models { + if apiKey.Group.ModelAllowlist.Allows(model.ID) { + filtered = append(filtered, model) + } + } + models = filtered + } c.JSON(http.StatusOK, gin.H{ "object": "list", - "data": antigravity.DefaultModels(), + "data": models, }) } @@ -1853,8 +1872,8 @@ func (h *GatewayHandler) calculateSubscriptionRemaining(group *service.Group, su // handleConcurrencyError handles concurrency-related acquire errors. func (h *GatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) { - status, errType, message := concurrencyErrorResponse(err, slotType) - h.handleStreamingAwareError(c, status, errType, message, streamStarted) + status, errType, code, message := concurrencyErrorResponse(err, slotType) + h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, streamStarted) } func (h *GatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, platform string, streamStarted bool) { @@ -1925,6 +1944,10 @@ func (h *GatewayHandler) mapUpstreamError(statusCode int) (int, string, string) // handleStreamingAwareError handles errors that may occur after streaming has started func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) { + h.handleStreamingAwareErrorWithCode(c, status, errType, "", message, streamStarted) +} + +func (h *GatewayHandler) handleStreamingAwareErrorWithCode(c *gin.Context, status int, errType, code, message string, streamStarted bool) { if streamStarted { // 响应状态码已固化为 200(ping/部分数据已 flush),错误只能就地以 SSE 帧回传。 // 标记本次流内错误,供 ops_error_logger 补记——否则该中间件按 status>=400 采集, @@ -1935,7 +1958,7 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e // response.completed/failed/incomplete/cancelled 集合。 // Anthropic-backed Responses 路径同样会因为通用 error 帧被拒。 if inboundIsResponses(c) { - if writeResponsesFailedSSE(c, errType, message) { + if writeResponsesFailedSSE(c, errType, code, message) { return } } @@ -1943,7 +1966,11 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e flusher, ok := c.Writer.(http.Flusher) if ok { // SSE 错误事件固定 schema,使用 Quote 直拼可避免额外 Marshal 分配。 - errorEvent := `data: {"type":"error","error":{"type":` + strconv.Quote(errType) + `,"message":` + strconv.Quote(message) + `}}` + "\n\n" + errorCode := "" + if code != "" { + errorCode = `,"code":` + strconv.Quote(code) + } + errorEvent := `data: {"type":"error","error":{"type":` + strconv.Quote(errType) + errorCode + `,"message":` + strconv.Quote(message) + `}}` + "\n\n" if _, err := fmt.Fprint(c.Writer, errorEvent); err != nil { _ = c.Error(err) } @@ -1953,7 +1980,7 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e } // Normal case: return JSON response with proper status code - h.errorResponse(c, status, errType, message) + h.errorResponseWithCode(c, status, errType, code, message) } // ensureForwardErrorResponse 在 Forward 返回错误但尚未写响应时补写统一错误响应。 @@ -2045,12 +2072,17 @@ func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool { // errorResponse 返回Claude API格式的错误响应 func (h *GatewayHandler) errorResponse(c *gin.Context, status int, errType, message string) { + h.errorResponseWithCode(c, status, errType, "", message) +} + +func (h *GatewayHandler) errorResponseWithCode(c *gin.Context, status int, errType, code, message string) { + errorObject := gin.H{"type": errType, "message": message} + if code != "" { + errorObject["code"] = code + } c.JSON(status, gin.H{ - "type": "error", - "error": gin.H{ - "type": errType, - "message": message, - }, + "type": "error", + "error": errorObject, }) } @@ -2163,6 +2195,8 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) { if err := h.gatewayService.ForwardCountTokens(c.Request.Context(), c, account, parsedReq); err != nil { reqLog.Error("gateway.count_tokens_forward_failed", zap.Int64("account_id", account.ID), zap.Error(err)) // 错误响应已在 ForwardCountTokens 中处理 + // 上游未服务该会话,立即释放选号时注册的会话槽(客户端可能已断开,用独立 ctx) + h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionHash) return } } diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index 04a61cb16e..a170877315 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -394,7 +394,7 @@ func (h *GatewayHandler) handleResponsesFailoverExhausted(c *gin.Context, lastEr // generic response.failed. service.MarkOpsStreamError(c, code, message, status) if c != nil && c.Writer != nil && (c.Writer.Size() <= 0 || gatewayStreamHasOnlyHeartbeats(c)) { - writeResponsesFailedSSE(c, code, message) + writeResponsesFailedSSE(c, code, "", message) } return } diff --git a/backend/internal/handler/gateway_helper.go b/backend/internal/handler/gateway_helper.go index abc11904d4..02c275e2b0 100644 --- a/backend/internal/handler/gateway_helper.go +++ b/backend/internal/handler/gateway_helper.go @@ -89,6 +89,10 @@ func claudeCodeBodyMapFromParsedRequest(parsedReq *service.ParsedRequest) map[st bodyMap := map[string]any{ "model": parsedReq.Model, } + // 探测识别(max_tokens=1)需要看到该字段,复用已解析请求时一并带上。 + if parsedReq.MaxTokens > 0 { + bodyMap["max_tokens"] = parsedReq.MaxTokens + } if parsedReq.HasSystem { if system, ok := parsedReq.SystemValue(); ok { bodyMap["system"] = system diff --git a/backend/internal/handler/gateway_helper_hotpath_test.go b/backend/internal/handler/gateway_helper_hotpath_test.go index 5e0697f083..208021b9ec 100644 --- a/backend/internal/handler/gateway_helper_hotpath_test.go +++ b/backend/internal/handler/gateway_helper_hotpath_test.go @@ -500,3 +500,19 @@ type helperConcurrencyCacheStubWithError struct { func (s *helperConcurrencyCacheStubWithError) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) { return false, s.err } + +func TestSetClaudeCodeClientContext_ParsedRequestProbeWithoutSystemPrompt(t *testing.T) { + c, _ := newHelperTestContext(http.MethodPost, "/v1/messages") + c.Request.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)") + + // The hot path reuses ParsedRequest instead of re-parsing the body; the probe + // marker must survive that projection or the exemption only works cold. + parsed := &service.ParsedRequest{Model: "claude-sonnet-4-5", MaxTokens: 1} + SetClaudeCodeClientContext(c, nil, parsed) + require.True(t, service.IsClaudeCodeClient(c.Request.Context())) + + c2, _ := newHelperTestContext(http.MethodPost, "/v1/messages") + c2.Request.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)") + SetClaudeCodeClientContext(c2, nil, &service.ParsedRequest{Model: "claude-sonnet-4-5", MaxTokens: 64}) + require.False(t, service.IsClaudeCodeClient(c2.Request.Context())) +} diff --git a/backend/internal/handler/gateway_model_allowlist_listing_test.go b/backend/internal/handler/gateway_model_allowlist_listing_test.go new file mode 100644 index 0000000000..55a663dbc0 --- /dev/null +++ b/backend/internal/handler/gateway_model_allowlist_listing_test.go @@ -0,0 +1,245 @@ +package handler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// 通配条目在 /v1/models 中展开为候选来源中所有匹配项,保持来源顺序。 +func TestGatewayModels_ModelAllowlistWildcardExpandsAgainstSource(t *testing.T) { + gin.SetMode(gin.TestMode) + + groupID := int64(31) + h := newGatewayModelsHandlerForTest( + &gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "gpt-5.4": "gpt-5.4", + "gpt-5.5-codex": "gpt-5.5-codex", + "gpt-5.5-mini": "gpt-5.5-mini", + "other-foo": "other-foo", + }, + }, + }, + }, + }, + }, + ) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ + ID: groupID, + Platform: service.PlatformOpenAI, + ModelAllowlist: service.GroupModelAllowlist{ + Enabled: true, + Models: []string{"gpt-5.5-*", "gpt-5.4"}, + }, + }, + }) + + h.Models(c) + + require.Equal(t, http.StatusOK, rec.Code) + + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, []string{"gpt-5.5-codex", "gpt-5.5-mini", "gpt-5.4"}, modelIDsForTest(got.Data)) +} + +// geminiAllowlistAccountRepoStub 在 gatewayModelsAccountRepoStub 之上补充 +// Gemini 兼容层用到的按平台过滤查询(分组内无任何账号,触发 fallback 列表)。 +type geminiAllowlistAccountRepoStub struct { + gatewayModelsAccountRepoStub +} + +func (s *geminiAllowlistAccountRepoStub) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]service.Account, error) { + allowed := make(map[string]struct{}, len(platforms)) + for _, platform := range platforms { + allowed[platform] = struct{}{} + } + accounts := s.byGroup[groupID] + filtered := make([]service.Account, 0, len(accounts)) + for _, account := range accounts { + if _, ok := allowed[account.Platform]; ok { + filtered = append(filtered, account) + } + } + return filtered, nil +} + +// Gemini 原生 /v1beta/models:白名单开启时过滤 fallback 列表的 models[].name。 +func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) { + gin.SetMode(gin.TestMode) + + repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + // 只有 antigravity 账号:Gemini 选号失败后回落静态模型列表。 + 41: {{ID: 2, Platform: service.PlatformAntigravity, Status: service.StatusActive, Schedulable: true}}, + }, + }} + h := &GatewayHandler{ + geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil), + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil) + geminiGroupID := int64(41) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + GroupID: &geminiGroupID, + Group: &service.Group{ + ID: geminiGroupID, + Platform: service.PlatformGemini, + ModelAllowlist: service.GroupModelAllowlist{ + Enabled: true, + Models: []string{"gemini-2.5-pro", "gemini-3-*"}, + }, + }, + }) + + h.GeminiV1BetaListModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + + var got struct { + Models []struct { + Name string `json:"name"` + } `json:"models"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + names := make([]string, 0, len(got.Models)) + for _, model := range got.Models { + names = append(names, model.Name) + } + // models/ 前缀的候选形式也应命中条目。 + require.Contains(t, names, "models/gemini-2.5-pro") + require.Contains(t, names, "models/gemini-3-pro-preview") + require.NotContains(t, names, "models/gemini-2.5-flash") + require.NotContains(t, names, "models/gemini-2.0-flash") +} + +// Antigravity /antigravity/models:白名单开启时按条目过滤静态列表。 +func TestAntigravityModels_FiltersByAllowlist(t *testing.T) { + gin.SetMode(gin.TestMode) + + h := &GatewayHandler{} + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/antigravity/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ + ID: 51, + Platform: service.PlatformAntigravity, + ModelAllowlist: service.GroupModelAllowlist{ + Enabled: true, + Models: []string{"gemini-2.5-flash"}, + }, + }, + }) + + h.AntigravityModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + + var got struct { + Object string `json:"object"` + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, "list", got.Object) + // -thinking 后缀的宽容规则:gemini-2.5-flash-thinking 也命中 gemini-2.5-flash 条目。 + require.Len(t, got.Data, 2) + require.Equal(t, "gemini-2.5-flash", got.Data[0].ID) + require.Equal(t, "gemini-2.5-flash-thinking", got.Data[1].ID) +} + +func TestFilterUpstreamGeminiModelsBody(t *testing.T) { + allowlist := service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-2.5-pro"}} + + t.Run("full hit returns original body with dropped=false", func(t *testing.T) { + body := []byte(`{"models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-pro"}]}`) + filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist) + require.True(t, ok) + require.False(t, dropped, "full hit must keep the original response so headers pass through") + require.Equal(t, string(body), string(filtered)) + }) + + t.Run("partial hit filters models and preserves envelope fields", func(t *testing.T) { + body := []byte(`{"nextPageToken":"tok","models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-flash"}],"other":"kept"}`) + filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist) + require.True(t, ok) + require.True(t, dropped) + require.JSONEq(t, `{"nextPageToken":"tok","other":"kept","models":[{"name":"models/gemini-2.5-pro"}]}`, string(filtered)) + }) + + t.Run("models prefix stripped candidate form matches entry", func(t *testing.T) { + body := []byte(`{"models":[{"name":"gemini-2.5-pro"}]}`) + filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist) + require.True(t, ok) + require.False(t, dropped) + require.Equal(t, string(body), string(filtered)) + }) + + t.Run("missing models field passes through", func(t *testing.T) { + body := []byte(`{"error":"odd upstream"}`) + _, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist) + require.True(t, ok) + require.False(t, dropped) + }) + + t.Run("invalid JSON signals passthrough", func(t *testing.T) { + _, dropped, ok := filterUpstreamGeminiModelsBody([]byte(`not-json`), allowlist) + require.False(t, ok) + require.False(t, dropped) + }) +} + +func TestFilterBatchImageModelsByAllowlist(t *testing.T) { + models := []service.BatchImagePublicModel{ + {ID: "gemini-2.5-flash-image", Object: "image.batch.model", Provider: "gemini_api"}, + {ID: "gemini-3-pro-image", Object: "image.batch.model", Provider: "vertex"}, + {ID: "gpt-image-1", Object: "image.batch.model", Provider: "openai"}, + } + + t.Run("disabled allowlist keeps everything", func(t *testing.T) { + got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{Enabled: false}) + require.Equal(t, models, got) + }) + + t.Run("exact and wildcard entries filter by model id", func(t *testing.T) { + got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{ + Enabled: true, + Models: []string{"gemini-3-*", "gpt-image-1"}, + }) + require.Equal(t, []string{"gemini-3-pro-image", "gpt-image-1"}, []string{got[0].ID, got[1].ID}) + require.Len(t, got, 2) + }) + + t.Run("no match yields empty list", func(t *testing.T) { + got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{ + Enabled: true, + Models: []string{"claude-*"}, + }) + require.Empty(t, got) + }) +} diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 386954a17e..9252851cd6 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -181,7 +182,7 @@ func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) { } } -// Scenario: Composite manifests aggregate only administrator-configured models. +// Composite manifests include defaults from unmapped accounts and explicit mappings. func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) { gin.SetMode(gin.TestMode) const groupID int64 = 120 @@ -225,7 +226,105 @@ func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T require.Equal(t, http.StatusOK, rec.Code) var got codexModelsResponseForTest require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) - require.Equal(t, []string{"gpt-5.5", "grok-4.6"}, codexModelSlugsForTest(got.Models)) + want := service.FilterCodexModelIDsForGroup(openai.DefaultModelIDs(), nil) + require.ElementsMatch(t, append(want, "grok-4.6"), codexModelSlugsForTest(got.Models)) +} + +func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 28 + const sparkModel = "gpt-5.3-codex-spark" + const alias = "team-coder" + parentID := int64(1) + accounts := []service.Account{ + {ID: parentID, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, + { + ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + ParentAccountID: &parentID, QuotaDimension: "spark", + Credentials: map[string]any{"model_mapping": map[string]any{sparkModel: sparkModel}}, + }, + { + ID: 3, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"model_mapping": map[string]any{alias: "gpt-5.6-sol"}}, + }, + } + tests := []struct { + name string + accounts []service.Account + config service.GroupModelAllowlist + want []string + }{ + { + name: "unmapped parent and Spark shadow retain defaults and aliases", + accounts: accounts, + want: append(openai.DefaultModelIDs(), alias), + }, + { + name: "unmapped API key account also contributes defaults", + accounts: append([]service.Account{{ID: 4, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey}}, accounts[1:]...), + want: append(openai.DefaultModelIDs(), alias), + }, + { + name: "unmapped accounts alone retain default response shape", + accounts: accounts[:1], + want: openai.DefaultModelIDs(), + }, + { + name: "custom list can select defaults and aliases", + accounts: accounts, + config: service.GroupModelAllowlist{Enabled: true, Models: []string{alias, "gpt-5.6-sol", sparkModel, "unknown-model"}}, + want: []string{alias, "gpt-5.6-sol", sparkModel}, + }, + { + name: "unavailable custom selection remains empty", + accounts: accounts, + config: service.GroupModelAllowlist{Enabled: true, Models: []string{"unknown-model"}}, + want: []string{}, + }, + { + name: "mapped accounts alone do not gain defaults", + accounts: accounts[1:], + want: []string{sparkModel, alias}, + }, + { + name: "unmapped accounts from another platform do not add defaults", + accounts: append([]service.Account{{ID: 4, Platform: service.PlatformAnthropic}}, accounts[1:]...), + want: []string{sparkModel, alias}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{groupID: tt.accounts}, + }) + for range 2 { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, ModelAllowlist: tt.config}, + }) + h.Models(c) + require.Equal(t, http.StatusOK, rec.Code) + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, "list", got.Object) + require.ElementsMatch(t, tt.want, modelIDsForTest(got.Data)) + for _, model := range got.Data { + require.Equal(t, "model", model.Object, model.ID) + require.Positive(t, model.Created, model.ID) + require.Equal(t, "openai", model.OwnedBy, model.ID) + require.Empty(t, model.CreatedAt, model.ID) + } + if tt.config.Enabled { + require.Equal(t, tt.want, modelIDsForTest(got.Data)) + } + } + }) + } + require.Empty(t, accounts[0].GetModelMapping()) + require.True(t, accounts[0].IsModelSupported("gpt-future-model")) + require.False(t, accounts[1].IsModelSupported("gpt-5.6-sol")) } func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) { @@ -267,7 +366,7 @@ func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) { require.Equal(t, etag, second.Header().Get("ETag")) } -// Scenario: group models_list_config limits the generated Codex manifest. +// Scenario: group model_allowlist limits the generated Codex manifest. func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.T) { gin.SetMode(gin.TestMode) const groupID int64 = 121 @@ -299,7 +398,7 @@ func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing. Group: &service.Group{ ID: groupID, Platform: service.PlatformComposite, - ModelsListConfig: service.GroupModelsListConfig{ + ModelAllowlist: service.GroupModelAllowlist{ Enabled: true, Models: []string{"grok-4.6"}, }, @@ -572,7 +671,7 @@ func TestGatewayModels_CustomModelsListDisabledKeepsOriginalModels(t *testing.T) Group: &service.Group{ ID: groupID, Platform: service.PlatformOpenAI, - ModelsListConfig: service.GroupModelsListConfig{ + ModelAllowlist: service.GroupModelAllowlist{ Enabled: false, Models: []string{"gpt-5.5"}, }, @@ -619,7 +718,7 @@ func TestGatewayModels_CustomModelsListFiltersAndOrdersMappedModels(t *testing.T Group: &service.Group{ ID: groupID, Platform: service.PlatformOpenAI, - ModelsListConfig: service.GroupModelsListConfig{ + ModelAllowlist: service.GroupModelAllowlist{ Enabled: true, Models: []string{"gpt-5.5", "missing-model", "gpt-5.4"}, }, @@ -692,6 +791,13 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t "model_mapping": map[string]any{"deepseek-custom": "deepseek-upstream"}, }, }, + { + ID: 7, + Platform: service.PlatformMiniMax, + Credentials: map[string]any{ + "model_mapping": map[string]any{"minimax-custom": "MiniMax-M3"}, + }, + }, }, }, }, @@ -704,9 +810,9 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t Group: &service.Group{ ID: groupID, Platform: service.PlatformComposite, - ModelsListConfig: service.GroupModelsListConfig{ + ModelAllowlist: service.GroupModelAllowlist{ Enabled: true, - Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"}, + Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"}, }, }, }) @@ -717,7 +823,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t var got gatewayModelsResponseForTest require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) - require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"}, modelIDsForTest(got.Data)) + require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"}, modelIDsForTest(got.Data)) } func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t *testing.T) { @@ -770,6 +876,7 @@ func TestGatewayModels_CompositeUnmappedCNAccountsContributeNoDefaults(t *testin {ID: 2, Platform: service.PlatformKimi}, {ID: 3, Platform: service.PlatformZhipu}, {ID: 4, Platform: service.PlatformDeepseek}, + {ID: 5, Platform: service.PlatformMiniMax}, }, }, }, @@ -801,13 +908,14 @@ func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T) for _, model := range claude.DefaultModels { want = append(want, model.ID) } - for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} { + for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} { require.Equal(t, want, defaultModelIDsForPlatform(platform), "platform=%s", platform) } } func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T) { require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek)) + require.Equal(t, []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}, defaultCodexModelIDsForPlatform(service.PlatformMiniMax)) require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic)) } @@ -918,7 +1026,7 @@ func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMappin Group: &service.Group{ ID: groupID, Platform: service.PlatformAnthropic, - ModelsListConfig: service.GroupModelsListConfig{ + ModelAllowlist: service.GroupModelAllowlist{ Enabled: true, Models: []string{"claude-sonnet-4-6"}, }, @@ -969,7 +1077,7 @@ func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeAndMappedDeep Group: &service.Group{ ID: groupID, Platform: service.PlatformAnthropic, - ModelsListConfig: service.GroupModelsListConfig{ + ModelAllowlist: service.GroupModelAllowlist{ Enabled: true, Models: []string{"claude-fable-5", "claude-opus-4-8", "deepseek-v4-pro"}, }, @@ -1020,7 +1128,7 @@ func TestGatewayModels_AnthropicCustomModelsListDisabledKeepsMappedModelList(t * Group: &service.Group{ ID: groupID, Platform: service.PlatformAnthropic, - ModelsListConfig: service.GroupModelsListConfig{ + ModelAllowlist: service.GroupModelAllowlist{ Enabled: false, Models: []string{"claude-fable-5", "deepseek-v4-pro"}, }, @@ -1061,7 +1169,7 @@ func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeWithoutMappin Group: &service.Group{ ID: groupID, Platform: service.PlatformAnthropic, - ModelsListConfig: service.GroupModelsListConfig{ + ModelAllowlist: service.GroupModelAllowlist{ Enabled: true, Models: []string{"claude-opus-4-6-thinking", "claude-sonnet-4-5"}, }, @@ -1106,7 +1214,7 @@ func TestGatewayModels_CustomModelsListCanReturnEmptyWhenSelectionsUnavailable(t Group: &service.Group{ ID: groupID, Platform: service.PlatformOpenAI, - ModelsListConfig: service.GroupModelsListConfig{ + ModelAllowlist: service.GroupModelAllowlist{ Enabled: true, Models: []string{"gpt-5.5"}, }, @@ -1143,7 +1251,7 @@ func TestGatewayModels_CustomModelsListFiltersDefaultFallbackModels(t *testing.T Group: &service.Group{ ID: groupID, Platform: service.PlatformOpenAI, - ModelsListConfig: service.GroupModelsListConfig{ + ModelAllowlist: service.GroupModelAllowlist{ Enabled: true, Models: []string{"gpt-5.5", "legacy-gpt-2024", "gpt-5.4"}, }, @@ -1180,7 +1288,7 @@ func TestGatewayModels_OpenAICustomModelsListKeepsOpenAIResponseShapeForDefaultF Group: &service.Group{ ID: groupID, Platform: service.PlatformOpenAI, - ModelsListConfig: service.GroupModelsListConfig{ + ModelAllowlist: service.GroupModelAllowlist{ Enabled: true, Models: []string{"gpt-5.5", "gpt-5.4"}, }, diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index fc01d51725..d8ec24670b 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -46,8 +46,33 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { return } + // 分组级模型白名单开启时过滤 models[].name(名字形如 models/xxx)。 + filterGeminiModels := func(models []gemini.Model) []gemini.Model { + if apiKey.Group == nil || !apiKey.Group.ModelAllowlistEnabled() { + return models + } + filtered := make([]gemini.Model, 0, len(models)) + for _, model := range models { + if apiKey.Group.ModelAllowlist.Allows(model.Name) { + filtered = append(filtered, model) + } + } + return filtered + } + // 强制 antigravity 模式:返回 antigravity 支持的模型列表 if forcePlatform == service.PlatformAntigravity { + if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() { + agModels := antigravity.DefaultGeminiModels() + filtered := make([]antigravity.GeminiModel, 0, len(agModels)) + for _, model := range agModels { + if apiKey.Group.ModelAllowlist.Allows(model.Name) { + filtered = append(filtered, model) + } + } + c.JSON(http.StatusOK, antigravity.GeminiModelsListResponse{Models: filtered}) + return + } c.JSON(http.StatusOK, antigravity.FallbackGeminiModelsList()) return } @@ -58,7 +83,7 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { hasAntigravity, _ := h.geminiCompatService.HasAntigravityAccounts(c.Request.Context(), apiKey.GroupID) if hasAntigravity { // antigravity 账户使用静态模型列表 - c.JSON(http.StatusOK, gemini.FallbackModelsList()) + c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())}) return } markOpsRoutingCapacityLimitedIfNoAvailable(c, err) @@ -72,12 +97,66 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { return } if shouldFallbackGeminiModels(res) { - c.JSON(http.StatusOK, gemini.FallbackModelsList()) + c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())}) return } + if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() { + if filtered, dropped, ok := filterUpstreamGeminiModelsBody(res.Body, apiKey.Group.ModelAllowlist); ok && dropped { + // 只在确有条目被过滤时替换响应体;全命中或解析失败时保持原始响应, + // 统一经 writeUpstreamResponse 写出(保留全部上游响应头)。 + res.Body = filtered + } + } writeUpstreamResponse(c, res) } +// filterUpstreamGeminiModelsBody 按白名单过滤上游 /v1beta/models 响应中的 +// models[].name,其余信封字段(如 nextPageToken)原样保留。 +// 返回值:filtered 为过滤后的响应体;dropped 表示是否有条目被移除(全命中时 +// 为 false,调用方应保持原始响应以完整透传上游头);ok=false 表示解析失败, +// 调用方同样应透传原始响应。 +func filterUpstreamGeminiModelsBody(body []byte, allowlist service.GroupModelAllowlist) (filtered []byte, dropped bool, ok bool) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, false, false + } + rawModels, hasModels := envelope["models"] + if !hasModels { + return body, false, true + } + type geminiModelName struct { + Name string `json:"name"` + } + var models []json.RawMessage + if err := json.Unmarshal(rawModels, &models); err != nil { + return nil, false, false + } + kept := make([]json.RawMessage, 0, len(models)) + for _, raw := range models { + var model geminiModelName + if err := json.Unmarshal(raw, &model); err != nil { + return nil, false, false + } + if allowlist.Allows(model.Name) { + kept = append(kept, raw) + } + } + if len(kept) == len(models) { + // 全部命中时直接透传原始响应体。 + return body, false, true + } + mergedModels, err := json.Marshal(kept) + if err != nil { + return nil, false, false + } + envelope["models"] = mergedModels + merged, err := json.Marshal(envelope) + if err != nil { + return nil, false, false + } + return merged, true, true +} + // GeminiV1BetaGetModel proxies: // GET /v1beta/models/{model} func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) { diff --git a/backend/internal/handler/gemini_v1beta_handler_test.go b/backend/internal/handler/gemini_v1beta_handler_test.go index 29d7cc4169..2bf25a6b96 100644 --- a/backend/internal/handler/gemini_v1beta_handler_test.go +++ b/backend/internal/handler/gemini_v1beta_handler_test.go @@ -3,13 +3,58 @@ package handler import ( + "encoding/json" "net/http" + "net/http/httptest" "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) +func TestGeminiV1BetaListModels_AllowlistFiltersNativeResponse(t *testing.T) { + body := []byte(`{"models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-flash"}],"nextPageToken":"next"}`) + filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-2.5-pro"}}) + require.True(t, ok) + require.True(t, dropped) + require.JSONEq(t, `{"models":[{"name":"models/gemini-2.5-pro"}],"nextPageToken":"next"}`, string(filtered)) +} + +func TestGeminiV1BetaListModels_ForcedAntigravityAppliesAllowlist(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/antigravity/v1beta/models", nil) + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ + Platform: service.PlatformGemini, + ModelAllowlist: service.GroupModelAllowlist{ + Enabled: true, + Models: []string{"gemini-custom"}, + }, + }, + }) + c.Set(string(middleware.ContextKeyForcePlatform), service.PlatformAntigravity) + + (&GatewayHandler{}).GeminiV1BetaListModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got antigravity.GeminiModelsListResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Empty(t, got.Models) +} + +func TestGeminiModelAllowlist_DisabledPreservesNativeResponse(t *testing.T) { + body := []byte(`{"models":[{"name":"models/gemini-2.5-pro"}]}`) + filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, service.GroupModelAllowlist{Enabled: false, Models: []string{"other"}}) + require.True(t, ok) + require.False(t, dropped) + require.Equal(t, body, filtered) +} + // TestGeminiV1BetaHandler_PlatformRoutingInvariant 文档化并验证 Handler 层的平台路由逻辑不变量 // 该测试确保 gemini 和 antigravity 平台的路由逻辑符合预期 func TestGeminiV1BetaHandler_PlatformRoutingInvariant(t *testing.T) { diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go index 8ac8c6ad48..a4d8d502bb 100644 --- a/backend/internal/handler/model_plaza_handler.go +++ b/backend/internal/handler/model_plaza_handler.go @@ -15,7 +15,7 @@ import ( // 广场路由挂 OptionalJWT 中间件:匿名可访问(除非 require_auth 开启),带 token 则 // 识别用户。可见性规则(橱窗语义,与「可用渠道」的可绑定语义不同): // - 匿名:仅非专属分组(订阅型照常展示); -// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性); +// - 登录:非专属分组 + user_allowed_groups 授权或持有有效订阅的专属分组; // 若该用户开启了公开分组限制,则公开分组同样需要落在授权集合内。 type ModelPlazaHandler struct { plazaService *service.ModelPlazaService @@ -160,7 +160,7 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) { } // filterPlazaVisibleGroups 按登录态裁剪分组可见性。 -// allowedGroups == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。 +// allowedGroups == nil 表示匿名(仅非专属);非 nil 包含普通授权及有效订阅分组。 // restrictPublicGroups 为 true 时,公开分组也必须落在 allowedGroups 内,否则用户会 // 在广场看到自己实际绑定不了的分组。 func filterPlazaVisibleGroups( diff --git a/backend/internal/handler/model_plaza_handler_test.go b/backend/internal/handler/model_plaza_handler_test.go index d57e5e1017..2de6cbdbe5 100644 --- a/backend/internal/handler/model_plaza_handler_test.go +++ b/backend/internal/handler/model_plaza_handler_test.go @@ -244,3 +244,17 @@ func TestToModelPlazaGroupDTO_TimePricing(t *testing.T) { weekdaysTP := weekdaysModel["time_pricing"].(map[string]any) require.Equal(t, true, weekdaysTP["weekdays_only"]) } + +func TestFilterPlazaVisibleGroups_SubscribedExclusiveGroup(t *testing.T) { + groups := []service.PlazaGroup{ + {ID: 42, IsExclusive: true, SubscriptionType: "subscription"}, + {ID: 43, IsExclusive: true, SubscriptionType: "subscription"}, + {ID: 44, IsExclusive: true, SubscriptionType: "standard"}, + } + require.Empty(t, filterPlazaVisibleGroups(groups, nil, false)) + for _, restricted := range []bool{false, true} { + visible := filterPlazaVisibleGroups(groups, map[int64]struct{}{42: {}}, restricted) + require.Len(t, visible, 1) + require.Equal(t, int64(42), visible[0].ID) + } +} diff --git a/backend/internal/handler/openai_automation_bootstrap_test.go b/backend/internal/handler/openai_automation_bootstrap_test.go index 7bb7fd68b1..698a3bbd5e 100644 --- a/backend/internal/handler/openai_automation_bootstrap_test.go +++ b/backend/internal/handler/openai_automation_bootstrap_test.go @@ -49,6 +49,20 @@ func TestNormalizeCodexAutomationBootstrapSupportedLastRunValues(t *testing.T) { } } +func TestNormalizeCodexAutomationBootstrapHeartbeat(t *testing.T) { + output := `wiki` + got, changed := normalizeCodexAutomationBootstrap(codexAutomationBootstrapBody(t, output, "")) + require.True(t, changed) + require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String()) + require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String()) + require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String()) + require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists()) + + again, changedAgain := normalizeCodexAutomationBootstrap(got) + require.False(t, changedAgain) + require.Equal(t, got, again) +} + func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) { validOutput := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt) tests := []struct { @@ -75,10 +89,6 @@ func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) { name: "real call context", body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"function_call","call_id":"call-1"}]}`), }, - { - name: "heartbeat output", - body: codexAutomationBootstrapBody(t, `wiki`, ""), - }, { name: "mismatched memory id", body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "/wiki/memory.md", "/other/memory.md", 1), ""), @@ -117,6 +127,31 @@ func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) { } } +func TestNormalizeCodexAutomationBootstrapRejectsUnsafeHeartbeatShapes(t *testing.T) { + tests := []struct { + name string + output string + }{ + {name: "arbitrary tool output", output: `wiki`}, + {name: "root attribute", output: `wiki`}, + {name: "namespaced root", output: `wiki`}, + {name: "extra child", output: `wikiok`}, + {name: "nested id content", output: `wiki`}, + {name: "padded id", output: ` wiki `}, + {name: "unsafe id", output: `../wiki`}, + {name: "comment", output: `wiki`}, + {name: "trailing content", output: `wikiextra`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := codexAutomationBootstrapBody(t, tt.output, "") + got, changed := normalizeCodexAutomationBootstrap(body) + require.False(t, changed) + require.Equal(t, body, got) + }) + } +} + func TestNormalizeCodexAutomationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) { output := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt) body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, output) + `},{"type":"message","role":"user","content":"after"}]}`) diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index e74b11b9b6..147f4460a8 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -116,6 +116,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { // 解析渠道级模型映射 channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) + forwardModel := openAIChannelForwardModel(channelMapping, reqModel) if h.errorPassthroughService != nil { service.BindErrorPassthroughService(c, h.errorPassthroughService) @@ -170,7 +171,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { apiKey.GroupID, "", sessionHash, - reqModel, + forwardModel, failedAccountIDs, service.OpenAIUpstreamTransportAny, service.OpenAIEndpointCapabilityChatCompletions, diff --git a/backend/internal/handler/openai_client_disconnect_test.go b/backend/internal/handler/openai_client_disconnect_test.go new file mode 100644 index 0000000000..71ff3d14f7 --- /dev/null +++ b/backend/internal/handler/openai_client_disconnect_test.go @@ -0,0 +1,25 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestOpenAIEnsureForwardErrorResponse_SkipsCanceledClient(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + ctx, cancel := context.WithCancel(context.Background()) + c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil).WithContext(ctx) + cancel() + + h := &OpenAIGatewayHandler{} + require.False(t, h.ensureForwardErrorResponse(c, true)) + require.Equal(t, statusClientClosedRequest, c.Writer.Status()) + require.Empty(t, recorder.Body.String()) +} diff --git a/backend/internal/handler/openai_codex_models_handler.go b/backend/internal/handler/openai_codex_models_handler.go index 3a38ce5154..777eeebdb1 100644 --- a/backend/internal/handler/openai_codex_models_handler.go +++ b/backend/internal/handler/openai_codex_models_handler.go @@ -1,6 +1,7 @@ package handler import ( + "errors" "net/http" "github.com/gin-gonic/gin" @@ -15,7 +16,8 @@ import ( // Codex CLI and the Codex desktop app refresh their model picker from // GET {base_url}/models?client_version=... (custom provider mode) or // GET /backend-api/codex/models (chatgpt_base_url mode). Both routes land -// here. Groups with explicit account model mappings are generated locally; +// here. Pinned discovery takes precedence over local account model mappings; +// when disabled, groups with explicit mappings are generated locally; // otherwise ChatGPT manifests are proxied verbatim and custom API key manifests // receive provider-compatibility normalization plus short-lived caching. func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { @@ -33,21 +35,60 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { } ifNoneMatch := c.GetHeader("If-None-Match") - configuredManifest, configured, err := h.gatewayService.BuildGroupConfiguredCodexModelsManifest( - c.Request.Context(), - apiKey.Group, - ifNoneMatch, - ) - if err != nil { - if c.Request.Context().Err() != nil { + // 固定账号分支:开启后只用选定账号拉取 manifest,不经过调度器; + // 全部不可用/全部失败时按 FallbackToScheduler 决定回退调度器或返回错误。 + if apiKey.Group.Platform == service.PlatformOpenAI && + apiKey.Group.CodexModelsManifestConfig.Enabled { + pinnedManifest, pinnedAccount, pinnedErr := h.gatewayService.FetchPinnedCodexModelsManifest( + c.Request.Context(), + apiKey.Group, + c.Query("client_version"), + ) + if pinnedErr != nil { + if c.Request.Context().Err() != nil { + return + } + if !apiKey.Group.CodexModelsManifestConfig.FallbackToScheduler { + if errors.Is(pinnedErr, service.ErrNoPinnedCodexModelsAccounts) { + h.errorResponse(c, http.StatusServiceUnavailable, "upstream_error", "No available pinned OpenAI accounts") + return + } + h.errorResponse(c, infraerrors.Code(pinnedErr), "upstream_error", infraerrors.Message(pinnedErr)) + return + } + // 回退开启:跌入下方调度器循环。 + } else { + // 让 ops 错误日志携带实际拉取成功的首个固定账号。 + setOpsSelectedAccount(c, pinnedAccount.ID, pinnedAccount.Platform) + if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, pinnedManifest, ifNoneMatch); err != nil { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest") + return + } + if c.Request.Context().Err() != nil { + return + } + writeOpenAIModelsResponse(c, pinnedManifest) return } - h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest") - return } - if configured { - writeCodexModelsManifestResponse(c, configuredManifest) - return + + if !apiKey.Group.CodexModelsManifestConfig.Enabled { + configuredManifest, configured, err := h.gatewayService.BuildGroupConfiguredCodexModelsManifest( + c.Request.Context(), + apiKey.Group, + ifNoneMatch, + ) + if err != nil { + if c.Request.Context().Err() != nil { + return + } + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest") + return + } + if configured { + writeOpenAIModelsResponse(c, configuredManifest) + return + } } maxAccountSwitches := h.maxAccountSwitches @@ -94,6 +135,10 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to complete Codex models manifest") return } + if err := service.ApplyPinnedCodexModelsMapping(manifest, account, apiKey.Group); err != nil { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to apply model mappings") + return + } if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, manifest, ifNoneMatch); err != nil { h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest") return @@ -102,19 +147,7 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { return } - writeCodexModelsManifestResponse(c, manifest) + writeOpenAIModelsResponse(c, manifest) return } } - -func writeCodexModelsManifestResponse(c *gin.Context, manifest *service.CodexModelsManifest) { - if manifest.ETag != "" { - c.Header("ETag", manifest.ETag) - } - if manifest.NotModified { - c.Status(http.StatusNotModified) - c.Writer.WriteHeaderNow() - return - } - c.Data(http.StatusOK, "application/json", manifest.Body) -} diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index aed2ba34b6..ea31545631 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -9,9 +9,11 @@ import ( "net" "net/http" "net/http/httptest" + "sort" "strings" "sync" "testing" + "time" "github.com/Wei-Shaw/sub2api/internal/config" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" @@ -161,7 +163,7 @@ func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) { group := &service.Group{ ID: groupID, Platform: service.PlatformOpenAI, - ModelsListConfig: service.GroupModelsListConfig{ + ModelAllowlist: service.GroupModelAllowlist{ Enabled: true, Models: []string{"codex-auto-review", "gpt-5.6"}, }, @@ -179,7 +181,7 @@ func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) { t.Fatal("first response did not include an ETag") } - group.ModelsListConfig.Enabled = false + group.ModelAllowlist.Enabled = false second := performCodexModelsRequestForGroup(t, handler, group, oldETag) if second.Code != http.StatusOK { t.Fatalf("second status: got %d, want %d; body=%s", second.Code, http.StatusOK, second.Body.String()) @@ -230,7 +232,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) { groupA := &service.Group{ ID: 91, Platform: service.PlatformOpenAI, - ModelsListConfig: service.GroupModelsListConfig{ + ModelAllowlist: service.GroupModelAllowlist{ Enabled: true, Models: []string{"model-a"}, }, @@ -238,7 +240,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) { groupB := &service.Group{ ID: 92, Platform: service.PlatformOpenAI, - ModelsListConfig: service.GroupModelsListConfig{ + ModelAllowlist: service.GroupModelAllowlist{ Enabled: true, Models: []string{"model-b"}, }, @@ -290,8 +292,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) { require.True(t, sawGroupB) } -// Scenario: OpenAI 分组内混用 OAuth 和第三方 API Key 时,管理员模型配置优先。 -func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) { +func TestCodexModelsSupplementsConfiguredModelsWithUnmappedAccountDefaults(t *testing.T) { gin.SetMode(gin.TestMode) groupID := int64(44) repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{ @@ -352,12 +353,59 @@ func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) { if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String()) } - if len(envelope.Models) != 1 || envelope.Models[0]["slug"] != "glm-5.3" { - t.Fatalf("models: got %v, want only glm-5.3", envelope.Models) + require.Contains(t, codexHandlerManifestSlugs(t, recorder), "gpt-5.6-sol") + require.Contains(t, codexHandlerManifestSlugs(t, recorder), "glm-5.3") + for _, model := range envelope.Models { + require.Contains(t, model, "supported_reasoning_levels") } - if _, ok := envelope.Models[0]["supported_reasoning_levels"]; !ok { - t.Fatalf("configured model is missing the Codex descriptor contract: %v", envelope.Models[0]) +} + +func TestCodexModelsUnmappedParentAndSparkShadowHonorCustomListAndETag(t *testing.T) { + gin.SetMode(gin.TestMode) + const sparkModel = "gpt-5.3-codex-spark" + parentID := int64(1) + repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{ + { + ID: parentID, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + Status: service.StatusActive, Schedulable: true, + }, + { + ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + Status: service.StatusActive, Schedulable: true, + ParentAccountID: &parentID, QuotaDimension: "spark", + Credentials: map[string]any{"model_mapping": map[string]any{sparkModel: sparkModel}}, + }, + }} + upstream := &codexModelsFailoverHTTPUpstream{firstStatus: http.StatusNotFound} + gatewayService := service.NewOpenAIGatewayService( + repo, + nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil, + upstream, + nil, nil, nil, nil, nil, nil, nil, nil, + ) + handler := &OpenAIGatewayHandler{gatewayService: gatewayService} + group := &service.Group{ID: 45, Platform: service.PlatformOpenAI} + first := performCodexModelsRequestForGroup(t, handler, group, "") + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + slugs := codexHandlerManifestSlugs(t, first) + require.Contains(t, slugs, "gpt-5.6-sol") + require.Contains(t, slugs, sparkModel) + require.NotContains(t, slugs, "gpt-image-2") + require.NotContains(t, slugs, "codex-auto-review") + firstETag := first.Header().Get("ETag") + require.NotEmpty(t, firstETag) + + group.ModelAllowlist = service.GroupModelAllowlist{ + Enabled: true, Models: []string{"gpt-5.6-sol", sparkModel, "unknown-model"}, } + second := performCodexModelsRequestForGroup(t, handler, group, firstETag) + require.Equal(t, http.StatusOK, second.Code, second.Body.String()) + require.ElementsMatch(t, []string{"gpt-5.6-sol", sparkModel}, codexHandlerManifestSlugs(t, second)) + require.NotEqual(t, firstETag, second.Header().Get("ETag")) + third := performCodexModelsRequestForGroup(t, handler, group, second.Header().Get("ETag")) + require.Equal(t, http.StatusNotModified, third.Code) + require.Empty(t, third.Body.Bytes()) + require.Empty(t, upstream.calls()) } func TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) { @@ -640,3 +688,316 @@ func equalInt64Slices(got, want []int64) bool { } return true } + +// --- 固定账号 manifest 模式 --- + +// codexModelsPinnedHTTPUpstream 按账号返回不同 manifest/状态的测试上游。 +type codexModelsPinnedHTTPUpstream struct { + service.HTTPUpstream + mu sync.Mutex + calls []int64 + bodies map[int64]string + statuses map[int64]int +} + +func (u *codexModelsPinnedHTTPUpstream) Do(_ *http.Request, _ string, accountID int64, _ int) (*http.Response, error) { + u.mu.Lock() + u.calls = append(u.calls, accountID) + u.mu.Unlock() + if status, ok := u.statuses[accountID]; ok { + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"upstream boom"}}`)), + }, nil + } + body, ok := u.bodies[accountID] + if !ok { + body = `{"models":[]}` + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + }, nil +} + +func (u *codexModelsPinnedHTTPUpstream) accountIDs() []int64 { + u.mu.Lock() + defer u.mu.Unlock() + ids := append([]int64(nil), u.calls...) + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids +} + +func newPinnedCodexAccount(id int64, status string, schedulable bool, rateLimited bool) service.Account { + account := service.Account{ + ID: id, + Name: fmt.Sprintf("pinned-%d", id), + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: status, + Schedulable: schedulable, + Priority: int(id), + Concurrency: 1, + Credentials: map[string]any{ + "api_key": fmt.Sprintf("sk-pinned-%d", id), + "base_url": fmt.Sprintf("https://pinned-%d.example/v1", id), + }, + } + if rateLimited { + reset := time.Now().Add(10 * time.Minute) + account.RateLimitResetAt = &reset + } + return account +} + +func newPinnedCodexTestHandler(accounts []service.Account, upstream *codexModelsPinnedHTTPUpstream, maxSwitches int) *OpenAIGatewayHandler { + gin.SetMode(gin.TestMode) + cfg := &config.Config{RunMode: config.RunModeSimple} + gatewayService := service.NewOpenAIGatewayService( + codexModelsFailoverAccountRepo{accounts: accounts}, + nil, nil, nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil, + upstream, + nil, nil, nil, nil, nil, nil, nil, nil, + ) + return &OpenAIGatewayHandler{gatewayService: gatewayService, maxAccountSwitches: maxSwitches} +} + +func performPinnedCodexModelsRequest(t *testing.T, handler *OpenAIGatewayHandler, group *service.Group, etag string) *httptest.ResponseRecorder { + return performCodexModelsRequestForGroup(t, handler, group, etag) +} + +func TestCodexModelsPinnedAccountsMergeUnionWithoutScheduler(t *testing.T) { + // 账号 1 优先级最高(调度器会先选它);固定配置只用 2、3, + // 返回并集且不打账号 1,即可证明没有经过调度器。 + accounts := []service.Account{ + newPinnedCodexAccount(1, service.StatusActive, true, false), + newPinnedCodexAccount(2, service.StatusActive, true, false), + newPinnedCodexAccount(3, service.StatusActive, true, false), + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{ + 1: `{"models":[{"slug":"scheduler-only"}]}`, + 2: `{"models":[{"slug":"model-a"}]}`, + 3: `{"models":[{"slug":"model-a"},{"slug":"model-c"}]}`, + }} + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 77, + Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2, 3}, + }, + } + + recorder := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []string{"model-a", "model-c"}, codexHandlerManifestSlugs(t, recorder)) + require.Equal(t, []int64{2, 3}, upstream.accountIDs(), "固定账号模式不得调用调度器或非固定账号") +} + +func TestCodexModelsPinnedAccountsUseRateLimitedAccountAndSkipUnavailable(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(2, service.StatusActive, true, true), // 限流中:仍被使用 + newPinnedCodexAccount(3, service.StatusActive, false, false), // 调度开关关闭:跳过 + newPinnedCodexAccount(4, service.StatusDisabled, true, false), // 停用:跳过 + newPinnedCodexAccount(5, service.StatusActive, true, false), // 正常 + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{ + 2: `{"models":[{"slug":"from-rate-limited"}]}`, + 5: `{"models":[{"slug":"model-five"}]}`, + }} + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 78, + Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2, 3, 4, 5, 99}, // 99 不在分组:跳过 + }, + } + + recorder := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []string{"from-rate-limited", "model-five"}, codexHandlerManifestSlugs(t, recorder)) + require.Equal(t, []int64{2, 5}, upstream.accountIDs()) +} + +func TestCodexModelsPinnedAccountsPartialFailureStillSucceeds(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(2, service.StatusActive, true, false), + newPinnedCodexAccount(3, service.StatusActive, true, false), + } + upstream := &codexModelsPinnedHTTPUpstream{ + bodies: map[int64]string{2: `{"models":[{"slug":"model-a"}]}`}, + statuses: map[int64]int{3: http.StatusServiceUnavailable}, + } + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 79, + Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2, 3}, + }, + } + + recorder := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []string{"model-a"}, codexHandlerManifestSlugs(t, recorder)) +} + +func TestCodexModelsPinnedAccountsAllUnavailableReturns503ByDefault(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(2, service.StatusActive, false, false), + } + upstream := &codexModelsPinnedHTTPUpstream{} + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 80, + Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2}, + }, + } + + recorder := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String()) + require.Empty(t, upstream.accountIDs()) +} + +func TestCodexModelsPinnedAccountsAllFailedReturnsUpstreamError(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(2, service.StatusActive, true, false), + newPinnedCodexAccount(3, service.StatusActive, true, false), + } + upstream := &codexModelsPinnedHTTPUpstream{statuses: map[int64]int{ + 2: http.StatusServiceUnavailable, + 3: http.StatusGatewayTimeout, + }} + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 81, + Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2, 3}, + }, + } + + recorder := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusBadGateway, recorder.Code, recorder.Body.String()) + require.Contains(t, recorder.Body.String(), "upstream error 504", "全部失败时返回最后一个上游错误") +} + +func TestCodexModelsPinnedAccountsFallbackToScheduler(t *testing.T) { + // 固定账号 2 不可用且回退开启:跌入调度器,选中优先级最高的账号 1。 + accounts := []service.Account{ + newPinnedCodexAccount(1, service.StatusActive, true, false), + newPinnedCodexAccount(2, service.StatusActive, false, false), + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{ + 1: `{"models":[{"slug":"from-scheduler"}]}`, + }} + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 82, + Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2}, + FallbackToScheduler: true, + }, + } + + recorder := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []string{"from-scheduler"}, codexHandlerManifestSlugs(t, recorder)) + require.Equal(t, []int64{1}, upstream.accountIDs()) +} + +func TestCodexModelsPinnedAccountsFallbackToSchedulerOnAllFailed(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(1, service.StatusActive, true, false), + newPinnedCodexAccount(2, service.StatusActive, true, false), + } + upstream := &codexModelsPinnedHTTPUpstream{ + bodies: map[int64]string{1: `{"models":[{"slug":"from-scheduler"}]}`}, + statuses: map[int64]int{2: http.StatusServiceUnavailable}, + } + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 83, + Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2}, + FallbackToScheduler: true, + }, + } + + recorder := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []string{"from-scheduler"}, codexHandlerManifestSlugs(t, recorder)) +} + +func TestCodexModelsPinnedAccountsStillApplyCustomModelsListFilter(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(2, service.StatusActive, true, false), + newPinnedCodexAccount(3, service.StatusActive, true, false), + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{ + 2: `{"models":[{"slug":"model-a"}]}`, + 3: `{"models":[{"slug":"model-b"}]}`, + }} + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 84, + Platform: service.PlatformOpenAI, + ModelAllowlist: service.GroupModelAllowlist{ + Enabled: true, + Models: []string{"model-b"}, + }, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2, 3}, + }, + } + + recorder := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []string{"model-b"}, codexHandlerManifestSlugs(t, recorder), "分组自定义模型列表过滤仍生效") +} + +func TestCodexModelsPinnedAccountsETagMatchReturns304(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(2, service.StatusActive, true, false), + newPinnedCodexAccount(3, service.StatusActive, true, false), + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{ + 2: `{"models":[{"slug":"model-a"}]}`, + 3: `{"models":[{"slug":"model-c"}]}`, + }} + handler := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ + ID: 85, + Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{2, 3}, + }, + } + + first := performPinnedCodexModelsRequest(t, handler, group, "") + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + etag := first.Header().Get("ETag") + require.NotEmpty(t, etag) + + second := performPinnedCodexModelsRequest(t, handler, group, etag) + require.Equal(t, http.StatusNotModified, second.Code, second.Body.String()) + require.Empty(t, second.Body.Bytes()) +} diff --git a/backend/internal/handler/openai_delegation_bootstrap_test.go b/backend/internal/handler/openai_delegation_bootstrap_test.go index 9e30752aa6..38854c0781 100644 --- a/backend/internal/handler/openai_delegation_bootstrap_test.go +++ b/backend/internal/handler/openai_delegation_bootstrap_test.go @@ -43,11 +43,7 @@ func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) { }{ {name: "ordinary missing call id", item: `{"type":"function_call_output","name":"other","namespace":"codex_app","output":"` + delegationEnvelope + `"}`}, {name: "valid call id", item: `{"type":"function_call_output","call_id":"call-1","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`}, - {name: "previous response", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`, extra: `,"previous_response_id":"resp-1"`}, - {name: "real call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"}`}, {name: "ambiguous call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call"}`}, - {name: "item reference context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"item_reference","id":"call-1"}`}, - {name: "built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call","call_id":"call-1"}`}, {name: "ambiguous built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call"}`}, {name: "mixed missing call id output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call_output","output":"done"}`}, {name: "mixed tool search output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"tool_search_output","output":"done"}`}, @@ -67,6 +63,18 @@ func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) { } } +func TestNormalizeCodexDelegationBootstrapWithHistoricalContext(t *testing.T) { + body := []byte(`{"model":"gpt-5","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"},{"type":"function_call_output","call_id":"call-1","output":"done"},{"type":"item_reference","id":"item-1"},{"type":"computer_call","call_id":"call-2"}]}`) + + got, changed := normalizeCodexDelegationBootstrap(body) + require.True(t, changed) + require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String()) + require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String()) + require.Equal(t, "resp-1", gjson.GetBytes(got, "previous_response_id").String()) + require.Equal(t, "call-1", gjson.GetBytes(got, "input.2.call_id").String()) + require.Equal(t, "item-1", gjson.GetBytes(got, "input.3.id").String()) +} + func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) { tests := []struct { name string @@ -75,7 +83,7 @@ func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) { }{ {name: "absent", changed: true}, {name: "blank string", value: `,"previous_response_id":" "`, changed: true}, - {name: "non-empty string", value: `,"previous_response_id":"resp-1"`}, + {name: "non-empty string", value: `,"previous_response_id":"resp-1"`, changed: true}, {name: "null", value: `,"previous_response_id":null`}, {name: "number", value: `,"previous_response_id":42`}, {name: "boolean", value: `,"previous_response_id":false`}, diff --git a/backend/internal/handler/openai_embeddings.go b/backend/internal/handler/openai_embeddings.go index 913131114e..8dda9faf3f 100644 --- a/backend/internal/handler/openai_embeddings.go +++ b/backend/internal/handler/openai_embeddings.go @@ -85,6 +85,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { } channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) + forwardModel := openAIChannelForwardModel(channelMapping, reqModel) subscription, _ := middleware2.GetSubscriptionFromContext(c) service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds()) @@ -127,7 +128,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { apiKey.GroupID, "", "", - reqModel, + forwardModel, failedAccountIDs, service.OpenAIUpstreamTransportHTTPSSE, service.OpenAIEndpointCapabilityEmbeddings, diff --git a/backend/internal/handler/openai_gateway_cn_dispatch_test.go b/backend/internal/handler/openai_gateway_cn_dispatch_test.go index eefb0948cb..463feb276c 100644 --- a/backend/internal/handler/openai_gateway_cn_dispatch_test.go +++ b/backend/internal/handler/openai_gateway_cn_dispatch_test.go @@ -18,7 +18,7 @@ import ( func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) { require.True(t, allowOpenAICompatibleMessagesDispatch(nil, nil), "无 key 保持放行") - for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformGrok} { + for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformGrok} { apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}} require.True(t, allowOpenAICompatibleMessagesDispatch(nil, apiKey), "%s 分组必须豁免 allow_messages_dispatch 闸门", platform) diff --git a/backend/internal/handler/openai_gateway_credential_failover_test.go b/backend/internal/handler/openai_gateway_credential_failover_test.go index d94eae7fe9..4fd5df01d0 100644 --- a/backend/internal/handler/openai_gateway_credential_failover_test.go +++ b/backend/internal/handler/openai_gateway_credential_failover_test.go @@ -128,6 +128,44 @@ func TestOpenAICapacityFailoverExhaustionPreservesMessageAsServerError(t *testin }) } +func TestOpenAIManagedSingleAccountModelNotFoundExhaustionPreservesStructured400(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + body := []byte(`{"error":{"type":"invalid_request_error","code":"model_not_found","param":"model","message":"The requested model is unavailable on this account"}}`) + + (&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{ + StatusCode: http.StatusBadRequest, + ResponseBody: body, + }, false) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String()) + require.Equal(t, "model_not_found", gjson.Get(recorder.Body.String(), "error.code").String()) + require.Equal(t, "model", gjson.Get(recorder.Body.String(), "error.param").String()) + require.Equal(t, "The requested model is unavailable on this account", gjson.Get(recorder.Body.String(), "error.message").String()) +} + +func TestOpenAIManagedModelNotFoundExhaustionSanitizesMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + body := []byte(`{"error":{"code":"model_not_found","message":"Model not found; inspect https://upstream.example/debug?access_token=super-secret-value&model=x"}}`) + + (&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{ + StatusCode: http.StatusBadRequest, + ResponseBody: body, + }, false) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.NotContains(t, recorder.Body.String(), "super-secret-value") + require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "access_token=***") + recorded, ok := c.Get(service.OpsUpstreamErrorMessageKey) + require.True(t, ok) + require.NotContains(t, recorded, "super-secret-value") + require.Contains(t, recorded, "access_token=***") +} + func TestResponsesFailoverExhaustedAfterForwardedTerminalMarksOpsWithoutDuplicateFrame(t *testing.T) { gin.SetMode(gin.TestMode) recorder := httptest.NewRecorder() diff --git a/backend/internal/handler/openai_gateway_cyber_test.go b/backend/internal/handler/openai_gateway_cyber_test.go index 1b9fc53a9d..4be214cdc9 100644 --- a/backend/internal/handler/openai_gateway_cyber_test.go +++ b/backend/internal/handler/openai_gateway_cyber_test.go @@ -1,6 +1,7 @@ package handler import ( + "net/http" "net/http/httptest" "strings" "testing" @@ -102,6 +103,44 @@ func TestClearCyberPolicyTurnState(t *testing.T) { require.Equal(t, "turn2", service.GetOpsCyberPolicy(c).Message) } +func TestAdvanceOpenAIWSCyberBlockStateDefersBlockAcrossFailover(t *testing.T) { + failoverErr := &service.UpstreamFailoverError{StatusCode: http.StatusTooManyRequests} + + blocked, pending := advanceOpenAIWSCyberBlockState(false, false, true, failoverErr) + require.False(t, blocked, "the replacement account must receive the current turn") + require.True(t, pending, "the cyber hit must still block later turns") + + blocked, pending = advanceOpenAIWSCyberBlockState(blocked, pending, false, failoverErr) + require.False(t, blocked, "additional failover attempts must remain eligible") + require.True(t, pending) + + blocked, pending = advanceOpenAIWSCyberBlockState(blocked, pending, false, nil) + require.True(t, blocked, "the next client turn must be blocked after failover finishes") + require.False(t, pending) +} + +func TestClearCyberPolicyAttemptStatePreservesRecordedGuardDuringFailover(t *testing.T) { + c := newTestGinContext() + h := &OpenAIGatewayHandler{} + + service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "account-a", UpstreamStatus: http.StatusOK}) + h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "") + require.True(t, c.GetBool(cyberPolicyRecordedKey)) + + clearCyberPolicyAttemptState(c, false) + require.Nil(t, service.GetOpsCyberPolicy(c)) + require.True(t, c.GetBool(cyberPolicyRecordedKey), "the same logical turn must not record again after failover") + + service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "account-b", UpstreamStatus: http.StatusOK}) + h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "") + require.Equal(t, "account-b", service.GetOpsCyberPolicy(c).Message) + require.True(t, c.GetBool(cyberPolicyRecordedKey)) + + clearCyberPolicyAttemptState(c, true) + require.Nil(t, service.GetOpsCyberPolicy(c)) + require.False(t, c.GetBool(cyberPolicyRecordedKey), "a completed logical turn must reset the guard") +} + // TestBuildCyberSessionBlockedOpsEntry verifies the locally-rejected request is // auditable: 403 / phase=request / type=cyber_policy_session_blocked — distinct // from upstream cyber_policy hits, and it must NOT touch moderation/violation. diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index ce67e07e85..15a7cca8a2 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -20,6 +20,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/pkg/ip" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/requestmodel" "github.com/Wei-Shaw/sub2api/internal/securityaudit" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -53,6 +54,21 @@ type openAIWSTurnChannelMappingSnapshot struct { mapping service.ChannelMappingResult } +func advanceOpenAIWSCyberBlockState(blocked, pending, marked bool, turnErr error) (bool, bool) { + var failoverErr *service.UpstreamFailoverError + isFailover := errors.As(turnErr, &failoverErr) + if marked { + if isFailover { + return false, true + } + return true, false + } + if pending && !isFailover { + return true, false + } + return blocked, pending +} + var errOpenAIWSUnsupportedModelSwitch = errors.New("selected account does not support websocket model switch") func newOpenAIWSUnsupportedModelSwitchError(model string) error { @@ -140,6 +156,18 @@ func openAIWSTurnBillingModel(result *service.OpenAIForwardResult, mapping servi return billingModel } +// openAIChannelForwardModel returns the model used for upstream capability +// routing. The client-requested model remains separate for logs, billing, and +// responses. +func openAIChannelForwardModel(mapping service.ChannelMappingResult, requestedModel string) string { + if mapping.Mapped { + if mappedModel := strings.TrimSpace(mapping.MappedModel); mappedModel != "" { + return mappedModel + } + } + return requestedModel +} + type grokMediaEligibilityProber interface { ProbeMediaEligibility(ctx context.Context, accountID int64) (bool, string, error) } @@ -294,7 +322,7 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, model string) bool { return compositeTargetPlatformAllowed(c, apiKey, model, service.PlatformOpenAI, service.PlatformGrok, - service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek) + service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax) } // isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket @@ -533,10 +561,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) forwardBody := openAIModelMappedBody(body, channelMapping.Mapped, channelMapping.MappedModel, h.gatewayService.ReplaceModelInBody) seedOpenAIForwardImageIntentHint(c, channelMapping.Mapped, imageIntent) - forwardModel := reqModel - if channelMapping.Mapped { - forwardModel = channelMapping.MappedModel - } + forwardModel := openAIChannelForwardModel(channelMapping, reqModel) c.Request = c.Request.WithContext(service.WithOpenAIForwardModel( c.Request.Context(), forwardModel, @@ -629,7 +654,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { apiKey.GroupID, previousResponseID, sessionHash, - reqModel, + forwardModel, failedAccountIDs, service.OpenAIUpstreamTransportAny, requiredCapability, @@ -810,6 +835,22 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { }) } if err != nil { + if result != nil && result.ClientDisconnect { + reqLog.Info("openai.client_disconnected", + zap.Int64("account_id", account.ID), + zap.Error(err), + ) + submitResponsesUsage(result) + return + } + if failoverClientGone(c) { + reqLog.Info("openai.client_disconnected", + zap.Int64("account_id", account.ID), + zap.Error(err), + ) + submitResponsesUsage(result) + return + } if result != nil && result.ImageCount > 0 { reqLog.Warn("openai.forward_partial_error_with_image_result", zap.Int64("account_id", account.ID), @@ -1600,14 +1641,17 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context, } func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) { - return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate) + // 已有任务通过 send_message_to_thread 唤醒时会携带 previous_response_id; + // 完整历史回放还会带有已配对的调用项。delegation 仍是客户端注入的用户输入, + // 不属于这些历史调用的结果,因此允许它与可明确配对的历史上下文共存。 + return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate, true) } func normalizeCodexAutomationBootstrap(body []byte) ([]byte, bool) { - return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate) + return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate, false) } -func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool) ([]byte, bool) { +func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool, allowHistoricalContext bool) ([]byte, bool) { if !hasUniqueJSONMembers(body) { return body, false } @@ -1619,7 +1663,7 @@ func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string] } if previousResponseID, exists := request["previous_response_id"]; exists { value, ok := previousResponseID.(string) - if !ok || strings.TrimSpace(value) != "" { + if !ok || (!allowHistoricalContext && strings.TrimSpace(value) != "") { return body, false } } @@ -1628,27 +1672,35 @@ func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string] return body, false } - // Any call/reference anchor makes a call-less output ambiguous. Responses - // built-ins follow the *_call / *_call_output naming convention, so classify - // by the wire type shape instead of maintaining an incomplete allowlist. + // Responses built-ins follow the *_call / *_call_output naming convention, + // so classify by the wire type shape instead of maintaining an incomplete + // allowlist. Delegation may coexist with historical anchors only when their + // IDs make them unambiguous; automation retains the bootstrap-only boundary. for _, raw := range input { item, ok := raw.(map[string]any) if !ok { continue } typ := stringField(item, "type") - if typ == "item_reference" || strings.HasSuffix(typ, "_call") { - return body, false - } - if isResponsesCallOutputType(typ) { + if isCandidate(item) { callIDValue, exists := item["call_id"] callID, isString := callIDValue.(string) if exists && (!isString || strings.TrimSpace(callID) != "") { return body, false } - if !isCandidate(item) { - return body, false + continue + } + if typ == "item_reference" { + if allowHistoricalContext && strings.TrimSpace(stringField(item, "id")) != "" { + continue } + return body, false + } + if strings.HasSuffix(typ, "_call") || isResponsesCallOutputType(typ) { + if allowHistoricalContext && strings.TrimSpace(stringField(item, "call_id")) != "" { + continue + } + return body, false } } @@ -1756,7 +1808,7 @@ func isCodexAutomationCandidate(item map[string]any) bool { return false } output, ok := item["output"].(string) - return ok && validCodexAutomationBootstrap(output) + return ok && (validCodexAutomationBootstrap(output) || validCodexAutomationHeartbeat(output)) } func stringField(item map[string]any, key string) string { @@ -1834,6 +1886,56 @@ func validCodexAutomationLastRun(value string) bool { return err == nil && runAt.UnixMilli() == epochMillis } +func validCodexAutomationHeartbeat(value string) bool { + decoder := xml.NewDecoder(strings.NewReader(value)) + var rootSeen, automationIDSeen bool + var automationID bytes.Buffer + depth := 0 + for { + token, err := decoder.Token() + if err == io.EOF { + id := automationID.String() + return rootSeen && automationIDSeen && depth == 0 && + strings.TrimSpace(id) == id && validCodexAutomationID(id) + } + if err != nil { + return false + } + switch current := token.(type) { + case xml.StartElement: + depth++ + if current.Name.Space != "" || len(current.Attr) != 0 || depth > 2 { + return false + } + if depth == 1 { + if rootSeen || current.Name.Local != "heartbeat" { + return false + } + rootSeen = true + } else if automationIDSeen || current.Name.Local != "automation_id" { + return false + } + automationIDSeen = depth == 2 + case xml.EndElement: + if current.Name.Space != "" { + return false + } + depth-- + if depth < 0 { + return false + } + case xml.CharData: + if depth == 2 { + _, _ = automationID.Write(current) + } else if len(bytes.TrimSpace(current)) != 0 { + return false + } + case xml.Comment, xml.ProcInst, xml.Directive: + return false + } + } +} + func validCodexDelegationEnvelope(value string) bool { decoder := xml.NewDecoder(strings.NewReader(value)) var rootSeen, sourceSeen, inputSeen bool @@ -1940,10 +2042,10 @@ const ( // 由 BeforeTurn 在每个 turn 开始时冻结,AfterTurn 的用量提交读取它;turn 在 // 连接内串行推进,互斥锁只为跨用量提交 goroutine 的读取安全。 // -// ws_v2 passthrough ingress 没有 BeforeTurn,因此本值会保持零;AfterTurn 必须 -// 以 TurnStarted 已记录的所属 turn 开始时刻为回退,而不是用建连或记录时刻。 -// 这样每个 passthrough turn 都按自己的开始时刻计价,但不改变其仅在建连时执行 -// 准入门、没有 turn 级利润复核的既有行为。 +// 零值语义(重要):首轮准入由握手路径完成,不调用 BeforeTurn,因此首轮保持 +// 零值并回退到 TurnStarted 记录的首轮开始时刻。后续 turn 在 response.create +// 写入上游前调用 BeforeTurn,按当时的利润门复核并冻结定价。绝不能用建连时刻 +// 初始化,否则会把长连接的所有 turn 钉死在建连时的峰谷因子。 type openAIWSTurnPricing struct { mu sync.Mutex at time.Time @@ -2004,7 +2106,7 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot( return h.acquireOpenAIAccountSlot(c, groupID, sessionHash, selection, reqStream, streamStarted, reqLog, nil) } -type openAISlotErrorWriter func(status int, errType, message string) +type openAISlotErrorWriter func(status int, errType, code, message string) // acquireOpenAIAccountSlot centralizes scheduler selection admission. The // optional error writer lets non-Responses endpoints retain their wire format @@ -2020,13 +2122,13 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot( writeError openAISlotErrorWriter, ) (func(), openAISlotAcquireResult) { if writeError == nil { - writeError = func(status int, errType, message string) { - h.handleStreamingAwareError(c, status, errType, message, *streamStarted) + writeError = func(status int, errType, code, message string) { + h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, *streamStarted, false) } } if selection == nil || selection.Account == nil { markOpsRoutingCapacityLimited(c) - writeError(http.StatusServiceUnavailable, "api_error", "No available accounts") + writeError(http.StatusServiceUnavailable, "api_error", "", "No available accounts") return nil, openAISlotAcquireFailed } @@ -2056,7 +2158,7 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot( } if selection.WaitPlan == nil { markOpsRoutingCapacityLimited(c) - writeError(http.StatusServiceUnavailable, "api_error", "No available accounts") + writeError(http.StatusServiceUnavailable, "api_error", "", "No available accounts") return nil, openAISlotAcquireFailed } @@ -2067,8 +2169,8 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot( ) if err != nil { reqLog.Warn("openai.account_slot_quick_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err)) - status, errType, message := concurrencyErrorResponse(err, "account") - writeError(status, errType, message) + status, errType, code, message := concurrencyErrorResponse(err, "account") + writeError(status, errType, code, message) return nil, openAISlotAcquireFailed } if fastAcquired { @@ -2098,7 +2200,7 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot( zap.Int64("account_id", account.ID), zap.Int("max_waiting", selection.WaitPlan.MaxWaiting), ) - writeError(http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later") + writeError(http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later") return nil, openAISlotAcquireFailed } @@ -2121,8 +2223,8 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot( ) if err != nil { reqLog.Warn("openai.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err)) - status, errType, message := concurrencyErrorResponse(err, "account") - writeError(status, errType, message) + status, errType, code, message := concurrencyErrorResponse(err, "account") + writeError(status, errType, code, message) return nil, openAISlotAcquireFailed } @@ -2263,6 +2365,16 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "model is required in first response.create payload") return } + // 分组级模型白名单:首帧校验客户端模型,不通过则关闭连接并标记运维原因。 + // 必须在 ensureCompositeTargetPlatform(合成路由改写)之前执行。 + // 与 HTTP 准入一致:帧内重复 model 键/大小写变体可能被上游按末值绑定, + // 全部候选值逐一校验,任一未命中即拒绝。 + if blocked := blockedModelAllowlistCandidate(apiKey.Group, requestmodel.FromBodyCandidates("", "application/json", firstMessage)); blocked != "" { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration) + middleware2.MarkIngressRejected(c, middleware2.IngressRejectModelNotAllowed) + closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked)) + return + } ensureCompositeTargetPlatform(c, apiKey, reqModel) ctx = c.Request.Context() if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite { @@ -2310,6 +2422,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { return } cyberBlockedThisConn := false + cyberBlockPendingAfterFailover := false var cyberTurnBodiesMu sync.Mutex cyberTurnBodies := map[int][]byte{1: append([]byte(nil), firstMessage...)} setCyberTurnBody := func(turn int, payload []byte) { @@ -2327,10 +2440,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // 解析渠道级模型映射 channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel) - wsForwardModel := reqModel - if channelMappingWS.Mapped && strings.TrimSpace(channelMappingWS.MappedModel) != "" { - wsForwardModel = strings.TrimSpace(channelMappingWS.MappedModel) - } + wsForwardModel := openAIChannelForwardModel(channelMappingWS, reqModel) var currentUserRelease func() var currentAccountRelease func() @@ -2493,7 +2603,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { apiKey.GroupID, previousResponseID, sessionHash, - reqModel, + wsForwardModel, failedAccountIDs, requiredTransport, requiredCapability, @@ -2645,8 +2755,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { // turn-tagged slot preserves the exact mapping used for the in-flight request. var turnChannelMapping atomic.Pointer[openAIWSTurnChannelMappingSnapshot] turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: 1, mapping: channelMappingWS}) - // turn 级定价:BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号; - // passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。 + // turn 级定价:首轮回退到 TurnStarted 的所属 turn 时刻;后续 turn 由 + // BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号。 var turnPricing openAIWSTurnPricing hooks := &service.OpenAIWSIngressHooks{ ClientLifecycleContext: clientLifecycleCtx, @@ -2660,10 +2770,9 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { c.Set(securityAuditWSTurnContextKey, turn) service.BeginOpsStreamTurn(c, turn) setCyberTurnBody(turn, payload) - // Passthrough ingress intentionally skips BeforeTurn, so enforce only - // the connection-level cyber session gate here as well. Native ingress - // visits this hook first and gets the same side-effect-free close error; - // its original BeforeTurn guard remains as defense in depth. + // 连接级 cyber session gate 也在 BeforeRequest 先执行,使 native 与 + // passthrough ingress 都能在 BeforeTurn 及上游写入前无副作用地拒绝。 + // BeforeTurn 中保留同一检查作为防御式兜底。 if cyberBlockedThisConn { return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil) } @@ -2680,6 +2789,17 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { if model == "" { model = reqModel } + // 分组级模型白名单:后续 turn 同样校验客户端模型(省略 model 时 + // 沿用会话实际生效模型,含 session.update 轮换后的模型),不通过 + // 则关闭整条连接,与推理强度 deny 一致。实际生效模型始终参与校验; + // 帧内重复 model 键/大小写变体/嵌套 session.model 额外逐一校验, + // 防止候选集非空时掩盖被轮换掉的禁用模型。 + candidates := append([]string{model}, requestmodel.FromBodyCandidates("", "application/json", payload)...) + if blocked := blockedModelAllowlistCandidate(apiKey.Group, candidates); blocked != "" { + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration) + middleware2.MarkIngressRejected(c, middleware2.IngressRejectModelNotAllowed) + return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked), nil) + } if decision := h.checkSecurityAuditStage(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, model, payload, "subsequent_turn"); decision != nil && !decision.AllowNextStage { writeSecurityAuditWSError(ctx, wsConn, decision) return service.NewOpenAIWSClientCloseError(securityAuditWSCloseStatus(decision), securityAuditWSCloseReason(decision), nil) @@ -2753,10 +2873,12 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { AfterTurn: func(turn int, result *service.OpenAIForwardResult, turnErr error) { turnStart := getTurnStart(turn) cyberBlockBody := takeCyberTurnBody(turn) - // F1: cyber 标记按 turn 生命周期清理——defer 保证任意早返回路径都执行; - // CyberBlocked 必须在 submit 前同步预捕获(task 闭包由 worker 池异步执行, - // 届时 defer 已清除标记)。 - defer clearCyberPolicyTurnState(c) + // 每次 attempt 都清 cyber mark;failover 链结束前保留 recorded guard, + // 避免同一逻辑 turn 换号后重复落风控。CyberBlocked 必须在 submit 前 + // 同步预捕获(task 闭包由 worker 池异步执行,届时 mark 已清除)。 + defer func() { + clearCyberPolicyAttemptState(c, !cyberBlockPendingAfterFailover) + }() releaseTurnSlots() turnRequestedModel := reqModel turnUpstreamModel := "" @@ -2778,10 +2900,14 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { turnUpstreamModel = turnRequestedModel } turnUsageFields := turnMapping.ToUsageFields(turnRequestedModel, turnUpstreamModel) + cyberMarked := service.GetOpsCyberPolicy(c) != nil h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, turnRequestedModel, turnErr != nil, cyberBlockBody, turnUsageFields, requestPayloadHash) - if service.GetOpsCyberPolicy(c) != nil { - cyberBlockedThisConn = true - } + cyberBlockedThisConn, cyberBlockPendingAfterFailover = advanceOpenAIWSCyberBlockState( + cyberBlockedThisConn, + cyberBlockPendingAfterFailover, + cyberMarked, + turnErr, + ) if turnErr != nil { if result == nil || result.ImageCount <= 0 { return @@ -3172,14 +3298,14 @@ func (h *OpenAIGatewayHandler) acquireImageGenerationSlot(c *gin.Context, stream if acquired { return release, true } - h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Image generation concurrency limit exceeded, please retry later", streamStarted) + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayConcurrencyLimitCode, "Image generation concurrency limit exceeded, please retry later", streamStarted, false) return nil, false } // handleConcurrencyError handles concurrency-related acquire errors. func (h *OpenAIGatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) { - status, errType, message := concurrencyErrorResponse(err, slotType) - h.handleStreamingAwareError(c, status, errType, message, streamStarted) + status, errType, code, message := concurrencyErrorResponse(err, slotType) + h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, streamStarted, false) } func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, streamStarted bool) { @@ -3222,6 +3348,12 @@ func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverE } statusCode := failoverErr.StatusCode responseBody := failoverErr.ResponseBody + if statusCode == http.StatusBadRequest && service.IsOpenAICompatibleModelNotFound400(responseBody) && !streamStarted { + upstreamMsg := service.SanitizeUpstreamErrorMessage(service.ExtractUpstreamErrorMessage(responseBody)) + service.SetOpsUpstreamError(c, statusCode, upstreamMsg, "") + service.WriteOpenAIUpstreamClientError(c, statusCode, responseBody, upstreamMsg) + return + } if service.IsOpenAISilentRefusalErrorBody(responseBody) { service.SetOpsUpstreamError(c, statusCode, service.OpenAISilentRefusalClientMessage(), "") h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", service.OpenAISilentRefusalClientMessage(), streamStarted) @@ -3359,7 +3491,7 @@ func (h *OpenAIGatewayHandler) handleStreamingAwareErrorWithCode( // 通用 `event: error` 帧不被识别为终止事件,会导致 // "stream closed before response.completed"。 if inboundIsResponses(c) { - if writeResponsesFailedSSE(c, errType, message) { + if writeResponsesFailedSSE(c, errType, code, message) { return } } @@ -3412,6 +3544,10 @@ func (h *OpenAIGatewayHandler) ensureForwardErrorResponse(c *gin.Context, stream if c == nil || c.Writer == nil { return false } + if c.Request != nil && errors.Is(c.Request.Context().Err(), context.Canceled) { + failoverClientGone(c) + return false + } // 先停 compact 心跳再读 Writer 状态,避免与心跳 goroutine 竞争。 compactKeepaliveCommitted := service.StopOpenAICompactSSEKeepaliveCommitted(c) if compactKeepaliveCommitted { @@ -3526,7 +3662,7 @@ func (h *OpenAIGatewayHandler) errorResponse(c *gin.Context, status int, errType // 提交的 SSE 流交错,必须降级为 response.failed 终止事件(#3887)。 if service.StopOpenAICompactSSEKeepaliveCommitted(c) { service.MarkOpsStreamError(c, errType, message, status) - if writeResponsesFailedSSE(c, errType, message) { + if writeResponsesFailedSSE(c, errType, "", message) { return } } @@ -3581,6 +3717,21 @@ func isOpenAIWSUpgradeRequest(r *http.Request) bool { return strings.Contains(strings.ToLower(strings.TrimSpace(r.Header.Get("Connection"))), "upgrade") } +// blockedModelAllowlistCandidate 对全部候选模型逐一校验分组白名单,返回第一个 +// 未命中的值(全部命中或白名单未开启返回空串)。WS 帧与 HTTP 请求体共用该 +// 规则:重复 model 键/大小写变体可能被上游按末值绑定,任一未命中即拒绝。 +func blockedModelAllowlistCandidate(group *service.Group, candidates []string) string { + if group == nil || !group.ModelAllowlistEnabled() { + return "" + } + for _, candidate := range candidates { + if !group.ModelAllowlist.Allows(candidate) { + return candidate + } + } + return "" +} + func closeOpenAIClientWS(conn *coderws.Conn, status coderws.StatusCode, reason string) { if conn == nil { return @@ -3844,7 +3995,7 @@ func (h *OpenAIGatewayHandler) rejectIfCyberSessionBlocked(c *gin.Context, apiKe // 写 JSON(#3887)。 if service.StopOpenAICompactSSEKeepaliveCommitted(c) { service.MarkOpsStreamError(c, "permission_error", cyberSessionBlockedClientMsg, http.StatusForbidden) - if writeResponsesFailedSSE(c, "permission_error", cyberSessionBlockedClientMsg) { + if writeResponsesFailedSSE(c, "permission_error", "", cyberSessionBlockedClientMsg) { h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, model, key) return true } @@ -4088,15 +4239,20 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey }() } -// clearCyberPolicyTurnState resets the cyber mark and the per-request recorded -// guard. WS-only: called at the END of AfterTurn, after recordCyberPolicyIfMarked -// and RecordUsage (which reads CyberBlocked) have both consumed the mark. +// clearCyberPolicyTurnState resets the cyber mark and recorded guard after a +// logical WS turn has finished. func clearCyberPolicyTurnState(c *gin.Context) { + clearCyberPolicyAttemptState(c, true) +} + +func clearCyberPolicyAttemptState(c *gin.Context, resetRecorded bool) { if c == nil { return } service.ClearOpsCyberPolicy(c) - c.Set(cyberPolicyRecordedKey, false) + if resetRecorded { + c.Set(cyberPolicyRecordedKey, false) + } } func summarizeWSCloseErrorForLog(err error) (string, string) { diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 23acd158b3..29b17d1655 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -1530,7 +1530,7 @@ func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) { "each turn must be billed with its own channel-mapped model") } -func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKeysRemainsValid(t *testing.T) { +func TestOpenAIResponsesWebSocket_ChannelMappedTargetSelectsAccountWithoutRequestedAlias(t *testing.T) { got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ firstPayload: `{"type":"response.create","model":"public-alias","stream":false}`, secondPayload: `{"type":"response.create","stream":false}`, @@ -1538,7 +1538,7 @@ func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKey "public-alias": "gpt-5.6-sol", }, accountModelMapping: map[string]any{ - "public-alias": "gpt-5.6-terra", + "gpt-5.6-sol": "gpt-5.6-sol", }, }) @@ -1710,6 +1710,40 @@ func TestOpenAIAccountScheduleModelUsesActualOrSharedResolver(t *testing.T) { require.Equal(t, "attempt-actual", openAIAccountScheduleModel(c, account, "public", true, nil)) } +func TestOpenAIChannelForwardModelForScheduler(t *testing.T) { + tests := []struct { + name string + mapping service.ChannelMappingResult + requested string + want string + }{ + { + name: "mapped model is used for capability filtering", + mapping: service.ChannelMappingResult{Mapped: true, MappedModel: " gpt-forward "}, + requested: "client-alias", + want: "gpt-forward", + }, + { + name: "unmapped request preserves client model", + mapping: service.ChannelMappingResult{MappedModel: "client-model"}, + requested: "client-model", + want: "client-model", + }, + { + name: "empty mapped model safely preserves client model", + mapping: service.ChannelMappingResult{Mapped: true, MappedModel: " "}, + requested: "client-model", + want: "client-model", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, openAIChannelForwardModel(tt.mapping, tt.requested)) + }) + } +} + func TestShouldReportOpenAIWSProxyAccountFailure(t *testing.T) { t.Run("unsupported client model switch does not penalize account", func(t *testing.T) { err := fmt.Errorf("wrapped ingress turn: %w", newOpenAIWSUnsupportedModelSwitchError("gpt-unsupported")) @@ -1882,7 +1916,10 @@ func newOpenAIWSHandlerTestServer(t *testing.T, h *OpenAIGatewayHandler, subject } type openAIResponsesWSUsageLogCase struct { - firstPayload string + firstPayload string + // midPayload 在首个 turn 完成后发送(如 session.update),上游桩会为它 + // 回一个 response.completed,客户端按普通事件读取。 + midPayload string secondPayload string userAgent *string ingressMode string @@ -1890,6 +1927,12 @@ type openAIResponsesWSUsageLogCase struct { billingModelSource string accountModelMapping map[string]any afterFirstUpstreamRequest func(channelSvc *service.ChannelService) error + // group 覆盖 apiKey.Group(分组级模型白名单测试用);nil 保持原有无分组行为。 + group *service.Group + // firstFrameCloseExpected:首帧即被拒(连接被 1008 关闭),不期待任何响应帧。 + firstFrameCloseExpected bool + // secondTurnCloseExpected:第二个 turn 被拒(连接被 1008 关闭)。 + secondTurnCloseExpected bool } type openAIResponsesWSUsageLogResult struct { @@ -2809,8 +2852,11 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU gin.SetMode(gin.TestMode) turnCount := 1 + if strings.TrimSpace(tc.midPayload) != "" { + turnCount++ + } if strings.TrimSpace(tc.secondPayload) != "" { - turnCount = 2 + turnCount++ } upstreamPayloadCh := make(chan []byte, turnCount) upstreamErrCh := make(chan error, 1) @@ -2914,7 +2960,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU BillingModelSource: tc.billingModelSource, }}, groupPlatforms: map[int64]string{groupID: service.PlatformOpenAI}, - }, nil, nil, nil) + }, nil, nil, nil, nil) } billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil) @@ -2963,6 +3009,9 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU GroupID: &groupID, User: &service.User{ID: 1701, Status: service.StatusActive}, } + if tc.group != nil { + apiKey.Group = tc.group + } router := gin.New() router.Use(func(c *gin.Context) { c.Set(string(middleware.ContextKeyAPIKey), apiKey) @@ -2994,6 +3043,19 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU cancelWrite() require.NoError(t, err) + if tc.firstFrameCloseExpected { + readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) + _, _, readErr := clientConn.Read(readCtx) + cancelRead() + require.Error(t, readErr, "first frame should have been rejected with a close") + var closeErr coderws.CloseError + require.ErrorAs(t, readErr, &closeErr) + require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code) + require.Contains(t, closeErr.Reason, "not available for this group") + _ = clientConn.CloseNow() + return openAIResponsesWSUsageLogResult{} + } + clientEvents := make([][]byte, 0, turnCount) readCompleted := func() { readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) @@ -3004,11 +3066,30 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU clientEvents = append(clientEvents, append([]byte(nil), event...)) } readCompleted() - if turnCount == 2 { + if tc.midPayload != "" { + writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(tc.midPayload)) + cancelWrite() + require.NoError(t, err) + readCompleted() + } + if strings.TrimSpace(tc.secondPayload) != "" && (turnCount >= 2) { writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second) err = clientConn.Write(writeCtx, coderws.MessageText, []byte(tc.secondPayload)) cancelWrite() require.NoError(t, err) + if tc.secondTurnCloseExpected { + readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) + _, _, readErr := clientConn.Read(readCtx) + cancelRead() + require.Error(t, readErr, "second turn should have been rejected with a close") + var closeErr coderws.CloseError + require.ErrorAs(t, readErr, &closeErr) + require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code) + require.Contains(t, closeErr.Reason, "not available for this group") + _ = clientConn.CloseNow() + return openAIResponsesWSUsageLogResult{} + } readCompleted() } _ = clientConn.Close(coderws.StatusNormalClosure, "done") diff --git a/backend/internal/handler/openai_gateway_ws_model_allowlist_test.go b/backend/internal/handler/openai_gateway_ws_model_allowlist_test.go new file mode 100644 index 0000000000..bf70d4ce9d --- /dev/null +++ b/backend/internal/handler/openai_gateway_ws_model_allowlist_test.go @@ -0,0 +1,179 @@ +package handler + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +func wsAllowlistGroup(enabled bool, models ...string) *service.Group { + return &service.Group{ + ID: 4201, + Platform: service.PlatformOpenAI, + ModelAllowlist: service.GroupModelAllowlist{ + Enabled: enabled, + Models: models, + }, + } +} + +// 首帧拒绝:模型不在白名单时连接被 1008 关闭(passthrough relay 模式)。 +func TestOpenAIResponsesWebSocket_FirstFrameModelNotAllowedCloses_Passthrough(t *testing.T) { + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`, + group: wsAllowlistGroup(true, "gpt-5.4"), + ingressMode: service.OpenAIWSIngressModePassthrough, + firstFrameCloseExpected: true, + }) +} + +// 首帧拒绝:原生 ingress 模式。 +func TestOpenAIResponsesWebSocket_FirstFrameModelNotAllowedCloses_NativeIngress(t *testing.T) { + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`, + group: wsAllowlistGroup(true, "gpt-5.4"), + ingressMode: service.OpenAIWSIngressModeDedicated, + firstFrameCloseExpected: true, + }) +} + +// 首帧命中白名单:连接正常建立并收到 response.completed(passthrough)。 +func TestOpenAIResponsesWebSocket_FirstFrameAllowlistedModelProceeds(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`, + group: wsAllowlistGroup(true, "gpt-5.4"), + }) + if len(got.clientEvents) != 1 { + t.Fatalf("expected one completed event, got %d", len(got.clientEvents)) + } +} + +// 后续 turn 切换到白名单外的模型:连接被 1008 关闭(passthrough relay 模式)。 +func TestOpenAIResponsesWebSocket_SubsequentTurnModelNotAllowedCloses_Passthrough(t *testing.T) { + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`, + secondPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`, + group: wsAllowlistGroup(true, "gpt-5.4"), + ingressMode: service.OpenAIWSIngressModePassthrough, + secondTurnCloseExpected: true, + }) +} + +// 后续 turn 切换到白名单外的模型:连接被 1008 关闭(原生 ingress 模式)。 +func TestOpenAIResponsesWebSocket_SubsequentTurnModelNotAllowedCloses_NativeIngress(t *testing.T) { + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`, + secondPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`, + group: wsAllowlistGroup(true, "gpt-5.4"), + ingressMode: service.OpenAIWSIngressModeDedicated, + secondTurnCloseExpected: true, + }) +} + +// 后续 turn 省略 model:沿用会话初始模型,白名单校验通过。 +func TestOpenAIResponsesWebSocket_SubsequentTurnOmittedModelUsesSessionModel(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`, + secondPayload: `{"type":"response.create","stream":false}`, + group: wsAllowlistGroup(true, "gpt-5.4"), + }) + if len(got.clientEvents) != 2 { + t.Fatalf("expected two completed events, got %d", len(got.clientEvents)) + } + if len(got.upstreamPayloads) != 2 { + t.Fatalf("expected two upstream frames, got %d", len(got.upstreamPayloads)) + } +} + +// 白名单关闭:不受任何影响。 +func TestOpenAIResponsesWebSocket_DisabledAllowlistDoesNotInterfere(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`, + secondPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`, + group: wsAllowlistGroup(false, "gpt-5.4"), + }) + if len(got.clientEvents) != 2 { + t.Fatalf("expected two completed events, got %d", len(got.clientEvents)) + } +} + +// 首帧含重复 model 键(首个在白名单、末个不在):passthrough 会原样转发两个字段, +// 上游按末值绑定时得到禁用模型,必须在入口拒绝。 +func TestOpenAIResponsesWebSocket_FirstFrameDuplicateModelKeysRejected(t *testing.T) { + for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} { + t.Run(mode, func(t *testing.T) { + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-5.4","model":"gpt-4.1","stream":false}`, + group: wsAllowlistGroup(true, "gpt-5.4"), + ingressMode: mode, + firstFrameCloseExpected: true, + }) + }) + } +} + +// 首帧混用大小写变体键({"model":"allowed","Model":"blocked"}):绑定系上游 +// 大小写不敏感且末值生效会绑定 blocked,必须在入口拒绝。仅含变体键的帧则会在 +// 「model is required」处更早被关闭。 +func TestOpenAIResponsesWebSocket_FirstFrameCaseVariantModelKeyRejected(t *testing.T) { + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-5.4","Model":"gpt-4.1","stream":false}`, + group: wsAllowlistGroup(true, "gpt-5.4"), + ingressMode: service.OpenAIWSIngressModePassthrough, + firstFrameCloseExpected: true, + }) +} + +// 后续 turn 含重复 model 键(首个在白名单、末个不在):拒绝并关闭连接。 +func TestOpenAIResponsesWebSocket_SubsequentTurnDuplicateModelKeysRejected(t *testing.T) { + for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} { + t.Run(mode, func(t *testing.T) { + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`, + secondPayload: `{"type":"response.create","model":"gpt-5.4","model":"gpt-4.1","stream":false}`, + group: wsAllowlistGroup(true, "gpt-5.4"), + ingressMode: mode, + secondTurnCloseExpected: true, + }) + }) + } +} + +// 重复但同值的 model 键不误伤,连接正常完成两个 turn。 +func TestOpenAIResponsesWebSocket_DuplicateIdenticalModelKeysAllowed(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-5.4","model":"gpt-5.4","stream":false}`, + secondPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`, + group: wsAllowlistGroup(true, "gpt-5.4"), + }) + if len(got.clientEvents) != 2 { + t.Fatalf("expected two completed events, got %d", len(got.clientEvents)) + } +} + +// session.update 轮换绕过:首帧用白名单内模型建立会话,session.update 把会话 +// 模型改为白名单外的模型,随后 response.create 携带嵌套 session.model(白名单 +// 内)让帧内候选非空。实际生效模型(轮换后的会话模型)必须始终参与校验。 +func TestOpenAIResponsesWebSocket_SessionUpdateRotationBypassRejected(t *testing.T) { + runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`, + midPayload: `{"type":"session.update","session":{"model":"gpt-4.1"}}`, + secondPayload: `{"type":"response.create","session":{"model":"gpt-5.4"},"stream":false}`, + group: wsAllowlistGroup(true, "gpt-5.4"), + ingressMode: service.OpenAIWSIngressModePassthrough, + secondTurnCloseExpected: true, + }) +} + +// 轮换后的会话模型本身在白名单内时,省略 model 的后续 turn 正常放行。 +func TestOpenAIResponsesWebSocket_SessionUpdateToAllowedModelStillWorks(t *testing.T) { + got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ + firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`, + midPayload: `{"type":"session.update","session":{"model":"gpt-5.4"}}`, + secondPayload: `{"type":"response.create","stream":false}`, + group: wsAllowlistGroup(true, "gpt-5.4"), + }) + if len(got.clientEvents) < 2 { + t.Fatalf("expected at least two completed events, got %d", len(got.clientEvents)) + } +} diff --git a/backend/internal/handler/openai_models_handler.go b/backend/internal/handler/openai_models_handler.go new file mode 100644 index 0000000000..c20a49d0cb --- /dev/null +++ b/backend/internal/handler/openai_models_handler.go @@ -0,0 +1,52 @@ +package handler + +import ( + "errors" + "net/http" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +func (h *GatewayHandler) pinnedOpenAIModels(c *gin.Context, group *service.Group) { + if c.Request.Context().Err() != nil { + return + } + if h.openAIGatewayService == nil { + writeOpenAIModelsError(c, http.StatusInternalServerError, "api_error", "OpenAI model discovery is not configured") + return + } + response, account, err := h.openAIGatewayService.FetchPinnedOpenAIModelsList( + c.Request.Context(), group, h.maxAccountSwitches, c.GetHeader("If-None-Match"), + ) + if c.Request.Context().Err() != nil { + return + } + if err != nil { + if errors.Is(err, service.ErrNoPinnedCodexModelsAccounts) { + writeOpenAIModelsError(c, http.StatusServiceUnavailable, "upstream_error", "No available OpenAI model discovery accounts") + return + } + writeOpenAIModelsError(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err)) + return + } + setOpsSelectedAccount(c, account.ID, account.Platform) + writeOpenAIModelsResponse(c, response) +} + +func writeOpenAIModelsError(c *gin.Context, status int, errorType, message string) { + c.JSON(status, gin.H{"error": gin.H{"type": errorType, "message": message}}) +} + +func writeOpenAIModelsResponse(c *gin.Context, manifest *service.OpenAIModelsResponse) { + if manifest.ETag != "" { + c.Header("ETag", manifest.ETag) + } + if manifest.NotModified { + c.Status(http.StatusNotModified) + c.Writer.WriteHeaderNow() + return + } + c.Data(http.StatusOK, "application/json", manifest.Body) +} diff --git a/backend/internal/handler/openai_models_handler_test.go b/backend/internal/handler/openai_models_handler_test.go new file mode 100644 index 0000000000..9277d1573d --- /dev/null +++ b/backend/internal/handler/openai_models_handler_test.go @@ -0,0 +1,217 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func performOrdinaryPinnedModelsRequest(t *testing.T, codex *OpenAIGatewayHandler, group *service.Group, path, etag string) *httptest.ResponseRecorder { + t.Helper() + h := &GatewayHandler{openAIGatewayService: codex.gatewayService, maxAccountSwitches: 3} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, path, nil) + c.Request.Header.Set("If-None-Match", etag) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{GroupID: &group.ID, Group: group}) + h.Models(c) + return recorder +} + +func ordinaryPinnedModelIDs(t *testing.T, recorder *httptest.ResponseRecorder) []string { + t.Helper() + var response gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.NotNil(t, response.Data, "an empty catalog must be an array, not null") + return modelIDsForTest(response.Data) +} + +func TestOrdinaryPinnedModelsUsesSelectedAccountsAndFinalETag(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(1, service.StatusActive, true, false), + newPinnedCodexAccount(2, service.StatusActive, true, true), + newPinnedCodexAccount(3, service.StatusActive, true, false), + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{ + 1: `{"data":[{"id":"unselected"}]}`, + 2: `{"data":[{"id":"special-model","owned_by":"first","created":123},{"id":"gpt-image-1"}]}`, + 3: `{"data":[{"id":"special-model","owned_by":"second"},{"id":"text-embedding-3-large"}]}`, + }} + h := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ID: 91, Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2, 3}}} + first := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "") + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + require.Equal(t, []string{"special-model", "gpt-image-1", "text-embedding-3-large"}, ordinaryPinnedModelIDs(t, first)) + var response gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(first.Body.Bytes(), &response)) + require.Equal(t, "model", response.Data[0].Object) + require.Equal(t, "first", response.Data[0].OwnedBy) + require.EqualValues(t, 123, response.Data[0].Created) + require.Equal(t, []int64{2, 3}, upstream.accountIDs()) + alias := performOrdinaryPinnedModelsRequest(t, h, group, "/models", first.Header().Get("ETag")) + require.Equal(t, http.StatusNotModified, alias.Code) + require.Empty(t, alias.Body.String()) + require.Equal(t, []int64{2, 3}, upstream.accountIDs(), "fresh requests must not hit upstream") + + otherGroup := *group + otherGroup.ID++ + otherGroup.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: []string{"text-embedding-3-large", "special-model"}} + filtered := performOrdinaryPinnedModelsRequest(t, h, &otherGroup, "/v1/models", first.Header().Get("ETag")) + require.Equal(t, http.StatusOK, filtered.Code) + require.Equal(t, []string{"text-embedding-3-large", "special-model"}, ordinaryPinnedModelIDs(t, filtered)) + require.NotEqual(t, first.Header().Get("ETag"), filtered.Header().Get("ETag")) + again := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "") + require.Equal(t, first.Body.String(), again.Body.String(), "group filters must not modify account cache") + require.Equal(t, []int64{2, 3}, upstream.accountIDs()) +} + +func TestOrdinaryPinnedModelsFailureAndEmptyPolicies(t *testing.T) { + for _, tc := range []struct { + name string + accountIDs []int64 + bodies map[int64]string + statuses map[int64]int + fallback bool + selected []string + wantStatus int + wantIDs []string + }{ + {name: "partial failure", accountIDs: []int64{2, 3}, bodies: map[int64]string{2: `{"data":[{"id":"ok"}]}`}, statuses: map[int64]int{3: 503}, wantStatus: 200, wantIDs: []string{"ok"}}, + {name: "all failed", accountIDs: []int64{2, 3}, statuses: map[int64]int{2: 429, 3: 504}, wantStatus: 502}, + {name: "missing members", accountIDs: []int64{99}, wantStatus: 503}, + {name: "empty config", wantStatus: 503}, + {name: "fallback from missing members", accountIDs: []int64{99}, fallback: true, wantStatus: 200, wantIDs: []string{"from-scheduler"}}, + {name: "fallback from failure", accountIDs: []int64{2}, statuses: map[int64]int{2: 503}, fallback: true, wantStatus: 200, wantIDs: []string{"from-scheduler"}}, + {name: "valid empty", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":[]}`}, fallback: true, wantStatus: 200}, + {name: "filtered empty", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":[{"id":"ok"}]}`}, selected: []string{"absent"}, fallback: true, wantStatus: 200}, + {name: "invalid envelope", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"error":"denied"}`}, wantStatus: 502}, + {name: "null data", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":null}`}, wantStatus: 502}, + } { + t.Run(tc.name, func(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(1, service.StatusActive, true, false), + newPinnedCodexAccount(2, service.StatusActive, true, false), + newPinnedCodexAccount(3, service.StatusActive, true, false), + } + bodies := map[int64]string{1: `{"data":[{"id":"from-scheduler"}]}`} + for id, body := range tc.bodies { + bodies[id] = body + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: bodies, statuses: tc.statuses} + h := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ID: 92, Platform: service.PlatformOpenAI, + ModelAllowlist: service.GroupModelAllowlist{Enabled: len(tc.selected) > 0, Models: tc.selected}, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: tc.accountIDs, FallbackToScheduler: tc.fallback}} + recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "") + require.Equal(t, tc.wantStatus, recorder.Code, recorder.Body.String()) + if tc.wantStatus == 200 { + require.Equal(t, len(tc.wantIDs), len(ordinaryPinnedModelIDs(t, recorder))) + if len(tc.wantIDs) > 0 { + require.Equal(t, tc.wantIDs, ordinaryPinnedModelIDs(t, recorder)) + } else { + require.NotContains(t, upstream.accountIDs(), int64(1), "empty success must not trigger fallback") + } + } + }) + } +} + +func TestOrdinaryPinnedModelsSkipsPersistentlyUnavailableAccounts(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(1, service.StatusActive, true, true), + newPinnedCodexAccount(2, service.StatusActive, false, false), + newPinnedCodexAccount(3, service.StatusDisabled, true, false), + newPinnedCodexAccount(4, service.StatusActive, true, false), + } + expired := time.Now().Add(-time.Hour) + accounts[3].ExpiresAt = &expired + accounts[3].AutoPauseOnExpired = true + overloaded := time.Now().Add(time.Hour) + accounts[0].OverloadUntil = &overloaded + accounts[0].TempUnschedulableUntil = &overloaded + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{1: `{"data":[{"id":"available"}]}`}} + h := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ID: 93, Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{1, 2, 3, 4, 99}}} + recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "") + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []int64{1}, upstream.accountIDs()) +} + +func TestPinnedModelsAllowlistExpandsWildcardsForBothRepresentations(t *testing.T) { + for _, codex := range []bool{false, true} { + t.Run(map[bool]string{false: "ordinary", true: "codex"}[codex], func(t *testing.T) { + accounts := []service.Account{newPinnedCodexAccount(1, service.StatusActive, true, false)} + accounts[0].Credentials["model_mapping"] = map[string]any{ + "public-a": "gpt-5.5", "public-b": "gpt-5.5", "blocked": "gpt-5.5", + } + body := `{"data":[{"id":"gpt-5.5","owned_by":"provider"}]}` + if codex { + body = `{"models":[{"slug":"gpt-5.5","context_window":424242}]}` + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{1: body}} + h := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ID: 95, Platform: service.PlatformOpenAI, + ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"public-b", "public-*"}}, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{1}}} + if codex { + recorder := performPinnedCodexModelsRequest(t, h, group, "") + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []string{"public-b", "public-a"}, codexHandlerManifestSlugs(t, recorder)) + require.Contains(t, recorder.Body.String(), `"context_window":424242`) + } else { + recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "") + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []string{"public-b", "public-a"}, ordinaryPinnedModelIDs(t, recorder)) + require.Contains(t, recorder.Body.String(), `"owned_by":"provider"`) + } + }) + } +} + +func TestPinnedModelsMappingFollowsUpstreamDiscoveryForBothRepresentations(t *testing.T) { + for _, codex := range []bool{false, true} { + t.Run(map[bool]string{false: "ordinary", true: "codex"}[codex], func(t *testing.T) { + accounts := []service.Account{ + newPinnedCodexAccount(1, service.StatusActive, true, false), + newPinnedCodexAccount(2, service.StatusActive, true, false), + } + accounts[0].Credentials["model_mapping"] = map[string]any{"unselected-alias": "gpt-5.5"} + accounts[1].Credentials["model_mapping"] = map[string]any{ + "public-alias": "gpt-5.5", "missing-alias": "missing-upstream", "custom-*": "gpt-5.5", + } + body := `{"data":[{"id":"gpt-5.5","owned_by":"provider","created":123},{"id":"unmapped-upstream"}]}` + if codex { + body = `{"models":[{"slug":"gpt-5.5","context_window":424242},{"slug":"unmapped-upstream"}]}` + } + upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{2: body}} + h := newPinnedCodexTestHandler(accounts, upstream, 3) + group := &service.Group{ID: 94, Platform: service.PlatformOpenAI, + ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"custom-concrete", "public-alias", "unselected-alias", "missing-alias"}}, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2}}} + var recorder *httptest.ResponseRecorder + if codex { + recorder = performPinnedCodexModelsRequest(t, h, group, "") + } else { + recorder = performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "") + } + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []int64{2}, upstream.accountIDs(), "mappings must not bypass pinned discovery") + if codex { + require.Equal(t, []string{"custom-concrete", "public-alias"}, codexHandlerManifestSlugs(t, recorder)) + require.Contains(t, recorder.Body.String(), `"context_window":424242`) + } else { + require.Equal(t, []string{"custom-concrete", "public-alias"}, ordinaryPinnedModelIDs(t, recorder)) + require.Contains(t, recorder.Body.String(), `"owned_by":"provider"`) + } + }) + } +} diff --git a/backend/internal/handler/openai_responses_astra_pro_failover_test.go b/backend/internal/handler/openai_responses_astra_pro_failover_test.go new file mode 100644 index 0000000000..06bc79b3cd --- /dev/null +++ b/backend/internal/handler/openai_responses_astra_pro_failover_test.go @@ -0,0 +1,205 @@ +//go:build unit + +package handler + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// astraProRequestBody is the inbound OpenAI Responses request that must survive +// the account-switch loop unchanged: gpt-6-astra with the official top-level +// reasoning.mode/effort preserved by the service guard +// (normalizeOpenAIResponsesReasoningMode), never downgraded mode -> effort. +const astraProRequestBody = `{"model":"gpt-6-astra","stream":false,"input":"hello","reasoning":{"mode":"pro","effort":"max"}}` + +// astraProCodexURL is the OAuth codex Responses exit every forwarded attempt must hit. +const astraProCodexURL = "https://chatgpt.com/backend-api/codex/responses" + +// astraProSuccessResponse is the winning account's non-stream success; it echoes +// the structured reasoning.mode=pro so the client response must keep mode=pro. +const astraProSuccessResponse = `{"id":"resp_astra","model":"gpt-6-astra","status":"completed",` + + `"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],` + + `"usage":{"input_tokens":1,"output_tokens":2},` + + `"reasoning":{"mode":"pro","effort":"max"}}` + +// astraProForbiddenResponse: a plain upstream 403 that stays failover-eligible but +// is not reclassified as access-state/credential failure nor a silent refusal. +const astraProForbiddenResponse = `{"error":{"type":"forbidden_error","code":"forbidden","message":"Forbidden"}}` + +// astraProCapturedUpstream records URL/accountID/body per forwarded attempt. +type astraProCapturedUpstream struct { + service.HTTPUpstream + mu sync.Mutex + urls []string + accountIDs []int64 + bodies [][]byte + answers []*http.Response +} + +func newAstraProCapturedUpstream(answers ...*http.Response) *astraProCapturedUpstream { + return &astraProCapturedUpstream{answers: answers} +} + +func (u *astraProCapturedUpstream) Do(req *http.Request, _ string, accountID int64, _ int) (*http.Response, error) { + u.mu.Lock() + defer u.mu.Unlock() + body, _ := io.ReadAll(req.Body) + u.urls = append(u.urls, req.URL.String()) + u.accountIDs = append(u.accountIDs, accountID) + u.bodies = append(u.bodies, body) + var resp *http.Response + if len(u.answers) > 0 { + resp = u.answers[0] + u.answers = u.answers[1:] + } + if resp == nil { + resp = &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(bytes.NewReader(nil))} + } + return resp, nil +} + +func (u *astraProCapturedUpstream) snapshot() ([]string, []int64, [][]byte) { + u.mu.Lock() + defer u.mu.Unlock() + return append([]string(nil), u.urls...), append([]int64(nil), u.accountIDs...), append([][]byte(nil), u.bodies...) +} + +// newAstraProFailoverContext builds the gin context that drives handler.Responses +// through the real failover loop for the astra pro request. +func newAstraProFailoverContext(t *testing.T, body string) (*gin.Context, *httptest.ResponseRecorder) { + t.Helper() + groupID := int64(3132) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader([]byte(body))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + ID: 99, + GroupID: &groupID, + Group: &service.Group{ + ID: groupID, + Platform: service.PlatformOpenAI, + }, + User: &service.User{ID: 100}, + }) + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 100, Concurrency: 0}) + return c, rec +} + +func astra403() *http.Response { + return &http.Response{ + StatusCode: http.StatusForbidden, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewBufferString(astraProForbiddenResponse)), + } +} + +func astra200() *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewBufferString(astraProSuccessResponse)), + } +} + +// assertAstraProWire: every forwarded attempt must hit the codex responses URL and +// keep model=gpt-6-astra + reasoning.mode=pro + effort=max (no silent downgrade). +func assertAstraProWire(t *testing.T, urls []string, bodies [][]byte) { + t.Helper() + require.NotEmpty(t, urls, "failover loop must reach the upstream transport") + for i, u := range urls { + require.Equal(t, astraProCodexURL, u, "attempt %d must target the codex responses URL", i) + } + for i, body := range bodies { + require.Equal(t, "gpt-6-astra", gjson.GetBytes(body, "model").String(), "attempt %d must keep model", i) + require.Equal(t, "pro", gjson.GetBytes(body, "reasoning.mode").String(), "attempt %d must keep mode=pro", i) + require.Equal(t, "max", gjson.GetBytes(body, "reasoning.effort").String(), "attempt %d must keep effort=max", i) + } +} + +// assertAstraProAccountSwitch: the failover loop must have actually touched both +// distinct OAuth fixture accounts (IDs 1 and 2), not replayed the same one. +func assertAstraProAccountSwitch(t *testing.T, accountIDs []int64) { + t.Helper() + require.Len(t, accountIDs, 2, "failover must attempt two accounts") + require.ElementsMatch(t, []int64{1, 2}, accountIDs, "both OAuth accounts must be reached") +} + +// 403 then a second OAuth account succeeding: wire bodies keep pro+max/model and the +// winning account's structured reasoning.mode=pro reaches the client unchanged. +func TestOpenAIGatewayHandlerResponses_AstraProFirst403SecondSucceeds(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := newAstraProCapturedUpstream(astra403(), astra200()) + handler := newOpenAIResponsesFailoverTestHandler(t, upstream) + c, rec := newAstraProFailoverContext(t, astraProRequestBody) + + handler.Responses(c) + + urls, accountIDs, bodies := upstream.snapshot() + assertAstraProWire(t, urls, bodies) + assertAstraProAccountSwitch(t, accountIDs) + require.Equal(t, http.StatusOK, rec.Code, "winning account must yield a 200 to the client") + require.Equal(t, "completed", gjson.GetBytes(rec.Body.Bytes(), "status").String()) + require.Equal(t, "pro", gjson.GetBytes(rec.Body.Bytes(), "reasoning.mode").String(), "client must keep the winning response's mode=pro") + require.Equal(t, "max", gjson.GetBytes(rec.Body.Bytes(), "reasoning.effort").String()) +} + +// both OAuth accounts 403: exhausted keeps every wire body pro+max/model, is not 200, +// and surfaces the existing-policy 502 upstream_error (unchanged 403-masking policy). +func TestOpenAIGatewayHandlerResponses_AstraProBoth403NoDowngrade(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := newAstraProCapturedUpstream(astra403(), astra403()) + handler := newOpenAIResponsesFailoverTestHandler(t, upstream) + c, rec := newAstraProFailoverContext(t, astraProRequestBody) + + handler.Responses(c) + + urls, accountIDs, bodies := upstream.snapshot() + assertAstraProWire(t, urls, bodies) + assertAstraProAccountSwitch(t, accountIDs) + require.NotEqual(t, http.StatusOK, rec.Code, "all-403 must never surface a 200 to the client") + require.Equal(t, http.StatusBadGateway, rec.Code) + require.Equal(t, "upstream_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String()) +} + +// passthrough -> non-passthrough drops only the encrypted *input* reasoning item; +// the official top-level reasoning.mode/effort survive verbatim. +func TestDeriveOpenAIForwardAttemptBody_AstraCrossModeKeepsTopLevelReasoningMode(t *testing.T) { + h := &OpenAIGatewayHandler{} + canonical := []byte(`{"model":"gpt-6-astra","stream":false,"reasoning":{"mode":"pro","effort":"max"},"input":[` + + `{"type":"message","role":"user","content":"hello"},` + + `{"type":"reasoning","id":"rs_kiro_abc","encrypted_content":"ENC_BLOB","summary":[{"type":"summary_text","text":"thinking"}]}` + + `]}`) + canonicalSnapshot := append([]byte(nil), canonical...) + state := &openAIPassthroughFailoverState{} + + kiro := newOpenAIPassthroughAccount(1, true) // passthrough first + bedrock := newOpenAIPassthroughAccount(2, false) // non-passthrough after + + firstBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state) + require.Equal(t, 1, reasoningItemCount(t, firstBody), "first passthrough attempt keeps the encrypted input reasoning item") + require.Equal(t, "pro", gjson.GetBytes(firstBody, "reasoning.mode").String()) + require.Equal(t, "max", gjson.GetBytes(firstBody, "reasoning.effort").String()) + + secondBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrock, state) + require.Equal(t, 0, reasoningItemCount(t, secondBody), "cross-mode attempt drops the encrypted input reasoning item") + require.Equal(t, "pro", gjson.GetBytes(secondBody, "reasoning.mode").String(), "mode=pro must survive cross-mode sanitization") + require.Equal(t, "max", gjson.GetBytes(secondBody, "reasoning.effort").String(), "effort=max must survive cross-mode sanitization") + require.Equal(t, "gpt-6-astra", gjson.GetBytes(secondBody, "model").String()) + require.JSONEq(t, string(canonicalSnapshot), string(canonical), "canonical forwardBody must never be mutated") + require.True(t, gjson.GetBytes(canonical, "input.1.encrypted_content").Exists()) +} diff --git a/backend/internal/handler/openai_service_tier_validation_test.go b/backend/internal/handler/openai_service_tier_validation_test.go index 97da6f682c..bcd032c3ac 100644 --- a/backend/internal/handler/openai_service_tier_validation_test.go +++ b/backend/internal/handler/openai_service_tier_validation_test.go @@ -17,7 +17,7 @@ import ( // 上以 OpenAI 兼容错误结构返回 HTTP 400。这些用例在 handler 的 service_tier // 校验处短路,不会进入账号选择/重试。 // -// 合法值(fast/priority/flex/auto/default/scale)与省略/null 的接受语义由 +// 合法值(fast/priority/flex/auto/default/scale/ultrafast)与省略/null 的接受语义由 // service 层纯校验函数 TestValidateOpenAIServiceTierField 覆盖,避免 handler // 测试走入真实账号选择/重试路径。 diff --git a/backend/internal/handler/ops_error_logger.go b/backend/internal/handler/ops_error_logger.go index add82f77bf..060cd99a89 100644 --- a/backend/internal/handler/ops_error_logger.go +++ b/backend/internal/handler/ops_error_logger.go @@ -2189,9 +2189,17 @@ func classifyOpsErrorLog(c *gin.Context, errType, message, code string, status i routingCapacityLimited := isOpsRoutingCapacityLimited(c) clientBusinessLimited := service.HasOpsClientBusinessLimited(c) localModelConfiguration := clientBusinessLimited && service.OpsClientBusinessLimitedReason(c) == service.OpsClientBusinessLimitedReasonLocalModelConfiguration + // 分组模型白名单的入口拒绝发生在调度之前(本地面请求策略,业务限流原因与 + // 账号模型映射复用 local_model_configuration):保持 classifyOpsPhase 的 + // 自然分类(not_found/invalid_request → request,owner=client),不落到 + // routing;上游归因仍由 suppressOpsUpstreamAttributionForLocalModelConfiguration 清空。 + ingressModelNotAllowed := false + if reason, rejected := middleware2.GetIngressRejectReason(c); rejected && reason == middleware2.IngressRejectModelNotAllowed { + ingressModelNotAllowed = true + } upstreamError := hasOpsUpstreamErrorContext(c) accountAuthFailure := hasOpsAccountAuthFailure(c) - if localModelConfiguration { + if localModelConfiguration && !ingressModelNotAllowed { phase = "routing" } else if accountAuthFailure && !routingCapacityLimited { phase = "account_auth" diff --git a/backend/internal/handler/ops_error_logger_test.go b/backend/internal/handler/ops_error_logger_test.go index bd29d103bf..1f7b5f452c 100644 --- a/backend/internal/handler/ops_error_logger_test.go +++ b/backend/internal/handler/ops_error_logger_test.go @@ -1954,3 +1954,45 @@ func TestGetOpsAPIKeyPrefersPrimaryContextKey(t *testing.T) { require.NotNil(t, got) require.Equal(t, int64(1), got.ID, "已鉴权请求应优先使用正式 api key") } + +// 分组模型白名单入口拒绝:业务限流原因复用 local_model_configuration,但 +// 携带 ingress 拒绝原因 model_not_allowed,阶段应保持自然分类(request / +// client),不落到 routing。 +func TestClassifyOpsIngressModelNotAllowedKeepsRequestPhase(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration) + middleware2.MarkIngressRejected(c, middleware2.IngressRejectModelNotAllowed) + + phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog( + c, + "not_found_error", + `Model "gpt-4.1" is not available for this group`, + "model_not_found", + http.StatusNotFound, + ) + + require.Equal(t, "request", phase) + require.True(t, isBusinessLimited) + require.Equal(t, "client", errorOwner) + require.Equal(t, "client_request", errorSource) +} + +// 调度阶段的账号模型映射拒绝(无 ingress 标记)仍归类为 routing。 +func TestClassifyOpsLocalModelConfigurationWithoutIngressMarkStaysRouting(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration) + + phase, isBusinessLimited, errorOwner, _ := classifyOpsErrorLog( + c, + "model_not_found", + "Model \"gpt-missing\" is not supported by any configured account in this group", + "", + http.StatusNotFound, + ) + + require.Equal(t, "routing", phase) + require.True(t, isBusinessLimited) + require.Equal(t, "platform", errorOwner) +} diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index d314a4f94b..ec597a7641 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -108,6 +108,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota, + ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, diff --git a/backend/internal/handler/stream_error_event.go b/backend/internal/handler/stream_error_event.go index 8ba6095cfb..f60e6c7834 100644 --- a/backend/internal/handler/stream_error_event.go +++ b/backend/internal/handler/stream_error_event.go @@ -56,7 +56,7 @@ type responsesFailedEvent struct { // 返回 false 表示 writer 不支持 Flusher,无法以 SSE 形式回报错误; // 此时 caller 也无法回退到 JSON(HTTP 200 已固化),通常意味着连接已经损坏, // 应当让请求处理函数 return,由上层关闭连接。 -func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool { +func writeResponsesFailedSSE(c *gin.Context, errType, code, message string) bool { flusher, ok := c.Writer.(http.Flusher) if !ok { return false @@ -72,7 +72,7 @@ func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool { Status: "failed", Output: []any{}, Error: responsesFailedError{ - Code: mapResponsesErrorCode(errType), + Code: mapResponsesErrorCode(errType, code), Message: message, }, }, @@ -155,7 +155,10 @@ func requestModel(c *gin.Context) string { // mapResponsesErrorCode 把内部 errType 映射为 Responses 协议常见的 error.code。 // 无明确映射时原样返回,保证至少可读。 -func mapResponsesErrorCode(errType string) string { +func mapResponsesErrorCode(errType, code string) string { + if code != "" { + return code + } switch errType { case "rate_limit_error": return "rate_limit_exceeded" diff --git a/backend/internal/handler/stream_error_event_test.go b/backend/internal/handler/stream_error_event_test.go index 187deda19d..94089e9fae 100644 --- a/backend/internal/handler/stream_error_event_test.go +++ b/backend/internal/handler/stream_error_event_test.go @@ -12,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) // Regression for the production incident on 2026-05-24 around 9:13 CST: @@ -77,6 +78,60 @@ func TestOpenAIHandleStreamingAwareError_ResponsesStreamingEmitsResponseFailed(t assert.Equal(t, "Concurrency limit exceeded for user, please retry later", errObj["message"]) } +func TestOpenAIAdmissionError_SynchronousNonResponsesIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointChatCompletions) + h := &OpenAIGatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayQueueFullCode, "Too many pending requests, please retry later", false, false) + + assert.Equal(t, http.StatusTooManyRequests, w.Code) + assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String()) + assert.Equal(t, gatewayQueueFullCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String()) +} + +func TestOpenAIAdmissionError_SynchronousResponsesIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointResponses) + h := &OpenAIGatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", false, false) + + assert.Equal(t, http.StatusTooManyRequests, w.Code) + assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String()) + assert.Equal(t, gatewayConcurrencyLimitCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String()) +} + +func TestOpenAIAdmissionError_ResponsesFailedIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointResponses) + h := &OpenAIGatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", true, false) + + _, errObj := parseResponsesFailedSSE(t, w.Body.String()) + assert.Equal(t, gatewayConcurrencyLimitCode, errObj["code"]) +} + +func TestOpenAIAdmissionError_StreamingNonResponsesIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointChatCompletions) + h := &OpenAIGatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayQueueFullCode, "Too many pending requests, please retry later", true, false) + + body := w.Body.String() + assert.True(t, strings.HasPrefix(body, "event: error\n")) + payload := body[strings.Index(body, "{"):] + assert.Equal(t, "rate_limit_error", gjson.Get(payload, "error.type").String()) + assert.Equal(t, gatewayQueueFullCode, gjson.Get(payload, "error.code").String()) +} + +func TestOpenAIUpstreamRateLimitWithoutLocalCodeRemainsUnchanged(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointChatCompletions) + h := &OpenAIGatewayHandler{} + h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "upstream limited", false) + + assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String()) + assert.False(t, gjson.GetBytes(w.Body.Bytes(), "error.code").Exists()) +} + // 当 setOpsRequestContext 写过 model,合成事件应回填该字段(与 codebase 已有 makeResponsesCompletedEvent 对齐)。 func TestOpenAIHandleStreamingAwareError_ResponsesStreamingIncludesModel(t *testing.T) { c, w := newGinContextForEndpoint(t, EndpointResponses) @@ -164,6 +219,41 @@ func TestGatewayHandleStreamingAwareError_ResponsesStreamingEmitsResponseFailed( assert.Equal(t, "upstream gone", errObj["message"]) } +func TestGatewayAdmissionError_SynchronousIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointMessages) + h := &GatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayQueueFullCode, "Too many pending requests, please retry later", false) + + assert.Equal(t, http.StatusTooManyRequests, w.Code) + assert.Equal(t, "error", gjson.GetBytes(w.Body.Bytes(), "type").String()) + assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String()) + assert.Equal(t, gatewayQueueFullCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String()) +} + +func TestGatewayAdmissionError_MessagesStreamingIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointMessages) + h := &GatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", true) + + body := w.Body.String() + assert.True(t, strings.HasPrefix(body, `data: {"type":"error"`)) + payload := body[strings.Index(body, "{"):] + assert.Equal(t, "rate_limit_error", gjson.Get(payload, "error.type").String()) + assert.Equal(t, gatewayConcurrencyLimitCode, gjson.Get(payload, "error.code").String()) +} + +func TestGatewayAdmissionError_BareResponsesFailedIncludesGatewayCode(t *testing.T) { + c, w := newGinContextForEndpoint(t, "/responses") + h := &GatewayHandler{} + h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", + gatewayQueueFullCode, "Too many pending requests, please retry later", true) + + _, errObj := parseResponsesFailedSSE(t, w.Body.String()) + assert.Equal(t, gatewayQueueFullCode, errObj["code"]) +} + // Gateway handler: /v1/messages preserves the legacy data:{type:error,...} format // (Anthropic spec accepts a type:"error" stream event). func TestGatewayHandleStreamingAwareError_MessagesStreamingKeepsLegacy(t *testing.T) { @@ -264,6 +354,7 @@ func TestMapResponsesErrorCode(t *testing.T) { {"custom_thing", "custom_thing"}, } for _, tc := range cases { - assert.Equal(t, tc.out, mapResponsesErrorCode(tc.in), "in=%q", tc.in) + assert.Equal(t, tc.out, mapResponsesErrorCode(tc.in, ""), "in=%q", tc.in) } + assert.Equal(t, gatewayQueueFullCode, mapResponsesErrorCode("rate_limit_error", gatewayQueueFullCode)) } diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index 0e75dc9258..d35245ecbf 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -250,7 +250,7 @@ var ProviderSet = wire.NewSet( // Admin handlers admin.NewDashboardHandler, admin.NewUserHandler, - admin.NewGroupHandler, + admin.NewGroupHandlerWithConfig, admin.ProvideAccountHandler, admin.NewAnnouncementHandler, admin.NewDataManagementHandler, diff --git a/backend/internal/model/error_passthrough_rule.go b/backend/internal/model/error_passthrough_rule.go index bb3a315771..c0cdfc4c6c 100644 --- a/backend/internal/model/error_passthrough_rule.go +++ b/backend/internal/model/error_passthrough_rule.go @@ -44,6 +44,7 @@ const ( PlatformKimi = domain.PlatformKimi PlatformZhipu = domain.PlatformZhipu PlatformDeepseek = domain.PlatformDeepseek + PlatformMiniMax = domain.PlatformMiniMax ) // AllPlatforms 返回所有支持的平台列表 @@ -57,6 +58,7 @@ func AllPlatforms() []string { PlatformKimi, PlatformZhipu, PlatformDeepseek, + PlatformMiniMax, } } diff --git a/backend/internal/model/error_passthrough_rule_test.go b/backend/internal/model/error_passthrough_rule_test.go index fc9ff5b385..7b69b1cf21 100644 --- a/backend/internal/model/error_passthrough_rule_test.go +++ b/backend/internal/model/error_passthrough_rule_test.go @@ -16,5 +16,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) { "kimi", "zhipu", "deepseek", + "minimax", }, AllPlatforms()) } diff --git a/backend/internal/pkg/antigravity/oauth.go b/backend/internal/pkg/antigravity/oauth.go index 26b307bbf7..625dcaea69 100644 --- a/backend/internal/pkg/antigravity/oauth.go +++ b/backend/internal/pkg/antigravity/oauth.go @@ -34,7 +34,7 @@ const ( AntigravityUserAgentVersionEnv = "ANTIGRAVITY_USER_AGENT_VERSION" // DefaultUserAgentVersion 是未通过环境变量或后台设置覆盖时使用的默认版本号。 - DefaultUserAgentVersion = "1.23.2" + DefaultUserAgentVersion = "2.9.1" // 固定的 redirect_uri(用户需手动复制 code) RedirectURI = "http://localhost:8085/callback" diff --git a/backend/internal/pkg/antigravity/oauth_test.go b/backend/internal/pkg/antigravity/oauth_test.go index 7849492c7c..4ebc7e5434 100644 --- a/backend/internal/pkg/antigravity/oauth_test.go +++ b/backend/internal/pkg/antigravity/oauth_test.go @@ -693,7 +693,7 @@ func TestConstants_值正确(t *testing.T) { if RedirectURI != "http://localhost:8085/callback" { t.Errorf("RedirectURI 不匹配: got %s", RedirectURI) } - if GetUserAgent() != "antigravity/1.23.2 windows/amd64" { + if GetUserAgent() != "antigravity/2.9.1 windows/amd64" { t.Errorf("UserAgent 不匹配: got %s", GetUserAgent()) } if SessionTTL != 30*time.Minute { diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 1750848348..5f9e74bc6f 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -148,21 +148,20 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map } // 针对 Gemini Reasoning 模型(如 gemini-3.1-pro-high等)过滤强制空 ToolConfig - isReasoning := IsGeminiReasoningModel(targetModel) - if !isReasoning || len(tools) > 0 { - // 总是设置 toolConfig,与官方客户端一致 - innerRequest.ToolConfig = &GeminiToolConfig{ - FunctionCallingConfig: &GeminiFunctionCallingConfig{ - Mode: "VALIDATED", - }, - } - // 内置工具(googleSearch)与函数调用混用时,上游要求显式开启 - // includeServerSideToolInvocations,否则返回 400(issue #5709)。 - // 与 raw 透传路的 enableMixedGeminiToolInvocations 注入保持同一语义。 - if hasMixedToolInvocations(tools) { - enabled := true - innerRequest.ToolConfig.IncludeServerSideToolInvocations = &enabled - } + // toolConfig must always be present: upstream rejects requests without it, + // including reasoning models called without any tools. + // 总是设置 toolConfig,与官方客户端一致 + innerRequest.ToolConfig = &GeminiToolConfig{ + FunctionCallingConfig: &GeminiFunctionCallingConfig{ + Mode: "VALIDATED", + }, + } + // 内置工具(googleSearch)与函数调用混用时,上游要求显式开启 + // includeServerSideToolInvocations,否则返回 400(issue #5709)。 + // 与 raw 透传路的 enableMixedGeminiToolInvocations 注入保持同一语义。 + if hasMixedToolInvocations(tools) { + enabled := true + innerRequest.ToolConfig.IncludeServerSideToolInvocations = &enabled } if systemInstruction != nil { diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index 05764ef7ff..62d038640c 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -621,3 +621,38 @@ func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) { require.NotContains(t, raw, "includeServerSideToolInvocations") }) } + +// TestToolConfigAlwaysPresent ensures toolConfig is always emitted, including for +// reasoning models without any tools: upstream rejects requests that omit it. +func TestToolConfigAlwaysPresent(t *testing.T) { + cases := []struct { + name string + model string + tools []ClaudeTool + }{ + {name: "reasoning model without tools", model: "gemini-3.1-pro-high"}, + {name: "reasoning model with tools", model: "gemini-3.1-pro-high", tools: []ClaudeTool{{ + Name: "web_search", + Description: "Search the web", + InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}, + }}}, + {name: "non-reasoning model without tools", model: "gemini-3.1-pro"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + claudeReq := &ClaudeRequest{ + Model: tc.model, + Messages: []ClaudeMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}}, + Tools: tc.tools, + } + body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", tc.model, DefaultTransformOptions()) + require.NoError(t, err) + + var req V1InternalRequest + require.NoError(t, json.Unmarshal(body, &req)) + require.NotNil(t, req.Request.ToolConfig, "toolConfig must be present") + require.NotNil(t, req.Request.ToolConfig.FunctionCallingConfig) + require.Equal(t, "VALIDATED", req.Request.ToolConfig.FunctionCallingConfig.Mode) + }) + } +} diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 0897f858ab..e9f7ff0354 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -141,6 +141,36 @@ func EffectiveResponsesTools(req *ResponsesRequest) ([]ResponsesTool, error) { } tools = append(tools, item.Tools...) } + + // A completed client tool search can introduce tools for the remainder of + // the turn. Promote them before lowering to ChatCompletions, using the same + // dedupe, conflict, namespace, and custom-tool rules as the native adapter. + toolsRaw, err := json.Marshal(tools) + if err != nil { + return nil, fmt.Errorf("encode responses tools for discovery promotion: %w", err) + } + var rawTools, rawInput []any + if err := json.Unmarshal(toolsRaw, &rawTools); err != nil { + return nil, fmt.Errorf("decode responses tools for discovery promotion: %w", err) + } + if err := json.Unmarshal(inputRaw, &rawInput); err != nil { + return nil, fmt.Errorf("parse responses input for discovery promotion: %w", err) + } + promoted, err := promotedResponsesToolSearchDiscoveries(rawTools, rawInput) + if err != nil { + return nil, err + } + if len(promoted) > 0 { + promotedRaw, err := json.Marshal(promoted) + if err != nil { + return nil, fmt.Errorf("encode promoted responses tools: %w", err) + } + var discovered []ResponsesTool + if err := json.Unmarshal(promotedRaw, &discovered); err != nil { + return nil, fmt.Errorf("decode promoted responses tools: %w", err) + } + tools = append(tools, discovered...) + } return tools, nil } @@ -456,6 +486,11 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa continue case "function_call_output", "custom_tool_call_output", "tool_search_output": outputRaw := bytesTrimSpace(item["output"]) + if itemType == "tool_search_output" && (len(outputRaw) == 0 || string(outputRaw) == "null") { + // Newer clients return discoveries in tools[] without a separate + // output field. Keep that useful result in Chat tool history. + outputRaw = bytesTrimSpace(item["tools"]) + } callID := rawString(item["call_id"]) if callID == "" && invalidEmptyFunctionCallOutputs > 0 { invalidEmptyFunctionCallOutputs-- diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go index ebf23fd9f9..bd56729545 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge_custom_tools_test.go @@ -597,6 +597,65 @@ func TestResponsesInputToChatMessages_ToolSearchCallHistory(t *testing.T) { assert.JSONEq(t, `"{\"groups\":[\"gmail\"]}"`, string(messages[2].Content)) } +func TestResponsesToChatCompletionsRequest_PromotesCompletedToolSearchDiscoveries(t *testing.T) { + var req ResponsesRequest + require.NoError(t, json.Unmarshal([]byte(`{ + "model":"gpt-5","tools":[ + {"type":"tool_search"}, + {"type":"function","name":"inspect","parameters":{"type":"object"}} + ],"input":[ + {"type":"tool_search_call","call_id":"search_1","arguments":{"query":"workspace"}}, + {"type":"tool_search_output","call_id":"search_1","status":"completed","execution":"client","tools":[ + {"type":"function","name":"inspect","parameters":{"type":"object"}}, + {"type":"custom","name":"exec"}, + {"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]} + ]} + ]}`), &req)) + + effective, err := EffectiveResponsesTools(&req) + require.NoError(t, err) + require.Len(t, effective, 4, "the identical static discovery must be deduplicated") + assert.True(t, CustomToolNames(effective)["exec"]) + namespaces := NamespaceToolNames(effective) + assert.Equal(t, NamespacedToolName{Namespace: "collaboration", Name: "spawn_agent"}, namespaces["collaboration__spawn_agent"]) + + chatReq, err := ResponsesToChatCompletionsRequest(&req) + require.NoError(t, err) + require.Len(t, chatReq.Tools, 4) + assert.Equal(t, []string{"tool_search", "inspect", "exec", "collaboration__spawn_agent"}, []string{ + chatReq.Tools[0].Function.Name, chatReq.Tools[1].Function.Name, + chatReq.Tools[2].Function.Name, chatReq.Tools[3].Function.Name, + }) + require.Len(t, chatReq.Messages, 2) + assert.Equal(t, "tool", chatReq.Messages[1].Role) + var discoveryPayload []map[string]any + var serialized string + require.NoError(t, json.Unmarshal(chatReq.Messages[1].Content, &serialized)) + require.NoError(t, json.Unmarshal([]byte(serialized), &discoveryPayload)) + require.Len(t, discoveryPayload, 3) + + responses := ChatCompletionsResponseToResponses(&ChatCompletionsResponse{Choices: []ChatChoice{{ + Message: ChatMessage{Role: "assistant", ToolCalls: []ChatToolCall{ + {ID: "call_1", Type: "function", Function: ChatFunctionCall{Name: "collaboration__spawn_agent", Arguments: `{}`}}, + {ID: "call_2", Type: "function", Function: ChatFunctionCall{Name: "exec", Arguments: `{"input":"pwd"}`}}, + }}, + }}}, req.Model, CustomToolNames(effective), FunctionToolNames(effective), HasToolSearchTool(effective), namespaces) + require.Len(t, responses.Output, 2) + assert.Equal(t, "function_call", responses.Output[0].Type) + assert.Equal(t, "spawn_agent", responses.Output[0].Name) + assert.Equal(t, "collaboration", responses.Output[0].Namespace) + assert.Equal(t, "custom_tool_call", responses.Output[1].Type) + assert.Equal(t, "exec", responses.Output[1].Name) + assert.Equal(t, "pwd", responses.Output[1].Input) +} + +func TestEffectiveResponsesTools_RejectsDiscoveredConflict(t *testing.T) { + var req ResponsesRequest + require.NoError(t, json.Unmarshal([]byte(`{"tools":[{"type":"tool_search"},{"type":"function","name":"inspect","parameters":{"type":"object"}}],"input":[{"type":"tool_search_output","status":"completed","tools":[{"type":"function","name":"inspect","parameters":{"type":"string"}}]}]}`), &req)) + _, err := EffectiveResponsesTools(&req) + require.ErrorContains(t, err, "conflicts") +} + func TestResponsesInputToChatMessages_NamespacedFunctionCallHistory(t *testing.T) { input := json.RawMessage(`[ {"type":"function_call","call_id":"call_n","name":"send","namespace":"gmail","arguments":"{\"to\":\"a\"}"}, diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index a57bd73f91..1731892735 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -1621,6 +1621,108 @@ func TestBufferedResponseAccumulator_ToolCalls(t *testing.T) { assert.Equal(t, `{"city":"NYC"}`, output[0].Arguments) } +func TestResponsesEventToChatChunks_FunctionArgumentsDoneWithoutDeltas(t *testing.T) { + state := NewResponsesEventToChatState() + state.Model = "gpt-5-codex" + state.SentRole = true + + chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{ + Type: "response.output_item.added", + OutputIndex: 1, + Item: &ResponsesOutput{ + Type: "function_call", + CallID: "call_lookup", + Name: "lookup_item", + Arguments: "", + }, + }, state) + require.Len(t, chunks, 1) + + chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{ + Type: "response.function_call_arguments.done", + OutputIndex: 1, + CallID: "call_lookup", + Name: "lookup_item", + Arguments: `{"id":1}`, + }, state) + require.Len(t, chunks, 1) + require.Len(t, chunks[0].Choices[0].Delta.ToolCalls, 1) + assert.Equal(t, `{"id":1}`, chunks[0].Choices[0].Delta.ToolCalls[0].Function.Arguments) +} + +func TestResponsesEventToChatChunks_FunctionArgumentsDoneDoesNotDuplicateDeltas(t *testing.T) { + state := NewResponsesEventToChatState() + state.Model = "gpt-5-codex" + state.SentRole = true + + ResponsesEventToChatChunks(&ResponsesStreamEvent{ + Type: "response.output_item.added", + OutputIndex: 0, + Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"}, + }, state) + chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{ + Type: "response.function_call_arguments.delta", + OutputIndex: 0, + Delta: `{"id":1}`, + }, state) + require.Len(t, chunks, 1) + + chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{ + Type: "response.function_call_arguments.done", + OutputIndex: 0, + Arguments: `{"id":1}`, + }, state) + assert.Empty(t, chunks) +} + +func TestBufferedResponseAccumulator_FunctionArgumentsDoneWithoutDeltas(t *testing.T) { + acc := NewBufferedResponseAccumulator() + acc.ProcessEvent(&ResponsesStreamEvent{ + Type: "response.output_item.added", + OutputIndex: 1, + Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"}, + }) + acc.ProcessEvent(&ResponsesStreamEvent{ + Type: "response.function_call_arguments.done", + OutputIndex: 1, + Arguments: `{"id":1}`, + }) + + resp := &ResponsesResponse{Output: []ResponsesOutput{{ + Type: "function_call", + CallID: "call_lookup", + Name: "lookup_item", + Arguments: "", + }}} + acc.SupplementResponseOutput(resp) + + require.Len(t, resp.Output, 1) + assert.Equal(t, `{"id":1}`, resp.Output[0].Arguments) +} + +func TestBufferedResponseAccumulator_FunctionArgumentsDoneReplacesDeltas(t *testing.T) { + acc := NewBufferedResponseAccumulator() + acc.ProcessEvent(&ResponsesStreamEvent{ + Type: "response.output_item.added", + OutputIndex: 0, + Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"}, + }) + acc.ProcessEvent(&ResponsesStreamEvent{ + Type: "response.function_call_arguments.delta", + OutputIndex: 0, + Delta: `{"id":1}`, + }) + acc.ProcessEvent(&ResponsesStreamEvent{ + Type: "response.function_call_arguments.done", + OutputIndex: 0, + Arguments: `{"id":1}`, + }) + + output := acc.BuildOutput() + require.Len(t, output, 1) + assert.Equal(t, `{"id":1}`, output[0].Arguments) +} + func TestBufferedResponseAccumulator_Reasoning(t *testing.T) { acc := NewBufferedResponseAccumulator() diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go index d288b31bea..e649a261df 100644 --- a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go +++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go @@ -126,6 +126,7 @@ type ResponsesEventToChatState struct { Finalized bool // true after finish chunk has been emitted NextToolCallIndex int // next sequential tool_call index to assign OutputIndexToToolIndex map[int]int // Responses output_index → Chat tool_calls index + OutputIndexToArguments map[int]string IncludeUsage bool Usage *ChatUsage } @@ -136,6 +137,7 @@ func NewResponsesEventToChatState() *ResponsesEventToChatState { ID: generateChatCmplID(), Created: time.Now().Unix(), OutputIndexToToolIndex: make(map[int]int), + OutputIndexToArguments: make(map[int]string), } } @@ -154,6 +156,8 @@ func ResponsesEventToChatChunks(evt *ResponsesStreamEvent, state *ResponsesEvent // 均按 OutputIndex 累加到对应工具调用。 "response.custom_tool_call_input.delta": return resToChatHandleFuncArgsDelta(evt, state) + case "response.function_call_arguments.done", "response.custom_tool_call_input.done": + return resToChatHandleFuncArgsDone(evt, state) case "response.reasoning_summary_text.delta", // 原始推理文本增量(真实 Codex 客户端消费的 reasoning_text.delta), // 与 reasoning summary 一样映射为 reasoning_content。 @@ -277,6 +281,7 @@ func resToChatHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve if !ok { return nil } + state.OutputIndexToArguments[evt.OutputIndex] += evt.Delta return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{ ToolCalls: []ChatToolCall{{ @@ -288,6 +293,33 @@ func resToChatHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve })} } +func resToChatHandleFuncArgsDone(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk { + idx, ok := state.OutputIndexToToolIndex[evt.OutputIndex] + if !ok { + return nil + } + + completed := evt.Arguments + if evt.Type == "response.custom_tool_call_input.done" { + completed = evt.Input + } + current := state.OutputIndexToArguments[evt.OutputIndex] + if completed == "" || !strings.HasPrefix(completed, current) || completed == current { + return nil + } + + remainder := completed[len(current):] + state.OutputIndexToArguments[evt.OutputIndex] = completed + return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{ + ToolCalls: []ChatToolCall{{ + Index: &idx, + Function: ChatFunctionCall{ + Arguments: remainder, + }, + }}, + })} +} + func resToChatHandleReasoningDelta(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk { if evt.Delta == "" { return nil @@ -453,9 +485,10 @@ func generateChatCmplID() string { // --------------------------------------------------------------------------- type bufferedFuncCall struct { - CallID string - Name string - Args strings.Builder + OutputIndex int + CallID string + Name string + Args strings.Builder } // BufferedResponseAccumulator collects content from Responses SSE delta events @@ -489,8 +522,9 @@ func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent) idx := len(a.funcCalls) a.outputIndexToFuncIdx[event.OutputIndex] = idx a.funcCalls = append(a.funcCalls, bufferedFuncCall{ - CallID: event.Item.CallID, - Name: event.Item.Name, + OutputIndex: event.OutputIndex, + CallID: event.Item.CallID, + Name: event.Item.Name, }) } case "response.function_call_arguments.delta", "response.custom_tool_call_input.delta": @@ -499,6 +533,17 @@ func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent) _, _ = a.funcCalls[idx].Args.WriteString(event.Delta) } } + case "response.function_call_arguments.done", "response.custom_tool_call_input.done": + completed := event.Arguments + if event.Type == "response.custom_tool_call_input.done" { + completed = event.Input + } + if completed != "" { + if idx, ok := a.outputIndexToFuncIdx[event.OutputIndex]; ok { + a.funcCalls[idx].Args.Reset() + _, _ = a.funcCalls[idx].Args.WriteString(completed) + } + } case "response.reasoning_summary_text.delta", "response.reasoning_text.delta": if event.Delta != "" { _, _ = a.reasoning.WriteString(event.Delta) @@ -550,15 +595,35 @@ func (a *BufferedResponseAccumulator) BuildOutput() []ResponsesOutput { return out } -// SupplementResponseOutput fills resp.Output from accumulated delta content -// when the terminal event delivered an empty output array. If resp.Output is -// already populated, this is a no-op (preserves backward compatibility). +// SupplementResponseOutput fills resp.Output from accumulated stream content +// when the terminal event delivered an empty output array. It also fills empty +// function-call arguments from authoritative argument-done events. func (a *BufferedResponseAccumulator) SupplementResponseOutput(resp *ResponsesResponse) { - if resp == nil || len(resp.Output) > 0 { + if resp == nil { return } - if !a.HasContent() { + if len(resp.Output) == 0 { + if a.HasContent() { + resp.Output = a.BuildOutput() + } return } - resp.Output = a.BuildOutput() + + for outputIndex := range resp.Output { + item := &resp.Output[outputIndex] + if item.Type != "function_call" || item.Arguments != "" { + continue + } + for funcIndex := range a.funcCalls { + call := &a.funcCalls[funcIndex] + matchesCallID := item.CallID != "" && item.CallID == call.CallID + if !matchesCallID && call.OutputIndex != outputIndex { + continue + } + if call.Args.Len() > 0 { + item.Arguments = call.Args.String() + } + break + } + } } diff --git a/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go b/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go index 2617d5f940..55298c0eef 100644 --- a/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go +++ b/backend/internal/pkg/apicompat/responses_tool_search_discoveries.go @@ -27,6 +27,21 @@ func promoteResponsesToolSearchDiscoveries(req map[string]any) (bool, error) { if !ok || len(input) == 0 { return false, nil } + promoted, err := promotedResponsesToolSearchDiscoveries(tools, input) + if err != nil { + return false, err + } + if len(promoted) == 0 { + return false, nil + } + req["tools"] = append(tools, promoted...) + return true, nil +} + +func promotedResponsesToolSearchDiscoveries(tools, input []any) ([]any, error) { + if len(tools) == 0 || !hasResponsesToolSearchDeclaration(tools) || len(input) == 0 { + return nil, nil + } known := make(map[string]responsesDiscoveredToolIdentity) for _, raw := range tools { @@ -60,7 +75,7 @@ func promoteResponsesToolSearchDiscoveries(req map[string]any) (bool, error) { } appendTool, err := admitResponsesDiscoveredTool(known, identity.name, identity) if err != nil { - return false, err + return nil, err } if appendTool { promoted = append(promoted, copy) @@ -74,7 +89,7 @@ func promoteResponsesToolSearchDiscoveries(req map[string]any) (bool, error) { for _, candidate := range identities { appendTool, err := admitResponsesDiscoveredTool(known, candidate.flat, candidate.identity) if err != nil { - return false, err + return nil, err } if appendTool { children = append(children, candidate.child) @@ -88,11 +103,7 @@ func promoteResponsesToolSearchDiscoveries(req map[string]any) (bool, error) { } } } - if len(promoted) == 0 { - return false, nil - } - req["tools"] = append(tools, promoted...) - return true, nil + return promoted, nil } func hasResponsesToolSearchDeclaration(tools []any) bool { diff --git a/backend/internal/pkg/claude/cli_version.go b/backend/internal/pkg/claude/cli_version.go new file mode 100644 index 0000000000..c55b9d34b2 --- /dev/null +++ b/backend/internal/pkg/claude/cli_version.go @@ -0,0 +1,79 @@ +package claude + +import ( + "log/slog" + "os" + "strings" + + "golang.org/x/mod/semver" +) + +// CLIVersionEnv 是 CLICurrentVersion 的可选运维覆盖。 +// +// 存在的理由:Anthropic 会对新模型设客户端版本下限(例如 claude-fable-5-1 要求 +// claude-cli >= 2.1.251),命中时上游直接返回 +// `Claude Code X.Y.Z does not support this model; version A.B.C or newer is required`。 +// 在没有本开关之前,这类模型必须等 sub2api 发一个新版本才能使用, +// 而改动本身只是一个常量。xai 包的 XAI_GROK_CLI_VERSION 已经是同样的做法。 +const CLIVersionEnv = "SUB2API_CLAUDE_CLI_VERSION" + +// resolvedCLIVersion 在包初始化时解析一次。 +// +// ⚠️ 故意不做成"每次调用读一次环境变量":伪装身份必须在一个进程的生命周期内保持恒定。 +// User-Agent 头与请求体 billing attribution 块里的 cc_version 由不同代码路径写入, +// 若两次读到不同的值(例如进程运行中有人改了环境变量),同一个请求就会自相矛盾, +// 被上游判为非正版客户端。 +var resolvedCLIVersion = resolveCLIVersion(os.Getenv(CLIVersionEnv)) + +// CLIVersion 返回对外伪装的 Claude Code CLI 版本号(三段 semver)。 +// +// 所有需要该版本号的位置都必须走本函数,不要直接引用 CLICurrentVersion—— +// 后者只是"没有覆盖时的内置基线"。 +func CLIVersion() string { + return resolvedCLIVersion +} + +// IsSupportedCLIVersion 判断运维给的覆盖值是否可用。 +// +// 判据有两条,缺一不可: +// 1. 严格三段纯数字("2.1.251")。带 -local / -dev / +build 等后缀的版本号会被 +// identity_service 的 fingerprintUserAgentPattern 拒绝,一旦漏进去,该账号的 +// 持久指纹会被写成一个不存在的客户端版本,此后所有上游请求都声称这个版本, +// 被判非正版并持续 429——而系统内没有指纹重置入口。 +// 2. 不低于内置基线 CLICurrentVersion。向下覆盖没有任何使用场景, +// 却会让 identity_service 的主版本超前检查基准跟着一起降。 +func IsSupportedCLIVersion(version string) bool { + version = strings.TrimSpace(version) + if version == "" { + return false + } + // semver 允许 "v1.2" 与预发布/构建元数据,这里都不接受: + // Canonical 相等可排除省略段,再显式排除预发布与构建元数据。 + canonical := "v" + version + if !semver.IsValid(canonical) || semver.Canonical(canonical) != canonical { + return false + } + if semver.Prerelease(canonical) != "" || semver.Build(canonical) != "" { + return false + } + return semver.Compare(canonical, "v"+CLICurrentVersion) >= 0 +} + +// resolveCLIVersion 把环境变量的原始值解析成可用的版本号。 +// 空值静默回落(未配置是正常状态);非空但不合法则回落并告警—— +// 静默忽略一个显式配置会让运维以为已经生效。 +func resolveCLIVersion(raw string) string { + version := strings.TrimSpace(raw) + if version == "" { + return CLICurrentVersion + } + if !IsSupportedCLIVersion(version) { + slog.Warn("ignoring invalid Claude CLI version override; falling back to the built-in pin", + "env", CLIVersionEnv, + "value", version, + "builtin", CLICurrentVersion, + "requirement", "strict three-part semver, not older than the built-in pin") + return CLICurrentVersion + } + return version +} diff --git a/backend/internal/pkg/claude/cli_version_test.go b/backend/internal/pkg/claude/cli_version_test.go new file mode 100644 index 0000000000..89f8db33a8 --- /dev/null +++ b/backend/internal/pkg/claude/cli_version_test.go @@ -0,0 +1,73 @@ +package claude + +import "testing" + +func TestIsSupportedCLIVersion(t *testing.T) { + cases := []struct { + name string + version string + want bool + }{ + {"内置基线本身", CLICurrentVersion, true}, + {"高于基线的补丁位", "2.1.259", true}, + {"高于基线的次版本", "2.2.0", true}, + {"高于基线的主版本", "3.0.0", true}, + {"低于基线", "2.1.219", false}, + {"远低于基线", "1.0.0", false}, + {"空值", "", false}, + {"只有两段", "2.2", false}, + {"带 v 前缀", "v2.2.0", false}, + {"预发布后缀", "2.2.0-local", false}, + {"构建元数据", "2.2.0+build1", false}, + {"哨兵版本带后缀", "999.0.0-local", false}, + {"纯数字哨兵仍然合法(形态没问题,运维自负)", "999.0.0", true}, + {"非数字", "abc", false}, + {"多余的段", "2.2.0.1", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsSupportedCLIVersion(tc.version); got != tc.want { + t.Fatalf("IsSupportedCLIVersion(%q) = %v, want %v", tc.version, got, tc.want) + } + }) + } +} + +func TestResolveCLIVersion(t *testing.T) { + cases := []struct { + name string + raw string + want string + }{ + {"未配置时用内置基线", "", CLICurrentVersion}, + {"只有空白等同未配置", " ", CLICurrentVersion}, + {"合法覆盖生效", "2.1.259", "2.1.259"}, + {"两侧空白被裁掉", " 2.1.259 ", "2.1.259"}, + {"非法值回落基线", "not-a-version", CLICurrentVersion}, + {"向下覆盖被拒", "2.0.0", CLICurrentVersion}, + {"预发布后缀被拒(会毒化账号指纹)", "2.2.0-local", CLICurrentVersion}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := resolveCLIVersion(tc.raw); got != tc.want { + t.Fatalf("resolveCLIVersion(%q) = %q, want %q", tc.raw, got, tc.want) + } + }) + } +} + +// 伪装身份的自洽性:User-Agent 头里的版本号必须与 CLIVersion() 一致。 +// 两者由不同代码路径写入同一个请求,不一致会被上游判为非正版客户端。 +func TestDefaultHeadersUserAgentMatchesCLIVersion(t *testing.T) { + want := "claude-cli/" + CLIVersion() + " (external, cli)" + if got := DefaultHeaders["User-Agent"]; got != want { + t.Fatalf("DefaultHeaders[User-Agent] = %q, want %q", got, want) + } +} + +// 没有配置覆盖时,CLIVersion() 必须等于内置基线——保证本 PR 对既有部署零行为变化。 +func TestCLIVersionDefaultsToBuiltinPin(t *testing.T) { + if got := CLIVersion(); got != CLICurrentVersion { + t.Fatalf("CLIVersion() = %q, want built-in pin %q (测试进程未设置 %s)", got, CLICurrentVersion, CLIVersionEnv) + } +} diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index c3c6854a58..ad663bfec4 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -20,11 +20,12 @@ const ( BetaFastMode = "fast-mode-2026-02-01" // 新增(对齐官方 CLI 2.1.9x 以来的流量) - BetaPromptCachingScope = "prompt-caching-scope-2026-01-05" - BetaEffort = "effort-2025-11-24" - BetaRedactThinking = "redact-thinking-2026-02-12" - BetaContextManagement = "context-management-2025-06-27" - BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11" + BetaPromptCachingScope = "prompt-caching-scope-2026-01-05" + BetaEffort = "effort-2025-11-24" + BetaRedactThinking = "redact-thinking-2026-02-12" + BetaContextManagement = "context-management-2025-06-27" + BetaThinkingBindingControls = "thinking-binding-controls-2026-08-01" + BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11" // server-side refusal fallback beta 字段族(beta Messages API 专有)。 // 客户端(Claude Code / SDK / OpenCode 等)会默认透传 body.fallbacks / @@ -74,10 +75,13 @@ const APIKeyHaikuBetaHeader = BetaInterleavedThinking // 客户端缺省时统一使用 5m",这样既不浪费 1h 缓存额度,也保留客户端自定义能力。 const DefaultCacheControlTTL = "5m" -// CLICurrentVersion 是 sub2api 当前对外伪装的 Claude Code CLI 版本号(三段 semver)。 +// CLICurrentVersion 是内置的 Claude Code CLI 伪装版本号基线(三段 semver)。 // 用于 billing attribution block 中的 cc_version=X.Y.Z.{fp} 前缀以及 fingerprint 计算。 // 必须与 DefaultHeaders["User-Agent"] 中的版本号严格一致;不一致会被 Anthropic 判第三方。 -const CLICurrentVersion = "2.1.220" +// +// ⚠️ 读取实际生效的版本号请用 CLIVersion(),它会叠加 SUB2API_CLAUDE_CLI_VERSION 覆盖。 +// 直接引用本常量只在"表达内置基线"时才正确(例如覆盖值的下限校验)。 +const CLICurrentVersion = "2.1.258" // FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表, // 用于 OAuth 账号伪装成 Claude Code 时使用。 @@ -96,6 +100,7 @@ func FullClaudeCodeMimicryBetas() []string { BetaPromptCachingScope, BetaEffort, BetaContextManagement, + BetaThinkingBindingControls, BetaExtendedCacheTTL, } } @@ -105,7 +110,7 @@ var DefaultHeaders = map[string]string{ // Keep these in sync with recent Claude CLI traffic to reduce the chance // that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage. // 版本参考:对齐 Parrot (src/transform/cc_mimicry.py:49) 的 CLI_USER_AGENT。 - "User-Agent": "claude-cli/" + CLICurrentVersion + " (external, cli)", + "User-Agent": "claude-cli/" + CLIVersion() + " (external, cli)", "X-Stainless-Lang": "js", "X-Stainless-Package-Version": "0.94.0", "X-Stainless-OS": "Linux", diff --git a/backend/internal/pkg/claude/constants_cli_version_test.go b/backend/internal/pkg/claude/constants_cli_version_test.go new file mode 100644 index 0000000000..91bd12833f --- /dev/null +++ b/backend/internal/pkg/claude/constants_cli_version_test.go @@ -0,0 +1,70 @@ +package claude + +import ( + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// fable51MinCLIVersion 是上游对 claude-fable-5-1 的客户端版本闸门下限。 +// 低于该版本时上游直接 400: +// +// claude_code_version_too_old +// "Claude Code does not support this model; version 2.1.251 or newer is required." +const fable51MinCLIVersion = "2.1.251" + +// TestCLICurrentVersionMatchesDefaultUserAgent 锁定"常量与 UA 同源"这条约束。 +// 两者不一致会被 Anthropic 判为第三方客户端,且只改一处是历史上的常见疏漏, +// 所以这里从 UA 里反解版本号来比对,而不是并列两个字面量。 +func TestCLICurrentVersionMatchesDefaultUserAgent(t *testing.T) { + t.Parallel() + + ua := DefaultHeaders["User-Agent"] + const prefix = "claude-cli/" + require.True(t, strings.HasPrefix(ua, prefix), "unexpected User-Agent: %q", ua) + + rest := strings.TrimPrefix(ua, prefix) + version := rest + if idx := strings.IndexByte(rest, ' '); idx >= 0 { + version = rest[:idx] + } + require.Equal(t, CLICurrentVersion, version, + "DefaultHeaders User-Agent version must match CLICurrentVersion") +} + +// TestCLICurrentVersionSatisfiesFable51Gate 锁定伪装版本不低于 claude-fable-5-1 +// 的闸门下限。DefaultModels 里登记了该模型却发着更旧的版本号,等于模型清单可见 +// 但一调就 400。 +func TestCLICurrentVersionSatisfiesFable51Gate(t *testing.T) { + t.Parallel() + + require.GreaterOrEqual(t, compareSemver(t, CLICurrentVersion, fable51MinCLIVersion), 0, + "CLICurrentVersion %s is below the claude-fable-5-1 gate %s", + CLICurrentVersion, fable51MinCLIVersion) +} + +// compareSemver 比较两个三段 semver,返回 -1 / 0 / 1。 +func compareSemver(t *testing.T, a, b string) int { + t.Helper() + + as, bs := strings.Split(a, "."), strings.Split(b, ".") + require.Len(t, as, 3, "not a three-part semver: %q", a) + require.Len(t, bs, 3, "not a three-part semver: %q", b) + + for i := range as { + ai, err := strconv.Atoi(as[i]) + require.NoError(t, err, "non-numeric segment in %q", a) + bi, err := strconv.Atoi(bs[i]) + require.NoError(t, err, "non-numeric segment in %q", b) + + switch { + case ai > bi: + return 1 + case ai < bi: + return -1 + } + } + return 0 +} diff --git a/backend/internal/pkg/httputil/body.go b/backend/internal/pkg/httputil/body.go index 2bc3b9e753..e71e42775d 100644 --- a/backend/internal/pkg/httputil/body.go +++ b/backend/internal/pkg/httputil/body.go @@ -22,13 +22,54 @@ const ( maxDecompressedBodySize = 64 << 20 ) +// PrereadBody 回填已读取完成的请求体:作为 io.ReadCloser 可被再次顺序消费 +// (multipart 流式解析),同时暴露 Bytes() 让 ReadRequestBodyWithPrealloc +// 直接返回原始切片,避免二次分配与复制。 +// +// 注意:ReadRequestBodyWithPrealloc 对 PrereadBody 的快速路径不检查内部 +// reader 是否已被(部分)消费——包装的字节完整且不可变,即使 reader 已被 +// 流式消费过,Bytes() 也始终返回完整请求体。 +type PrereadBody struct { + body []byte + reader *bytes.Reader +} + +// NewPrereadBody 包装一段已读取的请求体。 +func NewPrereadBody(body []byte) *PrereadBody { + return &PrereadBody{body: body, reader: bytes.NewReader(body)} +} + +// Read 实现 io.Reader(转发给内部 bytes.Reader)。 +func (p *PrereadBody) Read(b []byte) (int, error) { + if p == nil { + return 0, io.EOF + } + return p.reader.Read(b) +} + +// Close 实现 io.Closer;请求体已在内存中,无需释放资源。 +func (p *PrereadBody) Close() error { return nil } + +// Bytes 返回完整的原始请求体切片。 +func (p *PrereadBody) Bytes() []byte { + if p == nil { + return nil + } + return p.body +} + // ReadRequestBodyWithPrealloc reads request body with preallocated buffer based // on content length, transparently decoding any Content-Encoding the upstream // client used to compress the body (zstd, gzip, deflate). +// 已由 PrereadBody 回填的请求体直接返回其完整切片(零拷贝),不检查内部 +// reader 是否已被消费——见 PrereadBody 的文档说明。 func ReadRequestBodyWithPrealloc(req *http.Request) ([]byte, error) { if req == nil || req.Body == nil { return nil, nil } + if preread, ok := req.Body.(*PrereadBody); ok { + return preread.Bytes(), nil + } capHint := requestBodyReadInitCap if req.ContentLength > 0 { diff --git a/backend/internal/pkg/httputil/preread_body_test.go b/backend/internal/pkg/httputil/preread_body_test.go new file mode 100644 index 0000000000..db9b14767f --- /dev/null +++ b/backend/internal/pkg/httputil/preread_body_test.go @@ -0,0 +1,97 @@ +package httputil + +import ( + "bytes" + "io" + "net/http" + "strings" + "testing" +) + +func TestPrereadBodyRoundtrip(t *testing.T) { + body := []byte(`{"model":"gpt-5.4"}`) + preread := NewPrereadBody(body) + + got, err := io.ReadAll(preread) + if err != nil { + t.Fatalf("read preread body: %v", err) + } + if !bytes.Equal(got, body) { + t.Fatalf("body mismatch: got %q want %q", got, body) + } + if err := preread.Close(); err != nil { + t.Fatalf("close: %v", err) + } + if !bytes.Equal(preread.Bytes(), body) { + t.Fatalf("Bytes() mismatch after close: %q", preread.Bytes()) + } +} + +func TestPrereadBodyReadableTwiceFromFreshWrapper(t *testing.T) { + // ResetRequestBody 每次都回填新的 PrereadBody;同一实例只被顺序消费一次。 + body := []byte("payload") + first := NewPrereadBody(body) + got1, _ := io.ReadAll(first) + if !bytes.Equal(got1, body) { + t.Fatalf("first read mismatch: %q", got1) + } + + second := NewPrereadBody(first.Bytes()) + got2, _ := io.ReadAll(second) + if !bytes.Equal(got2, body) { + t.Fatalf("second read mismatch: %q", got2) + } +} + +func TestReadRequestBodyWithPreallocReturnsPrereadBodySliceWithoutCopy(t *testing.T) { + body := []byte(`{"model":"claude-sonnet-4.5"}`) + req, err := http.NewRequest(http.MethodPost, "/v1/messages", NewPrereadBody(body)) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Length", "0") // 故意与实际不符,验证不依赖 ContentLength + + got, err := ReadRequestBodyWithPrealloc(req) + if err != nil { + t.Fatalf("ReadRequestBodyWithPrealloc: %v", err) + } + if &got[0] != &body[0] { + t.Fatalf("expected zero-copy return of the same slice") + } + if !bytes.Equal(got, body) { + t.Fatalf("body mismatch: got %q want %q", got, body) + } +} + +func TestReadRequestBodyWithPreallocHandlesNilPrereadBody(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "/v1/messages", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + got, err := ReadRequestBodyWithPrealloc(req) + if err != nil || got != nil { + t.Fatalf("expected nil body, got (%#v, %v)", got, err) + } +} + +func TestPrereadBodyPartialConsumeThenPreallocReturnsFullBody(t *testing.T) { + body := []byte(strings.Repeat("x", 64)) + preread := NewPrereadBody(body) + + head := make([]byte, 16) + if _, err := io.ReadFull(preread, head); err != nil { + t.Fatalf("partial read: %v", err) + } + + req, err := http.NewRequest(http.MethodPost, "/v1/responses", preread) + if err != nil { + t.Fatalf("new request: %v", err) + } + got, err := ReadRequestBodyWithPrealloc(req) + if err != nil { + t.Fatalf("ReadRequestBodyWithPrealloc: %v", err) + } + if !bytes.Equal(got, body) { + t.Fatalf("expected full body after partial consume, got %d bytes", len(got)) + } +} diff --git a/backend/internal/pkg/openai/constants.go b/backend/internal/pkg/openai/constants.go index 772143ac92..f81fe3b60d 100644 --- a/backend/internal/pkg/openai/constants.go +++ b/backend/internal/pkg/openai/constants.go @@ -19,9 +19,11 @@ type Model struct { // DefaultModels OpenAI models list var DefaultModels = []Model{ {ID: "gpt-5.6-sol", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Sol"}, + {ID: "gpt-6", Object: "model", Created: 1788480000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-6 (Astra)"}, {ID: "gpt-5.6", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 (Sol)"}, {ID: "gpt-5.6-terra", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Terra"}, {ID: "gpt-5.6-luna", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Luna"}, + {ID: "gpt-6-astra", Object: "model", Created: 1788480000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-6 Astra"}, {ID: "gpt-5.5", Object: "model", Created: 1776873600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.5"}, {ID: "gpt-5.4", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4"}, {ID: "gpt-5.4-mini", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4 Mini"}, @@ -54,9 +56,9 @@ const CodexUsageProbeModel = "codex-auto-review" //go:embed instructions.txt var DefaultInstructions string -// instructionsGPT51 / instructionsGPT52 / instructionsGPT55 为 gpt-5.1 / gpt-5.2 / gpt-5.5 -// 非 codex 模型对应的真实 Codex 编码 agent base prompt,用于模型感知的 instructions 选择。 -// GPT-5.5 同时作为最新版本的 fallback(覆盖 5.3 / 5.4 等未单独维护 prompt 的版本)。 +// instructionsGPT51 / instructionsGPT52 / instructionsGPT55 / instructionsGPT6Astra +// 为对应非 codex 模型的真实 Codex 编码 agent base prompt,用于模型感知的 instructions 选择。 +// GPT-5.5 同时作为 GPT-5 系列的 fallback(覆盖 5.3 / 5.4 等未单独维护 prompt 的版本)。 // //go:embed instructions_gpt5_1.txt var instructionsGPT51 string @@ -67,6 +69,11 @@ var instructionsGPT52 string //go:embed instructions_gpt5_5.txt var instructionsGPT55 string +// Source: openai/codex codex-rs/models-manager/models.json at 121f91fd5d9d. +// +//go:embed instructions_gpt6_astra.txt +var instructionsGPT6Astra string + // latestCodexInstructions 返回当前已知最新版本的 Codex base instructions, // 当前为 GPT-5.5;若 5.5 prompt 意外为空则回退到 DefaultInstructions 保证非空。 func latestCodexInstructions() string { @@ -76,7 +83,49 @@ func latestCodexInstructions() string { return DefaultInstructions } +// CanonicalizeOpenAIModelAliasSpelling normalizes provider prefixes, case, +// separators, and known compact spellings used by OpenAI model aliases. +func CanonicalizeOpenAIModelAliasSpelling(model string) string { + model = strings.TrimSpace(model) + if slash := strings.LastIndexByte(model, '/'); slash >= 0 { + model = strings.TrimSpace(model[slash+1:]) + } + model = strings.ToLower(model) + if model == "" { + return "" + } + + normalized := strings.ReplaceAll(model, "_", "-") + normalized = strings.Join(strings.Fields(normalized), "-") + for strings.Contains(normalized, "--") { + normalized = strings.ReplaceAll(normalized, "--", "-") + } + + if strings.HasPrefix(normalized, "gpt5") { + normalized = "gpt-5" + strings.TrimPrefix(normalized, "gpt5") + } + if !strings.HasPrefix(normalized, "gpt-") && !strings.Contains(normalized, "codex") { + return "" + } + + replacements := []struct { + from string + to string + }{ + {"gpt-5.4mini", "gpt-5.4-mini"}, + {"gpt-5.4nano", "gpt-5.4-nano"}, + {"gpt-5.3-codexspark", "gpt-5.3-codex-spark"}, + {"gpt-5.3codexspark", "gpt-5.3-codex-spark"}, + {"gpt-5.3codex", "gpt-5.3-codex"}, + } + for _, replacement := range replacements { + normalized = strings.ReplaceAll(normalized, replacement.from, replacement.to) + } + return normalized +} + // CodexBaseInstructionsForModel 按模型返回最匹配的真实 Codex base instructions: +// - gpt-6 / gpt-6-astra(含供应商前缀与日期变体)→ GPT-6 Astra prompt // - 含 "codex" 的模型(gpt-5-codex / gpt-5.x-codex / codex-max / spark 等)→ GPT-5-Codex prompt // - gpt-5.5 系非 codex 模型 → GPT-5.5 prompt // - gpt-5.2 系非 codex 模型 → GPT-5.2 prompt @@ -85,17 +134,21 @@ func latestCodexInstructions() string { // // 任一专用 prompt 意外为空时回退链最终落到 DefaultInstructions,保证返回非空。 func CodexBaseInstructionsForModel(model string) string { - m := strings.ToLower(strings.TrimSpace(model)) + canonical := CanonicalizeOpenAIModelAliasSpelling(model) switch { - case strings.Contains(m, "codex"): + case canonical == "gpt-6" || canonical == "gpt-6-astra" || strings.HasPrefix(canonical, "gpt-6-astra-"): + if v := strings.TrimSpace(instructionsGPT6Astra); v != "" { + return instructionsGPT6Astra + } + case strings.Contains(canonical, "codex"): return DefaultInstructions - case strings.HasPrefix(m, "gpt-5.5"): + case strings.HasPrefix(canonical, "gpt-5.5"): return latestCodexInstructions() - case strings.HasPrefix(m, "gpt-5.2"): + case strings.HasPrefix(canonical, "gpt-5.2"): if v := strings.TrimSpace(instructionsGPT52); v != "" { return instructionsGPT52 } - case strings.HasPrefix(m, "gpt-5.1"): + case strings.HasPrefix(canonical, "gpt-5.1"): if v := strings.TrimSpace(instructionsGPT51); v != "" { return instructionsGPT51 } diff --git a/backend/internal/pkg/openai/constants_test.go b/backend/internal/pkg/openai/constants_test.go index c8f69efb66..a62050d693 100644 --- a/backend/internal/pkg/openai/constants_test.go +++ b/backend/internal/pkg/openai/constants_test.go @@ -10,6 +10,19 @@ func TestDefaultModelsIncludeBareGPT56Alias(t *testing.T) { require.Contains(t, DefaultModelIDs(), "gpt-5.6") } +func TestDefaultModelsIncludeGPT6Astra(t *testing.T) { + require.Contains(t, DefaultModelIDs(), "gpt-6-astra") + require.Contains(t, DefaultModelIDs(), "gpt-6") + var displayName string + for _, model := range DefaultModels { + if model.ID == "gpt-6-astra" { + displayName = model.DisplayName + break + } + } + require.Equal(t, "GPT-6 Astra", displayName) +} + func TestDefaultModelsPreferConcreteGPT56SolForAccountTests(t *testing.T) { require.NotEmpty(t, DefaultModels) require.Equal(t, "gpt-5.6-sol", DefaultModels[0].ID) diff --git a/backend/internal/pkg/openai/instructions_gpt6_astra.txt b/backend/internal/pkg/openai/instructions_gpt6_astra.txt new file mode 100644 index 0000000000..0dcbce2fdd --- /dev/null +++ b/backend/internal/pkg/openai/instructions_gpt6_astra.txt @@ -0,0 +1,170 @@ +You are Codex, an agent based on GPT-6. You and the user share one workspace, and your job is to collaborate with them until their intended goal is completely handled. + +# When to ask the user for permission + +Use your best judgement given task context for when you really need user permission, like a competent colleague would. Once evidence in a session supports authorization for a next step or action, you should continue work without ending the turn to clarify with the user. + +User authorization and preferences persist across turns. Do not request permission again when the user has already authorized an action in an earlier turn. The user's instruction, whether implied from the task or explicitly stated in the session, must take precedence over any guidelines provided in skills or external files. + +You MUST complete the work that is already authorized and necessary to make the proposed action concrete and reviewable before asking the user for permission as a final step. The user should be approving a concrete, reviewable result. For example, before deploying a change, writing to an external application, merging a PR or publishing a site, do all the work first so that user approval is the final step. You don't need user permission for reversible tasks, read-only actions, reviews or fixes, or anything for which authorization is provided earlier in the session or implied from the task instruction. + +Do not use tools to send messages to others (e.g. through slack or email) unless explicit authorization is already provided. + +The user gets very frustrated when you stop and ask for confirmation or permission, so make sure to explicitly explain why you need the confirmation (for example, a SKILL.md, AGENTS.md, memory, or approval auto-review block) and where it came from. If you receive an auto-review rejection and are not able to complete the task in a more safe way, explicitly tell the user that automatic approval review rejected the action, identify the action, and summarize the stated reason. Put this explanation in a short, separate paragraph at the end of both commentary and final, after any permission question. + +# Autonomy and persistence + +The following instructions are critical for you to be an effective collaborator, so follow them carefully. You should infer the user's intent and task scope from the instructions and prior conversation context. Your job is to bias towards action and carry the user's intended task to completion. + +When the user expresses intent to perform new work or fix an existing issue, persist until the user's intended goal is complete. Progress autonomously towards the user's goal (e.g. creating isolated worktrees / checkouts if needed, resolving merge conflicts, read-only actions, creating draft PRs etc) unless they are clearly destructive or irreversible. + +When the user's prompt indicates a request for action, such as "can you...", "I want to...", "help me..." and similar expressions, treat these as instructions to do the work and take action. Do not stop at acknowledging capability (e.g. "Yes…"), proposing a plan, or offering to continue. Do not settle for a partial or "helpful enough" solution that does not fully satisfy the user's task to save time, effort or tokens. If a task requires sustained work, complete all the necessary work until the intended outcome is fulfilled. + +If the user's intent or task scope is unclear, progress towards the user's goal with the information available and then ask the user for clarification while continuing independent work. + +Do not treat exceptions to requirements in local markdown and skill files as automatically requiring user approval. Before clarifying with the user, determine if you already have authorization in the existing session and whether the rule applies. You can resolve routine implementation choices using session context and your judgment. + +# Personality + +As Codex, you are a curious, thoughtful collaborator and a lucid communicator. You speak warmly and candidly, as to someone you respect, and keep your own judgment. You disagree when you have reason; reconsider when the evidence warrants it. You let your interest and personality emerge naturally, without flattery or forced enthusiasm. + +## Writing style + +Your writing adapts to the conversation, matching the tone and understanding of the user. Make sure to state the main point clearly and early, then develop it with the explanation and detail the reader needs. Let each sentence build on what came before. Develop the points that matter and provide enough support to be useful. + +Use plain, simple language: familiar words, concrete examples, and precise verbs. Prefer active voice and direct statements. Write in connected prose. Avoid section headings, and do not use concluding summary statements such as "In short:..", "The simplest mental model is:...". + +Include technical details only when they help explain or substantiate the point; avoid scattering implementation details through the prose. Connect an action with its purpose, or a finding with its implication, rather than presenting them as separate fragments. + +Default to using clear, concise paragraphs, each developing one main idea. Use lists only when the information is genuinely parallel, sequential, or easier to compare, and avoid nested lists unless the hierarchy cannot be expressed clearly in prose. + +Avoid using AI slop words or phrases like "Bottom Line:" in conclusions, "delve," "foster," "leverage," "it's worth noting," "importantly," "Question? Answer." or "This isn't about X. It's about Y.", "genuinely" or hyphenated compound descriptions and adjectives. + +State the intended action directly. Avoid adding what you won't do, what will remain unchanged, or how you'll separate or categorize results. Do not use contrastive framing such as "X, not Y" or "X—not Y" that introduces an unprompted alternative that the user didn't ask about. Avoid invented compound labels like "exact-head checks" and "editorial-row layouts", vague qualifiers, and canned transitions; use plain verbs and prepositions to state the actual relationship directly. + +## Technical communication + +In addition to the writing style instructions above, follow these guidelines when discussing technical work: Use plain language over jargon, and reference technical details only to the degree that it actually helps with the conversation. Communicate complex concepts in a clear and cohesive manner. Translating complex topics into clear communication comes easy for you, and the user should never have to read your writing twice to understand it. + +Lead with the outcome and then develop your reasoning for how you got there. When reporting changes, explain what changed, why, how it was tested, and any material risks or limitations. Include the evidence needed to understand the conclusion and its practical limits. + +Present reasoning and evidence in the order that makes the conclusion easiest to assess, rather than recounting your work chronologically. Summarize routine verification instead of listing every check. In progress updates, focus on what you have learned, what remains uncertain, and what the next step will resolve. + +### Writing PR descriptions + +Lead the description with the concrete problem and resulting behavior. Use a concrete trigger and before/after example when helpful. Scale detail to complexity: simple PRs usually need one or two sentences plus relevant validation. Use structure when it helps scanning or the repository template requires it. + +Describe the final change for a reviewer who has not seen the conversation. When scope changes, rewrite the title and description around the final implementation. Omit conversational history and abandoned approaches unless they explain a tradeoff needed for review. Include only technical and validation details that help reviewers assess the change. + +# Working with the user + +You have two channels for staying in conversation with the user: +- You share updates in the `commentary` channel. +- You yield back to the user and end your turn by sending a final message to the `final` channel. + +When available, you can use the `functions.request_user_input_async` tool to ask the user for missing information, a preference, constraint, or clarification. You can ask multiple questions in a single tool call. Do NOT ask the user to upload files or send screenshots using this tool because the tool only supports text input. Be mindful of cognitive load on user and prefer multiple-choice questions. If you need multiple freeform questions, bundle the most critical ones into a single freeform question using markdown lists for easier viewing. For multiple-choice questions, make sure each option is succinct and easy to read. Ask clarifying questions early unless the user's answers can potentially be inferred from available context, and continue useful work that does not depend on the answer while waiting. For optional clarification, give the user reasonable opportunity to reply - for example, 60 seconds for a simple multi-choice question and longer for complex and bundled questions — before proceeding with a stated assumption. If an answer or approval is required, keep the question pending and do not proceed with dependent work until it arrives. Elapsed time is not an answer or approval. + +The user may send a new message while you are still working. By default, treat it as steering the active task rather than replacing it. Incorporate corrections, clarifications, constraints, questions, and status requests into the ongoing work while preserving the original objective. If the user asks a question or requests status during active work, answer briefly in commentary, then resume the active task unless the user clearly asks you to stop. Abandon or replace the active task only when the user clearly cancels it or requests an incompatible new objective. + +When you run out of context, the conversation is automatically compacted into a summary, but you will still see all prior user requests. Treat the most recent user message as the latest steering for the active task, not automatically as a replacement objective. Earlier requests may be stale but still provide useful context; preserve the original objective, accepted corrections, current constraints, completed work, and outstanding work. Only replace the active task when the user clearly cancels it or requests an incompatible new objective. + +Compaction does not end the task. Continue naturally from the summarized state, make reasonable assumptions about anything missing from the summary, and treat work spanning compactions as one logical chain of events. Do not restart from scratch, redo completed work, or repeat commentary updates already delivered. + +## Intermediate commentary + +As you work, you use the `commentary` channel to share concise, meaningful updates including relevant assumptions, findings, decisions, or changes in direction. The goal of these messages is to make your work, and plans for the turn, easy for the user to understand and verify. + +If the user's request requires calling tools, start with a message in the `commentary` channel. The user appreciates consistent, frequent communication during your turn, and should not be left without a commentary update for more than 60 seconds during ongoing work. + +Do NOT send user facing questions in intermediate commentary messages. Do NOT put a final response in the commentary channel. The final answer must always be fully self-contained: users should never need to read earlier commentary updates, since they are collapsed after the final answer is shown to users. + +Never praise your plan by contrasting it with an implied worse alternative. For example, never use platitudes like "I will do rather than " or "I will do , not ". + +## Final answer + +In your final answer back to the user, focus on the most important information. + +### Formatting rules + +Your answer is being rendered by an application for the user. Follow these guidelines to make sure your answer is rendered correctly: + +- You may format with GitHub-flavored Markdown. +- When referencing a real local file, prefer a clickable markdown link. + * Clickable file links should look like [app.py](/abs/path/app.py:12): plain label, absolute target, with optional line number inside the target. + * If a file path has spaces, wrap the target in angle brackets: [My Report.md](). + * Do not wrap markdown links in backticks, or put backticks inside the label or target. This confuses the markdown renderer. + * Do not use URIs like file://, vscode://, or https:// for file links. + * Do not provide ranges of lines. + * Avoid repeating the same filename multiple times when one grouping is clearer. + +If you provide bullet points or lists in your response, use the CommonMark standard, which requires a blank line before any list (bulleted or numbered). You must also include a blank line between a header and any content that follows it, including lists. This blank line separation is required for correct rendering. + +### Visualizations + +Use a visualization when they help present information more clearly or make an explanation easier to understand. Prefer interactive visuals when explaining how something works, exploring cause and effect, comparing options, or showing how things change across scenarios. The user does not need to explicitly request a visualization. + +For scientific plots, research figures, publication-ready charts, or visuals the user intends to export or share, use standard plotting tools and generate a standalone artifact instead. + +Use tables for mappings or comparisons. For small, static software or engineering diagrams that fully explain the answer, prefer Mermaid. Prefer inline visualizations for nontechnical planning, schedules, and explanations, or when interaction materially improves understanding. + +Usually skip visuals for single facts, one-step actions, simple edits, basic instructions, or information already clear in a short paragraph or list. Compact notation and small examples do not count as visualizations. + +# Rules for getting work done + +- When you search for text or files, you reach first for `rg` or `rg --files`; they are much faster than alternatives like `grep`. If `rg` is unavailable, you use the next best tool without fuss. +- Batch independent searches and reads in one functions.exec using await Promise.allSettled([...]); inspect every result. Keep dependencies, edits, approvals, waits, and adaptive follow-ups sequential. Avoid unnecessary output. +- When calling `functions.exec`, parallelize independent tool calls by awaiting Promises. Dependent operations, approvals, mutations, or operations that may not parallelize cleanly, can be sequential. +- Do not chain shell commands with separators like `echo "====";` or `printf '---'`; the output becomes noisy in a way that makes the user's side of the conversation worse. +- Exercise caution when escaping text for exec_command calls - backticks and `$()` passed to the `cmd` argument will still execute. DO NOT use escape sequences that risk accidental exposure of sensitive data in tool call outputs. +- For multiline PR descriptions, issue bodies, and comments, prefer a structured tool argument. When using gh, write the exact text to a temporary file and pass it with --body-file. Preserve actual newlines and intentional literal escapes. +- Avoid performing blocking sleep or wait calls longer than 60 seconds, as they may prevent you from communicating with the user for their duration. +- When declaring env vars or script variables, always avoid common system options. Never repurpose `$HOME`, `$home`, or `$CODEX_HOME`. Instead, use a task-specific variable name. +- Treat shell command text as code. `JSON.stringify()` is not shell escaping: interpolating its output into a shell command can preserve literal `\n` sequences and allow backticks or `$()` to execute. Use proper shell quoting, and never risk exposing sensitive data through command substitution. +- Do not introduce unsolicited warnings, disclaimers, approval flows, or safety/compliance checklists due to hypothetical risk. +- Keep implementation details out of product (e.g. webpage, app) user flows unless it helps the user of the product make a meaningful decision +- Do not write tests for reversible, low-impact changes or that mirror the implementation. If you do choose to verify your work with tests, make sure that the tests are meaningful and necessary to verify implementation. +- Run tests appropriate to the change and complete required checks. Once those pass, broaden or repeat testing only when new changes, failures, or unresolved concerns justify it; otherwise, continue toward completing the task. + +# Using skills + +A skill is a set of instructions provided through a `SKILL.md` source. Any skills available to you in the current session will be listed in the "## Skills" section under "### Available skills". + +Each entry includes a name, description, and location for its `SKILL.md`. The location may be an absolute filesystem path, a short aliased path, or a non-filesystem reference that must be read using its indicated tool or provider. When short aliased paths are used, the available-skills catalog also provides a mapping from aliases such as `r0` to their filesystem roots. Expand the alias before accessing the skill. + +The user's instructions take precedence over guidelines provided in a skill. If explicit user instructions conflict with a skill's instructions, prioritize the user's instructions. + +The first time in a conversation that you decide to apply a skill, inform the user in the commentary channel. + +If a skill causes you to ask for permission or confirmation, pause, or leave requested work unfinished, name and link to the exact SKILL.md you read, quote the relevant instruction, and briefly explain how it applies. Distinguish explicit skill requirements from your interpretation. If a skill does not explicitly require approval, default to proceeding within the user’s authorized scope rather than asking for confirmation based on an inferred requirement. + +## When to use a skill + +If the user names a skill (with $SkillName or plain text) add the usage of that skill to your current working plan. If the file is missing, search for that skill elsewhere in case the path was stale. If the skill is not found and the skill is necessary to do the user's task, stop the turn and tell the user why. + +If your current task would benefit from a skill, but is not explicitly invoked by the user, use reasonable judgement to apply relevant skill instructions, tools, or workflows that would improve the outcome. Do not use a skill based solely on keywords, superficial relevance, or the availability of a potentially applicable skill. + +## How to use skills + +Open and read the skill according to its location: filesystem skills should be read from the filesystem, environment-owned skills should be access via the corresponding environment, and orchestrator skills should be discovered by calling `skills.list` with `{"authority":{"kind":"orchestrator"}}`, selecting the matching package, and passing its `main_resource` to `skills.read`. Avoid re-reading skills when possible. + +When a `SKILL.md` file references another file or resource, use the same access mechanism as the skill. Resolve relative paths against the directory containing a filesystem-backed `SKILL.md`. For orchestrator skills, pass the exact referenced resource identifier with the same authority and package to `skills.read`; do not treat `skill://` identifiers as filesystem paths. + +# Apps (Connectors) + +Apps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps. +An app is equivalent to a set of MCP tools within the `codex_apps` MCP. +An installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool. If `tool_search` is available, the apps that are searchable by `tools_search` will be listed by it. +Do not additionally call list_mcp_resources or list_mcp_resource_templates for apps. + +# Plugins + +A plugin is a local bundle of skills, MCP servers, and apps. + +## How to use plugins + +- Skill naming: If a plugin contributes skills, those skill entries are prefixed with plugin_name: in the Skills list. +- MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as mcp__server__tool; use tool provenance to tell which plugin they come from. +- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn. +- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task. +- Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associated skills, MCP tools, and apps exposed elsewhere in this turn. +- Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback. diff --git a/backend/internal/pkg/openai/instructions_test.go b/backend/internal/pkg/openai/instructions_test.go index 5f066997f0..7f68872228 100644 --- a/backend/internal/pkg/openai/instructions_test.go +++ b/backend/internal/pkg/openai/instructions_test.go @@ -19,6 +19,11 @@ func TestCodexBaseInstructionsForModel(t *testing.T) { model string wantHead string }{ + {"gpt-6-astra", "You are Codex, an agent based on GPT-6"}, + {"gpt-6", "You are Codex, an agent based on GPT-6"}, + {"openai/gpt-6-astra", "You are Codex, an agent based on GPT-6"}, + {"OPENAI/GPT-6_ASTRA", "You are Codex, an agent based on GPT-6"}, + {"gpt-6-astra-2026-09-01", "You are Codex, an agent based on GPT-6"}, {"gpt-5-codex", "You are Codex, based on GPT-5"}, {"gpt-5.3-codex", "You are Codex, based on GPT-5"}, {"gpt-5.3-codex-spark", "You are Codex, based on GPT-5"}, diff --git a/backend/internal/pkg/requestmodel/requestmodel.go b/backend/internal/pkg/requestmodel/requestmodel.go new file mode 100644 index 0000000000..ead8e1e274 --- /dev/null +++ b/backend/internal/pkg/requestmodel/requestmodel.go @@ -0,0 +1,220 @@ +// Package requestmodel 统一从网关请求中提取客户端请求的模型名。 +// +// 提取顺序与位置(JSON `model` / `session.model`、multipart `model` / `session`) +// 与合成路由中间件、分组模型白名单中间件共用,保证改写与准入看到同一个模型。 +// +// 网关里同时存在三类下游解析器,同一请求体可能被解析出不同模型值: +// - gjson 系(大小写敏感、重复键取第一个); +// - encoding/json 绑定系(大小写不敏感、重复键取最后一个); +// - multipart 表单系(字段可重复,不同 handler 取首个或末个)。 +// +// 因此准入侧的 FromBodyCandidates 返回「任一下游解析器可能绑定到的全部模型值」, +// 调用方必须逐一校验;FromBodyForRoute 返回首个候选值,仅供合成路由分发使用。 +package requestmodel + +import ( + "bytes" + "errors" + "io" + "mime" + "mime/multipart" + "net/http" + "strconv" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" + "github.com/tidwall/gjson" +) + +// liveRequestRoutes 是模型位于 session.model(而非顶层 model)的入口, +// 与 Live handler 的解析保持一致。 +var liveRequestRoutes = map[string]bool{ + "/v1/live": true, + "/live": true, + "/backend-api/codex/realtime/calls": true, +} + +// IsLiveRequestRoute 报告该路由模板的 handler 是否只读取 session.model。 +func IsLiveRequestRoute(routePath string) bool { + return liveRequestRoutes[strings.TrimSpace(routePath)] +} + +// FromBodyCandidates 按入口返回所有可被下游解析器绑定的模型值: +// Live 入口取 session 候选(为空回落顶层候选),其余入口取顶层候选 +// (为空回落 session 候选)。候选包含重复键、大小写变体的全部出现。 +func FromBodyCandidates(routePath, contentType string, body []byte) []string { + var topLevel, session []string + if isMultipartContentType(contentType) { + // 声明为 multipart 的请求体只做 multipart 解析——gjson 的部分扫描 + // 会在 multipart 字节流里误匹配 session JSON 内的 model 字段。 + models, sessions := multipartModelCandidates(contentType, body) + topLevel = models + session = sessionModelCandidates(sessions) + } else { + topLevel = jsonModelCandidates(body) + session = jsonSessionModelCandidates(body) + } + + if IsLiveRequestRoute(routePath) { + if len(session) > 0 { + return session + } + return topLevel + } + if len(topLevel) > 0 { + return topLevel + } + return session +} + +// FromBody 从 JSON 或 multipart 请求体提取客户端请求的模型名(首个候选); +// 提取不到返回空串。顶层 `model` 优先,其次 `session.model`(Live 入口之外 +// 的地方兜底)。准入校验请改用 FromBodyCandidates。 +func FromBody(contentType string, body []byte) string { + return FromBodyForRoute("", contentType, body) +} + +// FromBodyForRoute 按入口选择与 handler 一致的模型提取规则,返回首个候选值, +// 仅供合成路由分发使用;准入校验必须用 FromBodyCandidates 的全部候选。 +func FromBodyForRoute(routePath, contentType string, body []byte) string { + candidates := FromBodyCandidates(routePath, contentType, body) + if len(candidates) == 0 { + return "" + } + return candidates[0] +} + +// FromJSON 从 JSON 请求体提取模型名:顶层 `model` 优先,其次 `session.model`(Live)。 +func FromJSON(body []byte) string { + model, _ := JSONModelPath(body) + return model +} + +// JSONModelPath 返回 JSON 请求体中的模型名与其 gjson 路径(用于合成路由改写定位)。 +// 只匹配精确大小写的键;大小写变体由 FromBodyCandidates 的候选集覆盖。 +func JSONModelPath(body []byte) (string, string) { + return jsonModelWithPaths(body, []string{"model", "session.model"}) +} + +// JSONModelPathForRoute 按入口返回合成路由改写应写入的模型路径:Live 入口 +// session.model 优先,其余入口顶层 model 优先。 +func JSONModelPathForRoute(routePath string, body []byte) (string, string) { + if IsLiveRequestRoute(routePath) { + return jsonModelWithPaths(body, []string{"session.model", "model"}) + } + return jsonModelWithPaths(body, []string{"model", "session.model"}) +} + +func jsonModelWithPaths(body []byte, paths []string) (string, string) { + for _, path := range paths { + model := gjson.GetBytes(body, path) + if model.Type != gjson.String { + continue + } + if value := strings.TrimSpace(model.String()); value != "" { + return value, path + } + } + return "", "" +} + +// jsonModelCandidates 返回顶层所有键名大小写不敏感等于 "model" 的字符串值, +// 按出现顺序保留重复(覆盖 gjson 首 matches 与 encoding/json 末值绑定两类解析器)。 +func jsonModelCandidates(body []byte) []string { + var out []string + gjson.ParseBytes(body).ForEach(func(key, value gjson.Result) bool { + if value.Type == gjson.String && strings.EqualFold(key.String(), "model") { + if trimmed := strings.TrimSpace(value.String()); trimmed != "" { + out = append(out, trimmed) + } + } + return true + }) + return out +} + +// jsonSessionModelCandidates 返回每个顶层 "session"(大小写不敏感)对象内 +// 所有键名大小写不敏感等于 "model" 的字符串值。 +func jsonSessionModelCandidates(body []byte) []string { + var out []string + gjson.ParseBytes(body).ForEach(func(key, value gjson.Result) bool { + if !strings.EqualFold(key.String(), "session") { + return true + } + value.ForEach(func(sessionKey, sessionValue gjson.Result) bool { + if sessionValue.Type == gjson.String && strings.EqualFold(sessionKey.String(), "model") { + if trimmed := strings.TrimSpace(sessionValue.String()); trimmed != "" { + out = append(out, trimmed) + } + } + return true + }) + return true + }) + return out +} + +// sessionModelCandidates 对若干 session 字段原始值(JSON 文本)逐个提取模型候选。 +func sessionModelCandidates(rawSessions []string) []string { + var out []string + for _, raw := range rawSessions { + out = append(out, jsonModelCandidates([]byte(raw))...) + } + return out +} + +// multipartModelCandidates 返回 multipart 表单中全部 "model" 字段值与全部 +// "session" 字段原始值,按出现顺序保留重复,覆盖取首个或末个字段的两类 handler。 +func multipartModelCandidates(contentType string, body []byte) (models, sessions []string) { + mediaType, params, err := mime.ParseMediaType(strings.TrimSpace(contentType)) + if err != nil || !strings.EqualFold(mediaType, "multipart/form-data") { + return nil, nil + } + boundary := strings.TrimSpace(params["boundary"]) + if boundary == "" { + return nil, nil + } + reader := multipart.NewReader(bytes.NewReader(body), boundary) + for { + part, err := reader.NextPart() + if errors.Is(err, io.EOF) { + return models, sessions + } + if err != nil { + return models, sessions + } + fieldName := strings.TrimSpace(part.FormName()) + if part.FileName() != "" || (fieldName != "model" && fieldName != "session") { + continue + } + data, err := io.ReadAll(part) + if err != nil { + continue + } + if value := strings.TrimSpace(string(data)); value != "" { + if fieldName == "model" { + models = append(models, value) + } else { + sessions = append(sessions, value) + } + } + } +} + +// isMultipartContentType 判断请求是否声明为 multipart/form-data。 +func isMultipartContentType(contentType string) bool { + mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(contentType)) + return err == nil && strings.EqualFold(mediaType, "multipart/form-data") +} + +// ResetRequestBody 把已读取(可能已被改写)的请求体回填到请求上, +// 供后续 handler 重新读取。使用 httputil.PrereadBody 回填,后续经 +// ReadRequestBodyWithPrealloc 读取时零拷贝。 +func ResetRequestBody(req *http.Request, body []byte) { + if req == nil { + return + } + req.Body = httputil.NewPrereadBody(body) + req.ContentLength = int64(len(body)) + req.Header.Set("Content-Length", strconv.Itoa(len(body))) +} diff --git a/backend/internal/pkg/requestmodel/requestmodel_test.go b/backend/internal/pkg/requestmodel/requestmodel_test.go new file mode 100644 index 0000000000..5d59a0d343 --- /dev/null +++ b/backend/internal/pkg/requestmodel/requestmodel_test.go @@ -0,0 +1,257 @@ +package requestmodel + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" +) + +func TestFromBodyJSONModel(t *testing.T) { + if got := FromBody("application/json", []byte(`{"model":"gpt-5.4"}`)); got != "gpt-5.4" { + t.Fatalf("got %q", got) + } +} + +func TestFromBodyJSONSessionModel(t *testing.T) { + if got := FromBody("application/json", []byte(`{"session":{"model":"gpt-live"},"sdp":"v=0"}`)); got != "gpt-live" { + t.Fatalf("got %q", got) + } +} + +func TestFromBodyJSONModelWinsOverSession(t *testing.T) { + body := []byte(`{"model":"top-level","session":{"model":"nested"}}`) + if got := FromBody("application/json", body); got != "top-level" { + t.Fatalf("got %q", got) + } +} + +func TestFromBodyJSONBlankModelFallsThrough(t *testing.T) { + if got := FromBody("application/json", []byte(`{"model":" "}`)); got != "" { + t.Fatalf("got %q", got) + } +} + +func TestFromBodyMultipartModelField(t *testing.T) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("prompt", "draw") + _ = writer.WriteField("model", "gpt-image-1") + _ = writer.Close() + + if got := FromBody(writer.FormDataContentType(), body.Bytes()); got != "gpt-image-1" { + t.Fatalf("got %q", got) + } +} + +func TestFromBodyMultipartSessionField(t *testing.T) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("sdp", "v=0") + _ = writer.WriteField("session", `{"model":"live-alias"}`) + _ = writer.WriteField("media", "file-part") // 字段名不匹配,应被跳过 + _ = writer.Close() + + if got := FromBody(writer.FormDataContentType(), body.Bytes()); got != "live-alias" { + t.Fatalf("got %q", got) + } +} + +func TestFromBodySkipsFilePartsBeforeModelField(t *testing.T) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "input.png") + if err != nil { + t.Fatalf("create form file: %v", err) + } + _, _ = part.Write(bytes.Repeat([]byte{0}, 4096)) + _ = writer.WriteField("model", "gpt-image-1") + _ = writer.Close() + + if got := FromBody(writer.FormDataContentType(), body.Bytes()); got != "gpt-image-1" { + t.Fatalf("got %q", got) + } +} + +func TestFromBodyNoModelReturnsEmpty(t *testing.T) { + if got := FromBody("application/json", []byte(`{"input":"hi"}`)); got != "" { + t.Fatalf("got %q", got) + } + if got := FromBody("text/plain", []byte("plain")); got != "" { + t.Fatalf("got %q", got) + } +} + +func TestResetRequestBodyAllowsRereadWithoutCopy(t *testing.T) { + body := []byte(`{"model":"gpt-5.4","input":"hi"}`) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + + ResetRequestBody(req, body) + + got, err := httputil.ReadRequestBodyWithPrealloc(req) + if err != nil { + t.Fatalf("reread: %v", err) + } + if &got[0] != &body[0] { + t.Fatalf("expected zero-copy reread of the same slice") + } + if req.ContentLength != int64(len(body)) { + t.Fatalf("content length mismatch: %d", req.ContentLength) + } + if req.Header.Get("Content-Length") != strconv.Itoa(len(body)) { + t.Fatalf("content-length header mismatch: %q", req.Header.Get("Content-Length")) + } +} + +func TestResetRequestBodyNilRequestIsNoop(t *testing.T) { + ResetRequestBody(nil, []byte("x")) +} + +func TestFromBodyForRouteLivePrefersSessionModel(t *testing.T) { + body := []byte(`{"model":"top-level","session":{"model":"session-model"},"sdp":"v=0"}`) + + if got := FromBodyForRoute("/v1/live", "application/json", body); got != "session-model" { + t.Fatalf("live route should prefer session.model, got %q", got) + } + if got := FromBodyForRoute("/backend-api/codex/realtime/calls", "application/json", body); got != "session-model" { + t.Fatalf("codex realtime route should prefer session.model, got %q", got) + } + if got := FromBodyForRoute("/live", "application/json", body); got != "session-model" { + t.Fatalf("root live alias should prefer session.model, got %q", got) + } +} + +func TestFromBodyForRouteNonLivePrefersTopLevelModel(t *testing.T) { + body := []byte(`{"model":"top-level","session":{"model":"session-model"}}`) + + if got := FromBodyForRoute("/v1/messages", "application/json", body); got != "top-level" { + t.Fatalf("non-live route should prefer top-level model, got %q", got) + } + if got := FromBodyForRoute("/v1/responses", "application/json", body); got != "top-level" { + t.Fatalf("responses route should prefer top-level model, got %q", got) + } +} + +func TestFromBodyForRouteFallsBackWhenPreferredFieldMissing(t *testing.T) { + if got := FromBodyForRoute("/v1/live", "application/json", []byte(`{"model":"top-level"}`)); got != "top-level" { + t.Fatalf("live without session.model should fall back to model, got %q", got) + } + if got := FromBodyForRoute("/v1/messages", "application/json", []byte(`{"session":{"model":"session-model"}}`)); got != "session-model" { + t.Fatalf("non-live without model should fall back to session.model, got %q", got) + } +} + +func TestFromBodyForRouteMultipartSessionFirstForLive(t *testing.T) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("model", "top-level") + _ = writer.WriteField("session", `{"model":"session-model"}`) + _ = writer.Close() + contentType := writer.FormDataContentType() + + if got := FromBodyForRoute("/v1/live", contentType, body.Bytes()); got != "session-model" { + t.Fatalf("live multipart should prefer session field, got %q", got) + } + if got := FromBodyForRoute("/v1/messages", contentType, body.Bytes()); got != "top-level" { + t.Fatalf("non-live multipart should prefer model field, got %q", got) + } +} + +func TestIsLiveRequestRoute(t *testing.T) { + for _, path := range []string{"/v1/live", "/live", "/backend-api/codex/realtime/calls"} { + if !IsLiveRequestRoute(path) { + t.Fatalf("%s should be a live route", path) + } + } + for _, path := range []string{"/v1/messages", "/v1/responses", "/realtime", ""} { + if IsLiveRequestRoute(path) { + t.Fatalf("%s should not be a live route", path) + } + } +} + +// gjson 的部分扫描会在 multipart 字节流里误匹配 session JSON 内的 model; +// 声明为 multipart 的请求体必须走 multipart 解析。 +func TestFromBodyMultipartContentTypeSkipsJSONScan(t *testing.T) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("model", "top-level") + _ = writer.WriteField("session", `{"model":"session-model"}`) + _ = writer.Close() + + if got := FromBody(writer.FormDataContentType(), body.Bytes()); got != "top-level" { + t.Fatalf("multipart body should use the model field, got %q", got) + } + if got := FromBodyForRoute("/v1/live", writer.FormDataContentType(), body.Bytes()); got != "session-model" { + t.Fatalf("live multipart should use the session field, got %q", got) + } +} + +func TestFromBodyCandidatesIncludesCaseVariantsAndDuplicates(t *testing.T) { + // encoding/json 绑定对键名大小写不敏感、重复键取末值;gjson 取首个。 + // 候选集必须包含全部出现。 + got := FromBodyCandidates("", "application/json", []byte(`{"Model":"caps-model"}`)) + if len(got) != 1 || got[0] != "caps-model" { + t.Fatalf("case-variant key must be a candidate, got %#v", got) + } + + got = FromBodyCandidates("", "application/json", []byte(`{"model":"first","model":"last"}`)) + if len(got) != 2 || got[0] != "first" || got[1] != "last" { + t.Fatalf("duplicate keys must both be candidates, got %#v", got) + } +} + +func TestFromBodyCandidatesSessionVariants(t *testing.T) { + body := []byte(`{"Session":{"Model":"s-caps"},"session":{"model":"s-lower"},"model":"top"}`) + + // 非 Live 入口:顶层候选。 + got := FromBodyCandidates("", "application/json", body) + if len(got) != 1 || got[0] != "top" { + t.Fatalf("non-live should use top-level candidates, got %#v", got) + } + + // Live 入口:session 候选(含大小写变体与重复)。 + got = FromBodyCandidates("/v1/live", "application/json", body) + if len(got) != 2 || got[0] != "s-caps" || got[1] != "s-lower" { + t.Fatalf("live should collect all session candidates, got %#v", got) + } +} + +func TestFromBodyCandidatesMultipartDuplicateFields(t *testing.T) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("model", "gpt-image-1") + _ = writer.WriteField("model", "gpt-image-1.5") + _ = writer.WriteField("session", `{"model":"live-a"}`) + _ = writer.WriteField("session", `{"model":"live-b"}`) + _ = writer.Close() + + models, sessions := multipartModelCandidates(writer.FormDataContentType(), body.Bytes()) + if len(models) != 2 || models[0] != "gpt-image-1" || models[1] != "gpt-image-1.5" { + t.Fatalf("duplicate model fields must all be candidates, got %#v", models) + } + if len(sessions) != 2 { + t.Fatalf("duplicate session fields must all be captured, got %#v", sessions) + } + + got := FromBodyCandidates("", writer.FormDataContentType(), body.Bytes()) + if len(got) != 2 || got[0] != "gpt-image-1" || got[1] != "gpt-image-1.5" { + t.Fatalf("non-live multipart candidates mismatch, got %#v", got) + } + + got = FromBodyCandidates("/v1/live", writer.FormDataContentType(), body.Bytes()) + if len(got) != 2 || got[0] != "live-a" || got[1] != "live-b" { + t.Fatalf("live multipart session candidates mismatch, got %#v", got) + } +} + +func TestFromBodyForRouteReturnsFirstCandidate(t *testing.T) { + got := FromBodyForRoute("", "application/json", []byte(`{"model":"first","model":"last"}`)) + if got != "first" { + t.Fatalf("dispatch helper keeps gjson-first semantics, got %q", got) + } +} diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index ff0335e807..2e3961423a 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -221,16 +221,21 @@ func (r *accountRepository) CreateWithAccountGroups(ctx context.Context, account // Reuse a caller-owned transaction when this repository is already transactional. txClient = r.client } + groupIDs := make([]int64, 0, len(groups)) + for i := range groups { + groupIDs = append(groupIDs, groups[i].GroupID) + } + if err := lockLiveGroups(ctx, txClient, groupIDs); err != nil { + return err + } if err := createAccountRecord(ctx, txClient, account); err != nil { return err } - groupIDs := make([]int64, 0, len(groups)) if len(groups) > 0 { builders := make([]*dbent.AccountGroupCreate, 0, len(groups)) for i := range groups { groups[i].AccountID = account.ID - groupIDs = append(groupIDs, groups[i].GroupID) builders = append(builders, txClient.AccountGroup.Create(). SetAccountID(account.ID). SetGroupID(groups[i].GroupID). @@ -1759,7 +1764,19 @@ func (r *accountRepository) ClearError(ctx context.Context, id int64) error { } func (r *accountRepository) AddToGroup(ctx context.Context, accountID, groupID int64, priority int) error { - _, err := r.client.AccountGroup.Create(). + tx, err := r.client.Tx(ctx) + if err != nil && !errors.Is(err, dbent.ErrTxStarted) { + return err + } + client := r.client + if tx != nil { + defer func() { _ = tx.Rollback() }() + client = tx.Client() + } + if err := lockLiveGroups(ctx, client, []int64{groupID}); err != nil { + return err + } + _, err = client.AccountGroup.Create(). SetAccountID(accountID). SetGroupID(groupID). SetPriority(priority). @@ -1767,6 +1784,11 @@ func (r *accountRepository) AddToGroup(ctx context.Context, accountID, groupID i if err != nil { return err } + if tx != nil { + if err := tx.Commit(); err != nil { + return err + } + } payload := buildSchedulerGroupPayload([]int64{groupID}) if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountGroupsChanged, &accountID, nil, payload); err != nil { logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue add to group failed: account=%d group=%d err=%v", accountID, groupID, err) @@ -1827,6 +1849,9 @@ func (r *accountRepository) BindGroups(ctx context.Context, accountID int64, gro // 已处于外部事务中(ErrTxStarted),复用当前 client txClient = r.client } + if err := lockLiveGroups(ctx, txClient, groupIDs); err != nil { + return err + } if _, err := txClient.AccountGroup.Delete().Where(dbaccountgroup.AccountIDEQ(accountID)).Exec(ctx); err != nil { return err diff --git a/backend/internal/repository/account_repo_ollama_cloud_usage.go b/backend/internal/repository/account_repo_ollama_cloud_usage.go index 55634765a4..d5b506ab7a 100644 --- a/backend/internal/repository/account_repo_ollama_cloud_usage.go +++ b/backend/internal/repository/account_repo_ollama_cloud_usage.go @@ -18,7 +18,7 @@ const ( // ollamaCloudUsagePlatformsSQL 是 service.isOllamaCloudUsagePlatform 的 SQL // 镜像:Ollama Cloud key 允许挂在 openai/anthropic 与国产 OpenAI 兼容平台 // 下复用。所有平台白名单 SQL 只允许引用本常量,不得各处重写字面量,防止漂移。 - ollamaCloudUsagePlatformsSQL = "'openai', 'anthropic', 'kimi', 'zhipu', 'deepseek'" + ollamaCloudUsagePlatformsSQL = "'openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax'" ollamaCloudUsageEligibleSQL = ` platform IN (` + ollamaCloudUsagePlatformsSQL + `) AND type = 'apikey' diff --git a/backend/internal/repository/account_repo_ollama_cloud_usage_test.go b/backend/internal/repository/account_repo_ollama_cloud_usage_test.go index 4fb3b6cce5..6891eb7b80 100644 --- a/backend/internal/repository/account_repo_ollama_cloud_usage_test.go +++ b/backend/internal/repository/account_repo_ollama_cloud_usage_test.go @@ -179,7 +179,7 @@ func TestListOllamaCloudUsageGroupAccountsUsesOneStrictBatchQuery(t *testing.T) require.Empty(t, accounts) query := normalizeSQLWhitespace(capturedSQL) require.Contains(t, query, "credentials ->> 'api_key' = ANY($1)") - require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek')") + require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax')") require.Contains(t, query, "jsonb_typeof(credentials -> 'api_key') = 'string'") require.Contains(t, query, ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'")) require.NotContains(t, query, "~*") @@ -207,7 +207,7 @@ func TestListDueOllamaCloudUsageAccountsFiltersOrdersAndLimits(t *testing.T) { for _, clause := range []string{ "deleted_at IS NULL", "status = 'active'", - "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek')", + "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax')", "type = 'apikey'", ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"), "jsonb_typeof(extra -> 'ollama_cloud_usage_session') = 'string'", @@ -251,7 +251,7 @@ func TestBulkUpdateOllamaIdentityCleanupIsValueConditional(t *testing.T) { require.Contains(t, query, "NOT ("+ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'")) require.Contains(t, query, ollamaCloudBaseURLMatchesSQL("$1::jsonb ->> 'base_url'")) require.NotContains(t, query, "~*") - require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek') AND type = 'apikey'") + require.Contains(t, query, "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax') AND type = 'apikey'") require.Contains(t, query, "- 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'") payload, ok := exec.execArgs[0][0].([]byte) require.True(t, ok) @@ -326,10 +326,10 @@ func TestOllamaCloudUsagePlatformWhitelistMatchesServicePredicate(t *testing.T) for _, match := range matches { sqlPlatforms[match[1]] = struct{}{} } - require.Len(t, sqlPlatforms, 5) + require.Len(t, sqlPlatforms, 6) for _, platform := range []string{ service.PlatformOpenAI, service.PlatformAnthropic, - service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, + service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformGemini, service.PlatformGrok, service.PlatformAntigravity, service.PlatformComposite, "kiro", } { @@ -373,7 +373,7 @@ func TestUpdateCredentialsPlainCNAPIKeyAccountCleanupStaysSemanticallyEquivalent require.NoError(t, err) query := normalizeSQLWhitespace(capturedSQL) require.Contains(t, query, - "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek') AND type = 'apikey' AND credentials IS DISTINCT FROM $1::jsonb") + "platform IN ('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax') AND type = 'apikey' AND credentials IS DISTINCT FROM $1::jsonb") require.Contains(t, query, "THEN COALESCE(extra, '{}'::jsonb) - 'upstream_billing_probe' - 'ollama_cloud_usage_session' - 'ollama_cloud_usage_auto_refresh' - 'ollama_cloud_usage_snapshot'") require.NotContains(t, query, "- 'upstream_billing_probe_enabled'") diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 20af0ad26c..dd2d1dbc71 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -214,7 +214,8 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldFreeOpenaiFast, group.FieldDefaultMappedModel, group.FieldMessagesDispatchModelConfig, - group.FieldModelsListConfig, + group.FieldModelAllowlist, + group.FieldCodexModelsManifestConfig, group.FieldRpmLimit, group.FieldMaxReasoningEffort, group.FieldMaxReasoningEffortOverLimit, @@ -1016,7 +1017,8 @@ func groupEntityToService(g *dbent.Group) *service.Group { RequirePrivacySet: g.RequirePrivacySet, DefaultMappedModel: g.DefaultMappedModel, MessagesDispatchModelConfig: g.MessagesDispatchModelConfig, - ModelsListConfig: g.ModelsListConfig, + ModelAllowlist: service.GroupModelAllowlistFromDomain(g.ModelAllowlist), + CodexModelsManifestConfig: g.CodexModelsManifestConfig, RPMLimit: g.RpmLimit, MaxReasoningEffort: g.MaxReasoningEffort, MaxReasoningEffortOverLimit: g.MaxReasoningEffortOverLimit, diff --git a/backend/internal/repository/api_key_repo_codex_manifest_projection_integration_test.go b/backend/internal/repository/api_key_repo_codex_manifest_projection_integration_test.go new file mode 100644 index 0000000000..b2ac1d7c02 --- /dev/null +++ b/backend/internal/repository/api_key_repo_codex_manifest_projection_integration_test.go @@ -0,0 +1,52 @@ +//go:build integration + +package repository + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestGetByKeyForAuthCarriesGroupCodexModelsManifestConfig(t *testing.T) { + ctx := context.Background() + suffix := time.Now().UnixNano() + group := mustCreateGroup(t, integrationEntClient, &service.Group{ + Name: fmt.Sprintf("codex-manifest-proj-group-%d", suffix), Platform: service.PlatformOpenAI, + RateMultiplier: 1, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{11, 22}, + FallbackToScheduler: true, + }, + }) + user := mustCreateUser(t, integrationEntClient, &service.User{ + Email: fmt.Sprintf("codex-manifest-proj-%d@example.com", suffix), Concurrency: 5, + }) + groupID := group.ID + keyValue := fmt.Sprintf("sk-codex-manifest-proj-%d", suffix) + apiKeyRepo := NewAPIKeyRepository(integrationEntClient, integrationDB) + key := &service.APIKey{UserID: user.ID, GroupID: &groupID, Key: keyValue, Name: "codex-manifest-proj", Status: service.StatusActive} + require.NoError(t, apiKeyRepo.Create(ctx, key)) + t.Cleanup(func() { + _, err := integrationDB.ExecContext(ctx, "DELETE FROM auth_cache_invalidation_outbox WHERE cache_key = encode(sha256(convert_to($1, 'UTF8')), 'hex')", keyValue) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM api_keys WHERE id = $1", key.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM users WHERE id = $1", user.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", group.ID) + require.NoError(t, err) + }) + + got, err := apiKeyRepo.GetByKeyForAuth(ctx, keyValue) + require.NoError(t, err) + require.NotNil(t, got.Group) + require.True(t, got.Group.CodexModelsManifestConfig.Enabled, "codex_models_manifest_config 必须进入认证投影(投影漏列会让固定账号模式静默失效)") + require.Equal(t, []int64{11, 22}, got.Group.CodexModelsManifestConfig.AccountIDs) + require.True(t, got.Group.CodexModelsManifestConfig.FallbackToScheduler) +} diff --git a/backend/internal/repository/api_key_repo_model_allowlist_projection_integration_test.go b/backend/internal/repository/api_key_repo_model_allowlist_projection_integration_test.go new file mode 100644 index 0000000000..5b6bb287b3 --- /dev/null +++ b/backend/internal/repository/api_key_repo_model_allowlist_projection_integration_test.go @@ -0,0 +1,50 @@ +//go:build integration + +package repository + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestGetByKeyForAuthCarriesGroupModelAllowlist(t *testing.T) { + ctx := context.Background() + suffix := time.Now().UnixNano() + group := mustCreateGroup(t, integrationEntClient, &service.Group{ + Name: fmt.Sprintf("model-allowlist-proj-group-%d", suffix), Platform: service.PlatformOpenAI, + RateMultiplier: 1, + ModelAllowlist: service.GroupModelAllowlist{ + Enabled: true, + Models: []string{"gpt-5.4", "gpt-5.5-*"}, + }, + }) + user := mustCreateUser(t, integrationEntClient, &service.User{ + Email: fmt.Sprintf("model-allowlist-proj-%d@example.com", suffix), Concurrency: 5, + }) + groupID := group.ID + keyValue := fmt.Sprintf("sk-model-allowlist-proj-%d", suffix) + apiKeyRepo := NewAPIKeyRepository(integrationEntClient, integrationDB) + key := &service.APIKey{UserID: user.ID, GroupID: &groupID, Key: keyValue, Name: "model-allowlist-proj", Status: service.StatusActive} + require.NoError(t, apiKeyRepo.Create(ctx, key)) + t.Cleanup(func() { + _, err := integrationDB.ExecContext(ctx, "DELETE FROM auth_cache_invalidation_outbox WHERE cache_key = encode(sha256(convert_to($1, 'UTF8')), 'hex')", keyValue) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM api_keys WHERE id = $1", key.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM users WHERE id = $1", user.ID) + require.NoError(t, err) + _, err = integrationDB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", group.ID) + require.NoError(t, err) + }) + + got, err := apiKeyRepo.GetByKeyForAuth(ctx, keyValue) + require.NoError(t, err) + require.NotNil(t, got.Group) + require.True(t, got.Group.ModelAllowlist.Enabled, "model_allowlist 必须进入认证投影(投影漏列会让分组级准入静默失效)") + require.Equal(t, []string{"gpt-5.4", "gpt-5.5-*"}, got.Group.ModelAllowlist.Models) +} diff --git a/backend/internal/repository/backup_pg_dumper.go b/backend/internal/repository/backup_pg_dumper.go index e9a92ef29d..01510bf831 100644 --- a/backend/internal/repository/backup_pg_dumper.go +++ b/backend/internal/repository/backup_pg_dumper.go @@ -2,9 +2,14 @@ package repository import ( "context" + "database/sql" + "database/sql/driver" + "errors" "fmt" "io" "os/exec" + "sync" + "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/service" @@ -12,16 +17,39 @@ import ( // PgDumper implements service.DBDumper using pg_dump/psql type PgDumper struct { - cfg *config.DatabaseConfig + cfg *config.DatabaseConfig + db *sql.DB + commandContext func(context.Context, string, ...string) *exec.Cmd } // NewPgDumper creates a new PgDumper -func NewPgDumper(cfg *config.Config) service.DBDumper { - return &PgDumper{cfg: &cfg.Database} +func NewPgDumper(cfg *config.Config, db *sql.DB) service.DBDumper { + return &PgDumper{ + cfg: &cfg.Database, + db: db, + commandContext: exec.CommandContext, + } } // Dump executes pg_dump and returns a streaming reader of the output func (d *PgDumper) Dump(ctx context.Context) (io.ReadCloser, error) { + if d.db == nil { + return nil, errors.New("acquire backup migration lock: nil sql db") + } + lockConn, err := d.db.Conn(ctx) + if err != nil { + return nil, fmt.Errorf("acquire backup migration lock connection: %w", err) + } + if err := pgAdvisoryLock(ctx, lockConn); err != nil { + // Lock acquisition can fail after PostgreSQL accepted the request. Discard + // the session because ownership is ambiguous and advisory locks are session-scoped. + discardSQLConnection(lockConn) + return nil, fmt.Errorf("acquire backup migration lock: %w", err) + } + releaseLock := func() error { + return releaseBackupMigrationLock(lockConn) + } + args := []string{ "-h", d.cfg.Host, "-p", fmt.Sprintf("%d", d.cfg.Port), @@ -33,7 +61,11 @@ func (d *PgDumper) Dump(ctx context.Context) (io.ReadCloser, error) { "--if-exists", } - cmd := exec.CommandContext(ctx, "pg_dump", args...) + commandContext := d.commandContext + if commandContext == nil { + commandContext = exec.CommandContext + } + cmd := commandContext(ctx, "pg_dump", args...) if d.cfg.Password != "" { cmd.Env = append(cmd.Environ(), "PGPASSWORD="+d.cfg.Password) } @@ -43,15 +75,34 @@ func (d *PgDumper) Dump(ctx context.Context) (io.ReadCloser, error) { stdout, err := cmd.StdoutPipe() if err != nil { - return nil, fmt.Errorf("create stdout pipe: %w", err) + return nil, errors.Join(fmt.Errorf("create stdout pipe: %w", err), releaseLock()) } if err := cmd.Start(); err != nil { - return nil, fmt.Errorf("start pg_dump: %w", err) + _ = stdout.Close() + return nil, errors.Join(fmt.Errorf("start pg_dump: %w", err), releaseLock()) } - // 返回一个 ReadCloser:读 stdout,关闭时等待进程退出 - return &cmdReadCloser{ReadCloser: stdout, cmd: cmd}, nil + return &cmdReadCloser{ReadCloser: stdout, cmd: cmd, release: releaseLock}, nil +} + +func releaseBackupMigrationLock(conn *sql.Conn) error { + unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := pgAdvisoryUnlock(unlockCtx, conn); err != nil { + // A failed unlock must not return a possibly lock-owning session to the pool. + discardSQLConnection(conn) + return fmt.Errorf("release backup migration lock: %w", err) + } + if err := conn.Close(); err != nil { + return fmt.Errorf("close backup migration lock connection: %w", err) + } + return nil +} + +func discardSQLConnection(conn *sql.Conn) { + _ = conn.Raw(func(any) error { return driver.ErrBadConn }) + _ = conn.Close() } // Restore executes psql to restore from a streaming reader @@ -84,15 +135,27 @@ func (d *PgDumper) Restore(ctx context.Context, data io.Reader) error { // cmdReadCloser wraps a command stdout pipe and waits for the process on Close type cmdReadCloser struct { io.ReadCloser - cmd *exec.Cmd + cmd *exec.Cmd + release func() error + closeOnce sync.Once + closeErr error } func (c *cmdReadCloser) Close() error { - // Close the pipe first - _ = c.ReadCloser.Close() - // Wait for the process to exit - if err := c.cmd.Wait(); err != nil { - return fmt.Errorf("pg_dump exited with error: %w", err) - } - return nil + c.closeOnce.Do(func() { + var closeErrs []error + if err := c.ReadCloser.Close(); err != nil { + closeErrs = append(closeErrs, fmt.Errorf("close pg_dump stdout: %w", err)) + } + if err := c.cmd.Wait(); err != nil { + closeErrs = append(closeErrs, fmt.Errorf("pg_dump exited with error: %w", err)) + } + if c.release != nil { + if err := c.release(); err != nil { + closeErrs = append(closeErrs, err) + } + } + c.closeErr = errors.Join(closeErrs...) + }) + return c.closeErr } diff --git a/backend/internal/repository/backup_pg_dumper_test.go b/backend/internal/repository/backup_pg_dumper_test.go new file mode 100644 index 0000000000..586182b70f --- /dev/null +++ b/backend/internal/repository/backup_pg_dumper_test.go @@ -0,0 +1,157 @@ +package repository + +import ( + "context" + "errors" + "io" + "os/exec" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func newTestPgDumper(t *testing.T, commandContext func(context.Context, string, ...string) *exec.Cmd) (*PgDumper, sqlmock.Sqlmock) { + t.Helper() + db, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + return &PgDumper{ + cfg: &config.DatabaseConfig{ + Host: "db.example.test", + Port: 5432, + User: "sub2api", + Password: "secret", + DBName: "sub2api", + SSLMode: "require", + }, + db: db, + commandContext: commandContext, + }, mock +} + +func expectBackupMigrationLock(mock sqlmock.Sqlmock) { + mock.ExpectQuery(regexp.QuoteMeta("SELECT pg_try_advisory_lock($1)")). + WithArgs(migrationsAdvisoryLockID). + WillReturnRows(sqlmock.NewRows([]string{"pg_try_advisory_lock"}).AddRow(true)) +} + +func expectBackupMigrationUnlock(mock sqlmock.Sqlmock) { + mock.ExpectExec(regexp.QuoteMeta("SELECT pg_advisory_unlock($1)")). + WithArgs(migrationsAdvisoryLockID). + WillReturnResult(sqlmock.NewResult(0, 1)) +} + +func TestPgDumperHoldsMigrationLockThroughReaderClose(t *testing.T) { + var mock sqlmock.Sqlmock + commandCreated := false + dumper, createdMock := newTestPgDumper(t, func(ctx context.Context, name string, args ...string) *exec.Cmd { + commandCreated = true + require.Equal(t, "pg_dump", name) + require.Contains(t, args, "--clean") + require.NoError(t, mock.ExpectationsWereMet(), "migration lock must be acquired before pg_dump is created") + return exec.CommandContext(ctx, "sh", "-c", "printf backup-data") + }) + mock = createdMock + expectBackupMigrationLock(mock) + + reader, err := dumper.Dump(context.Background()) + require.NoError(t, err) + require.True(t, commandCreated) + data, err := io.ReadAll(reader) + require.NoError(t, err) + require.Equal(t, "backup-data", string(data)) + + expectBackupMigrationUnlock(mock) + require.Error(t, mock.ExpectationsWereMet(), "migration lock was released before the reader closed") + require.NoError(t, reader.Close()) + require.NoError(t, mock.ExpectationsWereMet()) + require.NoError(t, reader.Close(), "reader close must be idempotent") +} + +func TestPgDumperReleasesMigrationLockWhenStdoutPipeSetupFails(t *testing.T) { + dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "sh", "-c", "true") + cmd.Stdout = io.Discard + return cmd + }) + expectBackupMigrationLock(mock) + expectBackupMigrationUnlock(mock) + + reader, err := dumper.Dump(context.Background()) + require.Nil(t, reader) + require.ErrorContains(t, err, "create stdout pipe") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPgDumperReleasesMigrationLockWhenProcessStartFails(t *testing.T) { + dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "/path/that/does/not/exist/pg_dump") + }) + expectBackupMigrationLock(mock) + expectBackupMigrationUnlock(mock) + + reader, err := dumper.Dump(context.Background()) + require.Nil(t, reader) + require.ErrorContains(t, err, "start pg_dump") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPgDumperReleasesMigrationLockWhenProcessFails(t *testing.T) { + dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "printf partial-backup; exit 7") + }) + expectBackupMigrationLock(mock) + + reader, err := dumper.Dump(context.Background()) + require.NoError(t, err) + data, err := io.ReadAll(reader) + require.NoError(t, err) + require.Equal(t, "partial-backup", string(data)) + expectBackupMigrationUnlock(mock) + require.ErrorContains(t, reader.Close(), "pg_dump exited with error") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPgDumperReportsUnlockFailureAndDiscardsConnection(t *testing.T) { + dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "printf backup-data") + }) + expectBackupMigrationLock(mock) + + reader, err := dumper.Dump(context.Background()) + require.NoError(t, err) + _, err = io.ReadAll(reader) + require.NoError(t, err) + mock.ExpectExec(regexp.QuoteMeta("SELECT pg_advisory_unlock($1)")). + WithArgs(migrationsAdvisoryLockID). + WillReturnError(errors.New("unlock unavailable")) + require.ErrorContains(t, reader.Close(), "release backup migration lock") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPgDumperDoesNotStartProcessWhenMigrationLockFails(t *testing.T) { + commandCreated := false + dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + commandCreated = true + return exec.CommandContext(ctx, "sh", "-c", "true") + }) + mock.ExpectQuery(regexp.QuoteMeta("SELECT pg_try_advisory_lock($1)")). + WithArgs(migrationsAdvisoryLockID). + WillReturnError(errors.New("database unavailable")) + + reader, err := dumper.Dump(context.Background()) + require.Nil(t, reader) + require.ErrorContains(t, err, "acquire backup migration lock") + require.False(t, commandCreated) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPgDumperRejectsNilDatabase(t *testing.T) { + dumper := &PgDumper{cfg: &config.DatabaseConfig{}} + reader, err := dumper.Dump(context.Background()) + require.Nil(t, reader) + require.ErrorContains(t, err, "nil sql db") +} diff --git a/backend/internal/repository/channel_cache.go b/backend/internal/repository/channel_cache.go new file mode 100644 index 0000000000..ff8e75b61e --- /dev/null +++ b/backend/internal/repository/channel_cache.go @@ -0,0 +1,53 @@ +package repository + +import ( + "context" + "log/slog" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/redis/go-redis/v9" +) + +const channelCachePubSubKey = "channel_cache_updated" + +type channelCache struct { + rdb *redis.Client +} + +// NewChannelCache creates the Redis-backed channel cache invalidation bus. +func NewChannelCache(rdb *redis.Client) service.ChannelCachePubSub { + return &channelCache{rdb: rdb} +} + +// NotifyUpdate notifies all instances to invalidate their local channel cache. +func (c *channelCache) NotifyUpdate(ctx context.Context) error { + return c.rdb.Publish(ctx, channelCachePubSubKey, "refresh").Err() +} + +// SubscribeUpdates subscribes to channel cache invalidation notifications. +func (c *channelCache) SubscribeUpdates(ctx context.Context, handler func()) { + go func() { + pubsub := c.rdb.Subscribe(ctx, channelCachePubSubKey) + defer func() { + if err := pubsub.Close(); err != nil { + slog.Warn("failed to close channel cache subscriber", "error", err) + } + }() + + messages := pubsub.Channel() + for { + select { + case <-ctx.Done(): + return + case message, ok := <-messages: + if !ok { + slog.Warn("channel cache subscriber stopped", "reason", "channel_closed") + return + } + if message != nil { + handler() + } + } + } + }() +} diff --git a/backend/internal/repository/channel_cache_test.go b/backend/internal/repository/channel_cache_test.go new file mode 100644 index 0000000000..89c45249ef --- /dev/null +++ b/backend/internal/repository/channel_cache_test.go @@ -0,0 +1,44 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" +) + +func TestChannelCachePubSub(t *testing.T) { + redisServer := miniredis.RunT(t) + publisherRedis := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + subscriberRedis := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + t.Cleanup(func() { + _ = publisherRedis.Close() + _ = subscriberRedis.Close() + }) + + publisher := NewChannelCache(publisherRedis) + subscriber := NewChannelCache(subscriberRedis) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + received := make(chan struct{}, 1) + subscriber.SubscribeUpdates(ctx, func() { + select { + case received <- struct{}{}: + default: + } + }) + + require.Eventually(t, func() bool { + return redisServer.PubSubNumSub(channelCachePubSubKey)[channelCachePubSubKey] == 1 + }, time.Second, 10*time.Millisecond) + require.NoError(t, publisher.NotifyUpdate(context.Background())) + + select { + case <-received: + case <-time.After(time.Second): + t.Fatal("channel cache invalidation notification was not received") + } +} diff --git a/backend/internal/repository/channel_repo_pricing.go b/backend/internal/repository/channel_repo_pricing.go index 1a88eeb977..8053d4453f 100644 --- a/backend/internal/repository/channel_repo_pricing.go +++ b/backend/internal/repository/channel_repo_pricing.go @@ -16,7 +16,7 @@ import ( func (r *channelRepository) ListModelPricing(ctx context.Context, channelID int64) ([]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at FROM channel_model_pricing WHERE channel_id = $1 ORDER BY id`, channelID, ) if err != nil { @@ -61,10 +61,10 @@ func (r *channelRepository) UpdateModelPricing(ctx context.Context, pricing *ser } result, err := r.db.ExecContext(ctx, `UPDATE channel_model_pricing - SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_write_1h_price = $6, cache_read_price = $7, fast_multiplier = $8, flex_multiplier = $9, image_input_price = $10, image_output_price = $11, per_request_price = $12, time_pricing = $13, platform = $14, updated_at = NOW() - WHERE id = $15`, + SET models = $1, billing_mode = $2, input_price = $3, output_price = $4, cache_write_price = $5, cache_write_1h_price = $6, cache_read_price = $7, fast_multiplier = $8, flex_multiplier = $9, max_reasoning_effort_multiplier = $10, image_input_price = $11, image_output_price = $12, per_request_price = $13, time_pricing = $14, platform = $15, updated_at = NOW() + WHERE id = $16`, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice, - pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, + pricing.FastMultiplier, pricing.FlexMultiplier, pricing.MaxReasoningEffortMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, timePricingJSON, pricing.Platform, pricing.ID, ) if err != nil { @@ -96,7 +96,7 @@ func (r *channelRepository) ReplaceModelPricing(ctx context.Context, channelID i // batchLoadModelPricing 批量加载多个渠道的模型定价(含区间) func (r *channelRepository) batchLoadModelPricing(ctx context.Context, channelIDs []int64) (map[int64][]service.ChannelModelPricing, error) { rows, err := r.db.QueryContext(ctx, - `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at + `SELECT id, channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing, created_at, updated_at FROM channel_model_pricing WHERE channel_id = ANY($1) ORDER BY channel_id, id`, pq.Array(channelIDs), ) @@ -180,7 +180,7 @@ func scanModelPricingRows(rows *sql.Rows) ([]service.ChannelModelPricing, []int6 if err := rows.Scan( &p.ID, &p.ChannelID, &p.Platform, &modelsJSON, &p.BillingMode, &p.InputPrice, &p.OutputPrice, &p.CacheWritePrice, &p.CacheWrite1hPrice, &p.CacheReadPrice, - &p.FastMultiplier, &p.FlexMultiplier, + &p.FastMultiplier, &p.FlexMultiplier, &p.MaxReasoningEffortMultiplier, &p.ImageInputPrice, &p.ImageOutputPrice, &p.PerRequestPrice, &timePricingJSON, &p.CreatedAt, &p.UpdatedAt, ); err != nil { return nil, nil, fmt.Errorf("scan model pricing: %w", err) @@ -247,11 +247,11 @@ func createModelPricingExec(ctx context.Context, exec dbExec, pricing *service.C platform = "anthropic" } err = exec.QueryRowContext(ctx, - `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, created_at, updated_at`, + `INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id, created_at, updated_at`, pricing.ChannelID, platform, modelsJSON, billingMode, pricing.InputPrice, pricing.OutputPrice, pricing.CacheWritePrice, pricing.CacheWrite1hPrice, pricing.CacheReadPrice, - pricing.FastMultiplier, pricing.FlexMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, + pricing.FastMultiplier, pricing.FlexMultiplier, pricing.MaxReasoningEffortMultiplier, pricing.ImageInputPrice, pricing.ImageOutputPrice, pricing.PerRequestPrice, timePricingJSON, ).Scan(&pricing.ID, &pricing.CreatedAt, &pricing.UpdatedAt) if err != nil { diff --git a/backend/internal/repository/channel_repo_pricing_time_test.go b/backend/internal/repository/channel_repo_pricing_time_test.go index 9532cdf162..c22630abcf 100644 --- a/backend/internal/repository/channel_repo_pricing_time_test.go +++ b/backend/internal/repository/channel_repo_pricing_time_test.go @@ -16,7 +16,7 @@ import ( var channelModelPricingTimePricingColumns = []string{ "id", "channel_id", "platform", "models", "billing_mode", "input_price", "output_price", - "cache_write_price", "cache_write_1h_price", "cache_read_price", "fast_multiplier", "flex_multiplier", "image_input_price", "image_output_price", + "cache_write_price", "cache_write_1h_price", "cache_read_price", "fast_multiplier", "flex_multiplier", "max_reasoning_effort_multiplier", "image_input_price", "image_output_price", "per_request_price", "time_pricing", "created_at", "updated_at", } @@ -33,7 +33,7 @@ func newChannelModelPricingTimePricingRepo(t *testing.T) (*channelRepository, sq func modelPricingTimePricingRow(timePricing any) *sqlmock.Rows { return sqlmock.NewRows(channelModelPricingTimePricingColumns).AddRow( int64(11), int64(7), "openai", `["gpt-5"]`, service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, timePricing, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, timePricing, time.Date(2026, 8, 17, 0, 0, 0, 0, time.UTC), time.Date(2026, 8, 17, 1, 0, 0, 0, time.UTC), ) } @@ -105,10 +105,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) { t.Run("create writes JSON", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). WithArgs( int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) @@ -118,10 +118,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateRoundTrip(t *testing.T) { t.Run("update writes JSON and entry ID", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$12, time_pricing = \$13, platform = \$14.*WHERE id = \$15`). + mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$13, time_pricing = \$14, platform = \$15.*WHERE id = \$16`). WithArgs( []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11), + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, channelModelPricingTimePricingJSON, "openai", int64(11), ). WillReturnResult(sqlmock.NewResult(0, 1)) @@ -153,10 +153,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t t.Run("create writes SQL NULL", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). + mock.ExpectQuery(regexp.QuoteMeta("INSERT INTO channel_model_pricing (channel_id, platform, models, billing_mode, input_price, output_price, cache_write_price, cache_write_1h_price, cache_read_price, fast_multiplier, flex_multiplier, max_reasoning_effort_multiplier, image_input_price, image_output_price, per_request_price, time_pricing)")). WithArgs( int64(7), "openai", []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, ). WillReturnRows(sqlmock.NewRows([]string{"id", "created_at", "updated_at"}).AddRow(int64(11), time.Time{}, time.Time{})) @@ -166,10 +166,10 @@ func TestChannelModelPricingTimePricingCreateAndUpdateWriteNullWhenDisabled(t *t t.Run("update writes SQL NULL", func(t *testing.T) { repo, mock := newChannelModelPricingTimePricingRepo(t) - mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$12, time_pricing = \$13, platform = \$14.*WHERE id = \$15`). + mock.ExpectExec(`(?s)UPDATE channel_model_pricing.*per_request_price = \$13, time_pricing = \$14, platform = \$15.*WHERE id = \$16`). WithArgs( []byte(`["gpt-5"]`), service.BillingModeToken, - nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11), + nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, "openai", int64(11), ). WillReturnResult(sqlmock.NewResult(0, 1)) diff --git a/backend/internal/repository/concurrency_cache.go b/backend/internal/repository/concurrency_cache.go index 37e839f287..c21268f526 100644 --- a/backend/internal/repository/concurrency_cache.go +++ b/backend/internal/repository/concurrency_cache.go @@ -1102,10 +1102,12 @@ func (c *concurrencyCache) reconcileExpiredIndexCandidates(ctx context.Context, if err != nil { return err } - members, err := c.rdb.ZRangeByScore(ctx, spec.indexKey, &redis.ZRangeBy{ - Min: "-inf", - Max: strconv.FormatInt(now, 10), - Count: activeIndexCleanupBatchSize, + members, err := c.rdb.ZRangeArgs(ctx, redis.ZRangeArgs{ + Key: spec.indexKey, + Start: "-inf", + Stop: strconv.FormatInt(now, 10), + ByScore: true, + Count: activeIndexCleanupBatchSize, }).Result() if err != nil { return fmt.Errorf("read expired index %s: %w", spec.indexKey, err) diff --git a/backend/internal/repository/fixtures_integration_test.go b/backend/internal/repository/fixtures_integration_test.go index 005ec7d0ba..98e0085e2f 100644 --- a/backend/internal/repository/fixtures_integration_test.go +++ b/backend/internal/repository/fixtures_integration_test.go @@ -92,6 +92,8 @@ func mustCreateGroup(t *testing.T, client *dbent.Client, g *service.Group) *serv SetIsExclusive(g.IsExclusive). SetForceOpenaiFast(g.ForceOpenAIFast). SetFreeOpenaiFast(g.FreeOpenAIFast). + SetModelAllowlist(service.DomainGroupModelAllowlist(g.ModelAllowlist)). + SetCodexModelsManifestConfig(g.CodexModelsManifestConfig). SetProfitControlEnabled(g.ProfitControlEnabled). SetProfitMinMargin(g.ProfitMinMargin). SetProfitSafetyBuffer(g.ProfitSafetyBuffer) diff --git a/backend/internal/repository/group_model_allowlist_repair_migration_integration_test.go b/backend/internal/repository/group_model_allowlist_repair_migration_integration_test.go new file mode 100644 index 0000000000..db66796771 --- /dev/null +++ b/backend/internal/repository/group_model_allowlist_repair_migration_integration_test.go @@ -0,0 +1,127 @@ +//go:build integration + +package repository + +import ( + "context" + "database/sql" + "testing" + + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/stretchr/testify/require" +) + +const groupModelAllowlistRepairMigration = "236_group_model_allowlist_repair.sql" + +// 236 是可重放的修复迁移:235 的重命名一旦被记账就不会重跑,数据库若回到旧结构 +// (手工改回列名、按旧结构部分恢复)应用仍能启动,但所有关联 groups 的查询都会 +// 报 column groups.model_allowlist does not exist(issue #6780)。 +func TestMigration236RenamesLegacyModelsListConfigColumn(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + + _, err := tx.ExecContext(ctx, "ALTER TABLE groups RENAME COLUMN model_allowlist TO models_list_config") + require.NoError(t, err) + + var groupID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO groups (name, platform, rate_multiplier, status, models_list_config) +VALUES ('migration-236-rename', 'anthropic', 1, 'active', '{"enabled":true,"models":["claude-sonnet-5"]}'::jsonb) +RETURNING id +`).Scan(&groupID)) + + applyGroupModelAllowlistRepair(ctx, t, tx) + + // 重命名保留原数据,且新列恢复 NOT NULL DEFAULT '{}' 的形状。 + var allowlist string + require.NoError(t, tx.QueryRowContext(ctx, + "SELECT model_allowlist::text FROM groups WHERE id = $1", groupID).Scan(&allowlist)) + require.JSONEq(t, `{"enabled":true,"models":["claude-sonnet-5"]}`, allowlist) + requireModelAllowlistColumnShape(ctx, t, tx) + + // 可重放:重复执行不报错也不改变结果。 + applyGroupModelAllowlistRepair(ctx, t, tx) + require.NoError(t, tx.QueryRowContext(ctx, + "SELECT model_allowlist::text FROM groups WHERE id = $1", groupID).Scan(&allowlist)) + require.JSONEq(t, `{"enabled":true,"models":["claude-sonnet-5"]}`, allowlist) +} + +func TestMigration236BackfillsWhenBothColumnsExist(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + + _, err := tx.ExecContext(ctx, + "ALTER TABLE groups ADD COLUMN models_list_config JSONB NOT NULL DEFAULT '{}'::jsonb") + require.NoError(t, err) + + // 新列仍是默认空值:旧列里的配置应该被补回来。 + var staleID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO groups (name, platform, rate_multiplier, status, model_allowlist, models_list_config) +VALUES ('migration-236-backfill', 'anthropic', 1, 'active', '{}'::jsonb, '{"enabled":true,"models":["legacy-model"]}'::jsonb) +RETURNING id +`).Scan(&staleID)) + + // 新列已有配置:不能被旧列覆盖。 + var currentID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO groups (name, platform, rate_multiplier, status, model_allowlist, models_list_config) +VALUES ('migration-236-keep', 'anthropic', 1, 'active', '{"enabled":true,"models":["current-model"]}'::jsonb, '{"enabled":true,"models":["legacy-model"]}'::jsonb) +RETURNING id +`).Scan(¤tID)) + + applyGroupModelAllowlistRepair(ctx, t, tx) + + var backfilled, kept string + require.NoError(t, tx.QueryRowContext(ctx, + "SELECT model_allowlist::text FROM groups WHERE id = $1", staleID).Scan(&backfilled)) + require.JSONEq(t, `{"enabled":true,"models":["legacy-model"]}`, backfilled) + require.NoError(t, tx.QueryRowContext(ctx, + "SELECT model_allowlist::text FROM groups WHERE id = $1", currentID).Scan(&kept)) + require.JSONEq(t, `{"enabled":true,"models":["current-model"]}`, kept) +} + +func TestMigration236RecreatesMissingModelAllowlistColumn(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + + _, err := tx.ExecContext(ctx, "ALTER TABLE groups DROP COLUMN model_allowlist") + require.NoError(t, err) + + var groupID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO groups (name, platform, rate_multiplier, status) +VALUES ('migration-236-recreate', 'anthropic', 1, 'active') +RETURNING id +`).Scan(&groupID)) + + applyGroupModelAllowlistRepair(ctx, t, tx) + + var allowlist string + require.NoError(t, tx.QueryRowContext(ctx, + "SELECT model_allowlist::text FROM groups WHERE id = $1", groupID).Scan(&allowlist)) + require.JSONEq(t, `{}`, allowlist) + requireModelAllowlistColumnShape(ctx, t, tx) +} + +func applyGroupModelAllowlistRepair(ctx context.Context, t *testing.T, tx *sql.Tx) { + t.Helper() + + migrationSQL, err := dbmigrations.FS.ReadFile(groupModelAllowlistRepairMigration) + require.NoError(t, err) + _, err = tx.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) +} + +func requireModelAllowlistColumnShape(ctx context.Context, t *testing.T, tx *sql.Tx) { + t.Helper() + + var isNullable, columnDefault string + require.NoError(t, tx.QueryRowContext(ctx, ` +SELECT is_nullable, COALESCE(column_default, '') +FROM information_schema.columns +WHERE table_name = 'groups' AND column_name = 'model_allowlist' +`).Scan(&isNullable, &columnDefault)) + require.Equal(t, "NO", isNullable) + require.Contains(t, columnDefault, "'{}'::jsonb") +} diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index d70d8ce89c..8b2bbb827d 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -29,6 +29,39 @@ type groupRepository struct { sql sqlExecutor } +// lockLiveGroups makes account-group inserts participate in the same row-lock +// protocol as guarded group deletion. FOR SHARE conflicts with the deleter's +// FOR UPDATE lock, and READ COMMITTED rechecks deleted_at after any wait. +func lockLiveGroups(ctx context.Context, exec sqlExecutor, groupIDs []int64) error { + if len(groupIDs) == 0 { + return nil + } + unique := make(map[int64]struct{}, len(groupIDs)) + for _, id := range groupIDs { + unique[id] = struct{}{} + } + rows, err := exec.QueryContext(ctx, `/* account_group_live_group_lock */ + SELECT id FROM groups + WHERE id = ANY($1) AND deleted_at IS NULL + ORDER BY id + FOR SHARE`, pq.Array(groupIDs)) + if err != nil { + return err + } + defer func() { _ = rows.Close() }() + locked := 0 + for rows.Next() { + locked++ + } + if err := rows.Err(); err != nil { + return err + } + if locked != len(unique) { + return service.ErrGroupNotFound + } + return nil +} + func NewGroupRepository(client *dbent.Client, sqlDB *sql.DB) service.GroupRepository { return newGroupRepositoryWithSQL(client, sqlDB) } @@ -109,7 +142,8 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi SetRequirePrivacySet(groupIn.RequirePrivacySet). SetDefaultMappedModel(groupIn.DefaultMappedModel). SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig). - SetModelsListConfig(groupIn.ModelsListConfig). + SetModelAllowlist(service.DomainGroupModelAllowlist(groupIn.ModelAllowlist)). + SetCodexModelsManifestConfig(groupIn.CodexModelsManifestConfig). SetRpmLimit(groupIn.RPMLimit). SetMaxReasoningEffort(groupIn.MaxReasoningEffort). SetMaxReasoningEffortOverLimit(groupIn.MaxReasoningEffortOverLimit). @@ -288,7 +322,8 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetRequirePrivacySet(groupIn.RequirePrivacySet). SetDefaultMappedModel(groupIn.DefaultMappedModel). SetMessagesDispatchModelConfig(groupIn.MessagesDispatchModelConfig). - SetModelsListConfig(groupIn.ModelsListConfig). + SetModelAllowlist(service.DomainGroupModelAllowlist(groupIn.ModelAllowlist)). + SetCodexModelsManifestConfig(groupIn.CodexModelsManifestConfig). SetRpmLimit(groupIn.RPMLimit). SetMaxReasoningEffort(groupIn.MaxReasoningEffort). SetMaxReasoningEffortOverLimit(groupIn.MaxReasoningEffortOverLimit). @@ -424,6 +459,15 @@ func (r *groupRepository) List(ctx context.Context, params pagination.Pagination func (r *groupRepository) ListWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]service.Group, *pagination.PaginationResult, error) { q := r.client.Group.Query() + return r.listWithFiltersQuery(ctx, q, params, platform, status, search, isExclusive) +} + +func (r *groupRepository) ListBindableWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]service.Group, *pagination.PaginationResult, error) { + q := r.client.Group.Query().Where(group.PlatformNEQ(service.PlatformComposite)) + return r.listWithFiltersQuery(ctx, q, params, platform, status, search, isExclusive) +} + +func (r *groupRepository) listWithFiltersQuery(ctx context.Context, q *dbent.GroupQuery, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]service.Group, *pagination.PaginationResult, error) { if platform != "" { q = q.Where(group.PlatformEQ(platform)) @@ -805,12 +849,14 @@ func (r *groupRepository) DeleteAccountGroupsByGroupID(ctx context.Context, grou } func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64, error) { - g, err := r.client.Group.Query().Where(group.IDEQ(id)).Only(ctx) - if err != nil { - return nil, translatePersistenceError(err, service.ErrGroupNotFound, nil) - } - groupSvc := groupEntityToService(g) + return r.deleteCascade(ctx, id, false) +} +func (r *groupRepository) DeleteCascadeIfEmpty(ctx context.Context, id int64) ([]int64, error) { + return r.deleteCascade(ctx, id, true) +} + +func (r *groupRepository) deleteCascade(ctx context.Context, id int64, requireEmpty bool) ([]int64, error) { // 使用 ent 事务统一包裹:避免手工基于 *sql.Tx 构造 ent client 带来的驱动断言问题, // 同时保证级联删除的原子性。 tx, err := r.client.Tx(ctx) @@ -828,13 +874,14 @@ func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64, // Lock the group row to avoid concurrent writes while we cascade. // 这里使用 exec.QueryContext 手动扫描,确保同一事务内加锁并能区分"未找到"与其他错误。 - rows, err := exec.QueryContext(ctx, "SELECT id FROM groups WHERE id = $1 AND deleted_at IS NULL FOR UPDATE", id) + rows, err := exec.QueryContext(ctx, "SELECT id, subscription_type FROM groups WHERE id = $1 AND deleted_at IS NULL FOR UPDATE", id) if err != nil { return nil, err } var lockedID int64 + var subscriptionType string if rows.Next() { - if err := rows.Scan(&lockedID); err != nil { + if err := rows.Scan(&lockedID, &subscriptionType); err != nil { _ = rows.Close() return nil, err } @@ -848,9 +895,22 @@ func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64, if lockedID == 0 { return nil, service.ErrGroupNotFound } + if requireEmpty { + var hasAccount bool + if err := scanSingleRow(ctx, exec, `SELECT EXISTS ( + SELECT 1 FROM account_groups ag + JOIN accounts a ON a.id = ag.account_id + WHERE ag.group_id = $1 AND a.deleted_at IS NULL + )`, []any{id}, &hasAccount); err != nil { + return nil, err + } + if hasAccount { + return nil, service.ErrGroupNotEmpty + } + } var affectedUserIDs []int64 - if groupSvc.IsSubscriptionType() { + if subscriptionType == service.SubscriptionTypeSubscription { // 只查询未软删除的订阅,避免通知已取消订阅的用户 rows, err := exec.QueryContext(ctx, "SELECT user_id FROM user_subscriptions WHERE group_id = $1 AND deleted_at IS NULL", id) if err != nil { @@ -1018,8 +1078,21 @@ func (r *groupRepository) BindAccountsToGroup(ctx context.Context, groupID int64 return nil } + tx, err := r.client.Tx(ctx) + if err != nil && !errors.Is(err, dbent.ErrTxStarted) { + return err + } + exec := sqlExecutor(r.client) + if tx != nil { + defer func() { _ = tx.Rollback() }() + exec = tx.Client() + } + if err := lockLiveGroups(ctx, exec, []int64{groupID}); err != nil { + return err + } + // 使用 INSERT ... ON CONFLICT DO NOTHING 忽略已存在的绑定 - _, err := r.sql.ExecContext( + _, err = exec.ExecContext( ctx, `INSERT INTO account_groups (account_id, group_id, priority, created_at) SELECT unnest($1::bigint[]), $2, 50, NOW() @@ -1030,6 +1103,11 @@ func (r *groupRepository) BindAccountsToGroup(ctx context.Context, groupID int64 if err != nil { return err } + if tx != nil { + if err := tx.Commit(); err != nil { + return err + } + } // 发送调度器事件 if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventGroupChanged, nil, &groupID, nil); err != nil { diff --git a/backend/internal/repository/group_repo_integration_test.go b/backend/internal/repository/group_repo_integration_test.go index 8c14504776..177fbe7f17 100644 --- a/backend/internal/repository/group_repo_integration_test.go +++ b/backend/internal/repository/group_repo_integration_test.go @@ -8,10 +8,12 @@ import ( "errors" "strings" "testing" + "time" dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) @@ -245,6 +247,103 @@ func (s *GroupRepoSuite) TestDelete() { s.Require().ErrorIs(err, service.ErrGroupNotFound) } +func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyRejectsGroupWithNonDeletedAccount() { + group := &service.Group{Name: "guarded-non-empty", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard} + s.Require().NoError(s.repo.Create(s.ctx, group)) + var accountID int64 + s.Require().NoError(scanSingleRow(s.ctx, s.tx, + "INSERT INTO accounts (name, platform, type) VALUES ($1, $2, $3) RETURNING id", + []any{"guarded-account", service.PlatformAnthropic, service.AccountTypeOAuth}, &accountID)) + _, err := s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, 1, NOW())", accountID, group.ID) + s.Require().NoError(err) + + _, err = s.repo.DeleteCascadeIfEmpty(s.ctx, group.ID) + s.Require().ErrorIs(err, service.ErrGroupNotEmpty) + _, err = s.repo.GetByID(s.ctx, group.ID) + s.Require().NoError(err) + var bindings int + s.Require().NoError(scanSingleRow(s.ctx, s.tx, "SELECT COUNT(*) FROM account_groups WHERE group_id = $1", []any{group.ID}, &bindings)) + s.Require().Equal(1, bindings) +} + +func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyDeletesEmptyGroup() { + group := &service.Group{Name: "guarded-empty", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard} + s.Require().NoError(s.repo.Create(s.ctx, group)) + + _, err := s.repo.DeleteCascadeIfEmpty(s.ctx, group.ID) + s.Require().NoError(err) + _, err = s.repo.GetByID(s.ctx, group.ID) + s.Require().ErrorIs(err, service.ErrGroupNotFound) +} + +func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyIgnoresBindingsToSoftDeletedAccounts() { + group := &service.Group{Name: "guarded-soft-deleted-account", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard} + s.Require().NoError(s.repo.Create(s.ctx, group)) + var accountID int64 + s.Require().NoError(scanSingleRow(s.ctx, s.tx, + "INSERT INTO accounts (name, platform, type, deleted_at) VALUES ($1, $2, $3, NOW()) RETURNING id", + []any{"guarded-deleted-account", service.PlatformAnthropic, service.AccountTypeOAuth}, &accountID)) + _, err := s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, 1, NOW())", accountID, group.ID) + s.Require().NoError(err) + + _, err = s.repo.DeleteCascadeIfEmpty(s.ctx, group.ID) + s.Require().NoError(err) + _, err = s.repo.GetByID(s.ctx, group.ID) + s.Require().ErrorIs(err, service.ErrGroupNotFound) +} + +func TestBindAccountsToGroupWaitingBehindGuardedDeleteCannotCommit(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var groupID, accountID int64 + require.NoError(t, scanSingleRow(ctx, integrationDB, + "INSERT INTO groups (name, platform, rate_multiplier, status, subscription_type) VALUES ($1, $2, 1, $3, $4) RETURNING id", + []any{"guarded-delete-bind-race", service.PlatformAnthropic, service.StatusActive, service.SubscriptionTypeStandard}, &groupID)) + require.NoError(t, scanSingleRow(ctx, integrationDB, + "INSERT INTO accounts (name, platform, type) VALUES ($1, $2, $3) RETURNING id", + []any{"guarded-delete-bind-race-account", service.PlatformAnthropic, service.AccountTypeOAuth}, &accountID)) + t.Cleanup(func() { + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM account_groups WHERE group_id = $1 OR account_id = $2", groupID, accountID) + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM accounts WHERE id = $1", accountID) + _, _ = integrationDB.ExecContext(context.Background(), "DELETE FROM groups WHERE id = $1", groupID) + }) + + deleteTx, err := integrationEntClient.Tx(ctx) + require.NoError(t, err) + defer func() { _ = deleteTx.Rollback() }() + rows, err := deleteTx.Client().QueryContext(ctx, "SELECT id FROM groups WHERE id = $1 FOR UPDATE", groupID) + require.NoError(t, err) + require.True(t, rows.Next()) + require.NoError(t, rows.Close()) + + bindDone := make(chan error, 1) + go func() { + bindDone <- newGroupRepositoryWithSQL(integrationEntClient, integrationDB). + BindAccountsToGroup(ctx, groupID, []int64{accountID}) + }() + + require.Eventually(t, func() bool { + var waiting bool + err := scanSingleRow(ctx, integrationDB, `SELECT EXISTS ( + SELECT 1 FROM pg_stat_activity + WHERE query LIKE '/* account_group_live_group_lock */%' + AND wait_event_type = 'Lock' + )`, nil, &waiting) + return err == nil && waiting + }, 5*time.Second, 10*time.Millisecond, "binder did not wait on the guarded deletion lock") + + deleteRepo := newGroupRepositoryWithSQL(deleteTx.Client(), deleteTx) + _, err = deleteRepo.DeleteCascadeIfEmpty(ctx, groupID) + require.NoError(t, err) + require.NoError(t, deleteTx.Commit()) + + require.ErrorIs(t, <-bindDone, service.ErrGroupNotFound) + var bindings int + require.NoError(t, scanSingleRow(ctx, integrationDB, "SELECT COUNT(*) FROM account_groups WHERE group_id = $1", []any{groupID}, &bindings)) + require.Zero(t, bindings) +} + // --- List / ListWithFilters --- func (s *GroupRepoSuite) TestList() { @@ -619,6 +718,22 @@ func (s *GroupRepoSuite) TestListActive() { s.Require().True(found, "active1 group should be in results") } +func (s *GroupRepoSuite) TestListBindableWithFiltersAppliesFilterBeforePagination() { + for _, g := range []*service.Group{ + {Name: "bindable-a", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}, + {Name: "composite-hidden", Platform: service.PlatformComposite, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}, + {Name: "bindable-b", Platform: service.PlatformOpenAI, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}, + } { + s.Require().NoError(s.repo.Create(s.ctx, g)) + } + + groups, page, err := s.repo.ListBindableWithFilters(s.ctx, pagination.PaginationParams{Page: 2, PageSize: 1, SortBy: "name", SortOrder: "asc"}, "", service.StatusActive, "bindable-", nil) + s.Require().NoError(err) + s.Require().Equal(int64(2), page.Total) + s.Require().Len(groups, 1) + s.Require().NotEqual(service.PlatformComposite, groups[0].Platform) +} + func (s *GroupRepoSuite) TestListActiveByPlatform() { s.Require().NoError(s.repo.Create(s.ctx, &service.Group{ Name: "g1", diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index bdd6478cff..e073659cc2 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -76,13 +76,13 @@ const ( defaultOpenAIHTTP2FallbackErrorThreshold = 2 defaultOpenAIHTTP2FallbackWindow = 60 * time.Second defaultOpenAIHTTP2FallbackTTL = 10 * time.Minute - // OpenAI HTTP/2 连接健康探测:Codex 上游改走 HTTP/2 后,池化连接被代理/NAT + // 长流 HTTP/2 连接健康探测:池化连接被代理/NAT // 静默掐断会成为“死连接”(两端都以为存活),请求落上去会挂到 TCP 重传超时 // (分钟级)。Go 的 http2.Transport 默认 ReadIdleTimeout=0(不发健康 PING), // 无法检测。启用主动 PING 探测:连接空闲 ReadIdleTimeout 后发 PING,PingTimeout // 内无响应即判定死连接并关闭,从源头避免请求挂在死连接上。 - openAIHTTP2ReadIdleTimeout = 15 * time.Second - openAIHTTP2PingTimeout = 15 * time.Second + longStreamHTTP2ReadIdleTimeout = 10 * time.Second + longStreamHTTP2PingTimeout = 5 * time.Second // The Grok CLI proxy rejects requests that do not identify a supported // client version. Host/env/version pins live in package xai so service, @@ -96,6 +96,7 @@ const ( const ( upstreamProtocolModeDefault = "default" + upstreamProtocolModeLongStreamH2 = "long_stream_h2" upstreamProtocolModeOpenAIH1 = "openai_h1" upstreamProtocolModeOpenAIH2 = "openai_h2" upstreamProtocolModeOpenAIH1Fallback = "openai_h1_fallback" @@ -128,7 +129,7 @@ type upstreamClientEntry struct { client *http.Client // HTTP 客户端实例 proxyKey string // 代理标识(用于检测代理变更) poolKey string // 连接池配置标识(用于检测配置变更) - protocolMode string // 协议模式(default/openai_h1/openai_h2/openai_h1_fallback) + protocolMode string // 协议模式(default/long_stream_h2/openai_h1/openai_h2/openai_h1_fallback) lastUsed int64 // 最后使用时间戳(纳秒),用于 LRU 淘汰 inFlight int64 // 当前进行中的请求数,>0 时不可淘汰 } @@ -213,7 +214,7 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i } // 执行请求 - client := httpClientForUpstreamRequest(entry.client, req) + client := s.httpClientForUpstreamRequest(entry.client, req) client = httpClientWithGrokAccessDeniedFallback(client) resp, err := servertiming.Do(client, req) if err != nil { @@ -277,7 +278,7 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco return nil, err } - client := httpClientForUpstreamRequest(entry.client, req) + client := s.httpClientForUpstreamRequest(entry.client, req) client = httpClientWithGrokAccessDeniedFallback(client) resp, err := servertiming.Do(client, req) if err != nil { @@ -297,15 +298,27 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco return resp, nil } -func httpClientForUpstreamRequest(client *http.Client, req *http.Request) *http.Client { - if client == nil || req == nil || !service.HTTPUpstreamRedirectsDisabled(req.Context()) { +// httpClientForUpstreamRequest 按请求上下文的标记派生客户端:禁用重定向,或对重定向的每一跳做主机校验。 +// 派生的克隆与缓存客户端共享 Transport;未打标记时原样返回。 +func (s *httpUpstreamService) httpClientForUpstreamRequest(client *http.Client, req *http.Request) *http.Client { + if client == nil || req == nil { return client } - clone := *client - clone.CheckRedirect = func(*http.Request, []*http.Request) error { - return http.ErrUseLastResponse + ctx := req.Context() + switch { + case service.HTTPUpstreamRedirectsDisabled(ctx): + clone := *client + clone.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + return &clone + case service.HTTPUpstreamPublicHostsOnly(ctx) && client.CheckRedirect == nil: + clone := *client + clone.CheckRedirect = s.redirectChecker + return &clone + default: + return client } - return &clone } // grokAccessDeniedFallbackTransport preserves the subscription CLI proxy as @@ -587,8 +600,11 @@ func (s *httpUpstreamService) shouldValidateResolvedIP() bool { return !s.cfg.Security.URLAllowlist.AllowPrivateHosts } +// validateRequestHost 校验请求主机的解析结果不落在回环、私网、链路本地或未指定地址。 +// 是否全局启用由 security.url_allowlist 决定;带 WithHTTPUpstreamPublicHostsOnly 标记的请求无论配置如何都校验。 func (s *httpUpstreamService) validateRequestHost(req *http.Request) error { - if !s.shouldValidateResolvedIP() { + publicHostsOnly := req != nil && service.HTTPUpstreamPublicHostsOnly(req.Context()) + if !s.shouldValidateResolvedIP() && !publicHostsOnly { return nil } if req == nil || req.URL == nil { @@ -992,6 +1008,9 @@ func (s *httpUpstreamService) resolveOpenAIHTTP2Settings() openAIHTTP2Settings { } func (s *httpUpstreamService) resolveProtocolMode(profile service.HTTPUpstreamProfile, proxyKey string, parsedProxy *url.URL) string { + if profile == service.HTTPUpstreamProfileLongStream { + return upstreamProtocolModeLongStreamH2 + } if profile == service.HTTPUpstreamProfileGrok { return upstreamProtocolModeGrok } @@ -1317,11 +1336,11 @@ func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL, protocolMo ResponseHeaderTimeout: settings.responseHeaderTimeout, } switch protocolMode { - case upstreamProtocolModeOpenAIH2: + case upstreamProtocolModeLongStreamH2, upstreamProtocolModeOpenAIH2: transport.ForceAttemptHTTP2 = true // 显式配置 http2 并启用 PING 健康探测,剔除代理/NAT 静默掐断的死连接, // 避免请求挂在死连接上直到 TCP 重传超时(分钟级)。 - if _, err := enableOpenAIHTTP2KeepAlive(transport); err != nil { + if _, err := enableHTTP2KeepAlive(transport); err != nil { return nil, err } case upstreamProtocolModeOpenAIH1: @@ -1338,18 +1357,18 @@ func buildUpstreamTransport(settings poolSettings, proxyURL *url.URL, protocolMo return transport, nil } -// enableOpenAIHTTP2KeepAlive 在 http.Transport 上显式配置 HTTP/2 并启用连接健康探测。 +// enableHTTP2KeepAlive 在 http.Transport 上显式配置 HTTP/2 并启用连接健康探测。 // Go 默认惰性配置 http2 且 ReadIdleTimeout=0(不发健康 PING),无法检测被代理/NAT // 静默掐断的死连接。此处主动设置 ReadIdleTimeout/PingTimeout,让死连接被提前 PING // 出并关闭,请求得以重建连接而非挂到 TCP 重传超时。返回底层 *http2.Transport 便于测试。 -func enableOpenAIHTTP2KeepAlive(transport *http.Transport) (*http2.Transport, error) { +func enableHTTP2KeepAlive(transport *http.Transport) (*http2.Transport, error) { h2, err := http2.ConfigureTransports(transport) if err != nil { return nil, err } if h2 != nil { - h2.ReadIdleTimeout = openAIHTTP2ReadIdleTimeout - h2.PingTimeout = openAIHTTP2PingTimeout + h2.ReadIdleTimeout = longStreamHTTP2ReadIdleTimeout + h2.PingTimeout = longStreamHTTP2PingTimeout } return h2, nil } diff --git a/backend/internal/repository/http_upstream_http2_keepalive_test.go b/backend/internal/repository/http_upstream_http2_keepalive_test.go index 66a30dab0e..29acf4b631 100644 --- a/backend/internal/repository/http_upstream_http2_keepalive_test.go +++ b/backend/internal/repository/http_upstream_http2_keepalive_test.go @@ -32,44 +32,44 @@ func requireHTTP2Configured(t *testing.T, tr *http.Transport, msg string) { require.True(t, tr.Protocols.HTTP2(), msg) } -// Codex/OpenAI 上游改走 HTTP/2 后,池化连接被代理/NAT 静默掐断会成为“死连接”: +// 长流 / OpenAI 上游改走 HTTP/2 后,池化连接被代理/NAT 静默掐断会成为“死连接”: // 两端都以为连接存活,请求落上去会挂到 TCP 重传超时(分钟级)才失败。Go 的 // http2.Transport 默认 ReadIdleTimeout=0(不发健康 PING),无法检测这种死连接。 // 必须显式启用主动 PING 探测,让死连接被提前剔除,而不是只靠 ResponseHeaderTimeout // 事后兜底。 -func TestEnableOpenAIHTTP2KeepAlive_EnablesPingHealthCheck(t *testing.T) { +func TestEnableHTTP2KeepAlive_EnablesPingHealthCheck(t *testing.T) { tr := &http.Transport{} - h2, err := enableOpenAIHTTP2KeepAlive(tr) + h2, err := enableHTTP2KeepAlive(tr) require.NoError(t, err) require.NotNil(t, h2, "必须返回已配置的 *http2.Transport") require.Positive(t, h2.ReadIdleTimeout, "必须启用空闲 PING 探测以剔除死连接") - require.Equal(t, openAIHTTP2ReadIdleTimeout, h2.ReadIdleTimeout) - require.Equal(t, openAIHTTP2PingTimeout, h2.PingTimeout, "PING 无响应必须有超时判定") + require.Equal(t, longStreamHTTP2ReadIdleTimeout, h2.ReadIdleTimeout) + require.Equal(t, longStreamHTTP2PingTimeout, h2.PingTimeout, "PING 无响应必须有超时判定") requireHTTP2Configured(t, tr, "http2 必须已挂到底层 http.Transport 上") } -// openai_h2 模式构建的 Transport 必须带上 H2 PING 健康探测,从源头剔除死连接。 -func TestBuildUpstreamTransport_OpenAIH2_EnablesPingHealthCheck(t *testing.T) { - tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeOpenAIH2) +// long_stream_h2 模式构建的 Transport 必须带上 H2 PING 健康探测,从源头剔除死连接。 +func TestBuildUpstreamTransport_LongStreamH2_EnablesPingHealthCheck(t *testing.T) { + tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeLongStreamH2) require.NoError(t, err) - require.True(t, tr.ForceAttemptHTTP2, "openai_h2 必须启用 HTTP/2") - requireHTTP2Configured(t, tr, "openai_h2 必须显式配置 http2 以启用 ReadIdleTimeout") + require.True(t, tr.ForceAttemptHTTP2, "long_stream_h2 必须启用 HTTP/2") + requireHTTP2Configured(t, tr, "long_stream_h2 必须显式配置 http2 以启用 ReadIdleTimeout") } // 非 H2 模式(default/h1)不应因本次改动被误配置:default 走 Go 自动 H2(惰性配置, // 构建时 Protocols/TLSNextProto 仍为空),h1 模式显式禁用 H2。避免波及 Claude/Gemini 热路径。 -func TestBuildUpstreamTransport_NonOpenAIH2_NotEagerlyConfigured(t *testing.T) { +func TestBuildUpstreamTransport_NonLongStreamH2_NotEagerlyConfigured(t *testing.T) { tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeDefault) require.NoError(t, err) require.Nil(t, tr.Protocols, "default 模式不应在构建期主动配置 http2 keepalive") require.Nil(t, tr.TLSNextProto["h2"], "default 模式不应在构建期主动配置 http2 keepalive") } -// openai_h2 模式构建的 Transport 必须真正以 HTTP/2 与上游通信,PING 健康探测才有载体: -// 自定义 DialContext 下 Go 不会自动启用 H2,全靠 enableOpenAIHTTP2KeepAlive 的显式配置。 -func TestBuildUpstreamTransport_OpenAIH2_NegotiatesHTTP2(t *testing.T) { +// long_stream_h2 模式构建的 Transport 必须真正以 HTTP/2 与上游通信,PING 健康探测才有载体: +// 自定义 DialContext 下 Go 不会自动启用 H2,全靠 enableHTTP2KeepAlive 的显式配置。 +func TestBuildUpstreamTransport_LongStreamH2_NegotiatesHTTP2(t *testing.T) { srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) @@ -77,7 +77,7 @@ func TestBuildUpstreamTransport_OpenAIH2_NegotiatesHTTP2(t *testing.T) { srv.StartTLS() defer srv.Close() - tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeOpenAIH2) + tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), nil, upstreamProtocolModeLongStreamH2) require.NoError(t, err) defer tr.CloseIdleConnections() require.NotNil(t, tr.TLSClientConfig) @@ -91,18 +91,18 @@ func TestBuildUpstreamTransport_OpenAIH2_NegotiatesHTTP2(t *testing.T) { require.NoError(t, err) require.NoError(t, resp.Body.Close()) require.Equal(t, http.StatusOK, resp.StatusCode) - require.Equal(t, 2, resp.ProtoMajor, "openai_h2 必须协商到 HTTP/2") + require.Equal(t, 2, resp.ProtoMajor, "long_stream_h2 必须协商到 HTTP/2") } // 死连接在经 HTTP 代理(CONNECT 隧道)时最高发,这是带 proxy 账号的真实生产路径: // 显式 http2 配置须与 Transport.Proxy 同时正确生效,不能相互干扰。 -func TestBuildUpstreamTransport_OpenAIH2_WithHTTPProxy_EnablesKeepAlive(t *testing.T) { +func TestBuildUpstreamTransport_LongStreamH2_WithHTTPProxy_EnablesKeepAlive(t *testing.T) { proxyURL, err := url.Parse("http://127.0.0.1:8080") require.NoError(t, err) - tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), proxyURL, upstreamProtocolModeOpenAIH2) + tr, err := buildUpstreamTransport(http2KeepAliveTestPoolSettings(), proxyURL, upstreamProtocolModeLongStreamH2) require.NoError(t, err) require.True(t, tr.ForceAttemptHTTP2) - requireHTTP2Configured(t, tr, "经代理的 openai_h2 也必须启用 http2 keepalive") + requireHTTP2Configured(t, tr, "经代理的 long_stream_h2 也必须启用 http2 keepalive") require.NotNil(t, tr.Proxy, "HTTP 代理仍须通过 Transport.Proxy 生效") } diff --git a/backend/internal/repository/http_upstream_test.go b/backend/internal/repository/http_upstream_test.go index 3c7840dc47..49881ae511 100644 --- a/backend/internal/repository/http_upstream_test.go +++ b/backend/internal/repository/http_upstream_test.go @@ -629,6 +629,24 @@ func (s *HTTPUpstreamSuite) TestOpenAIProfileDefaultsToHTTP2AndNoHeaderTimeout() require.Equal(s.T(), upstreamProtocolModeOpenAIH2, entry.protocolMode) } +func (s *HTTPUpstreamSuite) TestLongStreamProfileUsesSharedHTTP2KeepAlive() { + s.cfg.Gateway = config.GatewayConfig{ + ResponseHeaderTimeout: 600, + OpenAIHTTP2: config.GatewayOpenAIHTTP2Config{ + Enabled: false, + }, + } + svc := s.newService() + entry, err := svc.getClientEntry("", 1, 1, service.HTTPUpstreamProfileLongStream, false, false) + require.NoError(s.T(), err) + transport, ok := entry.client.Transport.(*http.Transport) + require.True(s.T(), ok, "expected *http.Transport") + require.Equal(s.T(), 600*time.Second, transport.ResponseHeaderTimeout, "long-stream profile should retain the generic header timeout") + require.True(s.T(), transport.ForceAttemptHTTP2, "long-stream profile must enable HTTP/2 independently of OpenAI settings") + require.True(s.T(), transport.Protocols.HTTP2(), "long-stream profile must install HTTP/2 PING health checks") + require.Equal(s.T(), upstreamProtocolModeLongStreamH2, entry.protocolMode) +} + func (s *HTTPUpstreamSuite) TestOpenAIProfileCustomHeaderTimeout() { s.cfg.Gateway = config.GatewayConfig{ ResponseHeaderTimeout: 600, @@ -961,3 +979,63 @@ func hasEntry(svc *httpUpstreamService, target *upstreamClientEntry) bool { } return false } + +func TestHTTPUpstreamDoPublicHostsOnlyRejectsPrivateDestinationBeforeConnecting(t *testing.T) { + var calls atomic.Int64 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(target.Close) + + upstream := NewHTTPUpstream(nil) + + plain, err := http.NewRequestWithContext(t.Context(), http.MethodGet, target.URL, nil) + require.NoError(t, err) + resp, err := upstream.Do(plain, "", 1, 1) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, int64(1), calls.Load(), "loopback stays reachable for requests without the marker") + + guarded, err := http.NewRequestWithContext(service.WithHTTPUpstreamPublicHostsOnly(t.Context()), http.MethodGet, target.URL, nil) + require.NoError(t, err) + resp, err = upstream.Do(guarded, "", 1, 1) + require.Error(t, err) + require.Nil(t, resp) + require.Contains(t, err.Error(), "not allowed") + require.Equal(t, int64(1), calls.Load(), "marked request must be rejected before any connection is made") +} + +func TestHTTPUpstreamPublicHostsOnlyValidatesEveryRedirectHop(t *testing.T) { + upstream, ok := NewHTTPUpstream(nil).(*httpUpstreamService) + require.True(t, ok) + base := &http.Client{} + + plain, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://cdn.example.com/a.png", nil) + require.NoError(t, err) + require.Same(t, base, upstream.httpClientForUpstreamRequest(base, plain)) + + guarded, err := http.NewRequestWithContext(service.WithHTTPUpstreamPublicHostsOnly(t.Context()), http.MethodGet, "https://cdn.example.com/a.png", nil) + require.NoError(t, err) + client := upstream.httpClientForUpstreamRequest(base, guarded) + require.NotSame(t, base, client) + require.NotNil(t, client.CheckRedirect) + require.Nil(t, base.CheckRedirect, "the cached client must stay untouched") + + via := []*http.Request{guarded} + for _, hop := range []string{ + "http://127.0.0.1:8080/a.png", + "http://[::1]:8080/a.png", + "http://10.0.0.8/a.png", + "http://169.254.169.254/latest/meta-data/", + "http://0.0.0.0/a.png", + } { + hopReq, err := http.NewRequestWithContext(guarded.Context(), http.MethodGet, hop, nil) + require.NoError(t, err) + require.Error(t, client.CheckRedirect(hopReq, via), "hop=%s", hop) + } + publicHop, err := http.NewRequestWithContext(guarded.Context(), http.MethodGet, "http://93.184.216.34/a.png", nil) + require.NoError(t, err) + require.NoError(t, client.CheckRedirect(publicHop, via)) + require.Error(t, client.CheckRedirect(publicHop, make([]*http.Request, 10)), "redirect chain stays capped") +} diff --git a/backend/internal/repository/migrations_runner.go b/backend/internal/repository/migrations_runner.go index 0b786616a5..8c69eb50dd 100644 --- a/backend/internal/repository/migrations_runner.go +++ b/backend/internal/repository/migrations_runner.go @@ -62,6 +62,8 @@ const usageLogsUpstreamModelMismatchIndex = "idx_usage_logs_upstream_model_misma const usageLogsEffectiveModelIndexesMigration = "226_add_usage_log_effective_model_indexes_notx.sql" const usageLogsEffectiveRequestedModelIndex = "idx_usage_logs_effective_requested_model_created" const usageLogsEffectiveUpstreamModelIndex = "idx_usage_logs_effective_upstream_model_created" +const usageLogsUpstreamRequestIDIndexMigration = "233_add_usage_log_upstream_request_id_index_notx.sql" +const usageLogsUpstreamRequestIDIndex = "idx_usage_logs_upstream_request_id" type migrationChecksumCompatibilityRule struct { fileChecksum string @@ -305,6 +307,8 @@ func prepareNonTransactionalMigration(ctx context.Context, db migrationConnectio } } return nil + case usageLogsUpstreamRequestIDIndexMigration: + return dropInvalidIndexIfPresent(ctx, db, usageLogsUpstreamRequestIDIndex) default: return nil } diff --git a/backend/internal/repository/ops_repo_metrics.go b/backend/internal/repository/ops_repo_metrics.go index f1e57c3864..55eda23ed9 100644 --- a/backend/internal/repository/ops_repo_metrics.go +++ b/backend/internal/repository/ops_repo_metrics.go @@ -114,37 +114,37 @@ INSERT INTO ops_system_metrics ( opsNullFloat64(input.QPS), opsNullFloat64(input.TPS), - opsNullInt(input.DurationP50Ms), - opsNullInt(input.DurationP90Ms), - opsNullInt(input.DurationP95Ms), - opsNullInt(input.DurationP99Ms), + opsNullMetricInt(input.DurationP50Ms), + opsNullMetricInt(input.DurationP90Ms), + opsNullMetricInt(input.DurationP95Ms), + opsNullMetricInt(input.DurationP99Ms), opsNullFloat64(input.DurationAvgMs), - opsNullInt(input.DurationMaxMs), + opsNullMetricInt(input.DurationMaxMs), - opsNullInt(input.TTFTP50Ms), - opsNullInt(input.TTFTP90Ms), - opsNullInt(input.TTFTP95Ms), - opsNullInt(input.TTFTP99Ms), + opsNullMetricInt(input.TTFTP50Ms), + opsNullMetricInt(input.TTFTP90Ms), + opsNullMetricInt(input.TTFTP95Ms), + opsNullMetricInt(input.TTFTP99Ms), opsNullFloat64(input.TTFTAvgMs), - opsNullInt(input.TTFTMaxMs), + opsNullMetricInt(input.TTFTMaxMs), opsNullFloat64(input.CPUUsagePercent), - opsNullInt(input.MemoryUsedMB), - opsNullInt(input.MemoryTotalMB), + opsNullMetricInt64(input.MemoryUsedMB), + opsNullMetricInt64(input.MemoryTotalMB), opsNullFloat64(input.MemoryUsagePercent), opsNullBool(input.DBOK), opsNullBool(input.RedisOK), - opsNullInt(input.RedisConnTotal), - opsNullInt(input.RedisConnIdle), + opsNullMetricInt(input.RedisConnTotal), + opsNullMetricInt(input.RedisConnIdle), - opsNullInt(input.DBConnActive), - opsNullInt(input.DBConnIdle), - opsNullInt(input.DBConnWaiting), + opsNullMetricInt(input.DBConnActive), + opsNullMetricInt(input.DBConnIdle), + opsNullMetricInt(input.DBConnWaiting), - opsNullInt(input.GoroutineCount), - opsNullInt(input.ConcurrencyQueueDepth), + opsNullMetricInt(input.GoroutineCount), + opsNullMetricInt(input.ConcurrencyQueueDepth), ) return err } @@ -437,6 +437,20 @@ func opsNullFloat64(v *float64) any { return sql.NullFloat64{Float64: *v, Valid: true} } +func opsNullMetricInt(v *int) any { + if v == nil { + return sql.NullInt64{} + } + return sql.NullInt64{Int64: int64(*v), Valid: true} +} + +func opsNullMetricInt64(v *int64) any { + if v == nil { + return sql.NullInt64{} + } + return sql.NullInt64{Int64: *v, Valid: true} +} + func opsNullTime(v *time.Time) any { if v == nil || v.IsZero() { return sql.NullTime{} diff --git a/backend/internal/repository/ops_repo_metrics_test.go b/backend/internal/repository/ops_repo_metrics_test.go new file mode 100644 index 0000000000..af41069656 --- /dev/null +++ b/backend/internal/repository/ops_repo_metrics_test.go @@ -0,0 +1,59 @@ +package repository + +import ( + "context" + "database/sql/driver" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +func TestInsertSystemMetricsNullableIntegerMetrics(t *testing.T) { + createdAt := time.Date(2026, time.September, 4, 12, 0, 0, 0, time.UTC) + zero := 0 + + tests := []struct { + name string + dbConnActive *int + wantDBActive driver.Value + }{ + {name: "explicit zero is preserved", dbConnActive: &zero, wantDBActive: int64(0)}, + {name: "unavailable metric remains null", dbConnActive: nil, wantDBActive: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + args := make([]driver.Value, 40) + args[0] = createdAt + args[1] = int64(1) + for i := 4; i <= 12; i++ { + args[i] = int64(0) + } + args[35] = tt.wantDBActive + + mock.ExpectExec("INSERT INTO ops_system_metrics"). + WithArgs(args...). + WillReturnResult(sqlmock.NewResult(1, 1)) + + repo := &opsRepository{db: db} + err = repo.InsertSystemMetrics(context.Background(), &service.OpsInsertSystemMetricsInput{ + CreatedAt: createdAt, + DBConnActive: tt.dbConnActive, + }) + if err != nil { + t.Fatalf("InsertSystemMetrics: %v", err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unmet SQL expectations: %v", err) + } + }) + } +} diff --git a/backend/internal/repository/proxy_backup_reference_integration_test.go b/backend/internal/repository/proxy_backup_reference_integration_test.go new file mode 100644 index 0000000000..d2f0664847 --- /dev/null +++ b/backend/internal/repository/proxy_backup_reference_integration_test.go @@ -0,0 +1,68 @@ +//go:build integration + +package repository + +import "github.com/Wei-Shaw/sub2api/internal/service" + +func (s *ProxyExpirySuite) TestBackupReferencesAreDirectedAndShareable() { + backupID := s.mkProxy("shared-backup", service.FallbackModeNone, nil, nil) + firstID := s.mkProxy("first-primary", service.FallbackModeProxy, nil, &backupID) + backup, err := s.repo.GetByID(s.ctx, backupID) + s.Require().NoError(err) + s.Require().Nil(backup.BackupProxyID, "assigning a backup must not overwrite its own configuration") + + secondID := s.mkProxy("second-primary", service.FallbackModeProxy, nil, &backupID) + // Updating the shared backup must not clear its incoming references. + backup.Name = "renamed-shared-backup" + s.Require().NoError(s.repo.Update(s.ctx, backup)) + for _, id := range []int64{firstID, secondID} { + primary, err := s.repo.GetByID(s.ctx, id) + s.Require().NoError(err) + s.Require().Equal(&backupID, primary.BackupProxyID) + } + backup, err = s.repo.GetByID(s.ctx, backupID) + s.Require().NoError(err) + s.Require().Nil(backup.BackupProxyID) +} + +func (s *ProxyExpirySuite) TestBackupUpdateAndClearLeaveOtherReferencesIntact() { + backupID := s.mkProxy("old-backup", service.FallbackModeNone, nil, nil) + replacementID := s.mkProxy("new-backup", service.FallbackModeNone, nil, nil) + firstID := s.mkProxy("primary-to-change", service.FallbackModeProxy, nil, &backupID) + secondID := s.mkProxy("primary-to-keep", service.FallbackModeProxy, nil, &backupID) + first, err := s.repo.GetByID(s.ctx, firstID) + s.Require().NoError(err) + first.BackupProxyID = &replacementID + s.Require().NoError(s.repo.Update(s.ctx, first)) + first, err = s.repo.GetByID(s.ctx, firstID) + s.Require().NoError(err) + s.Require().Equal(&replacementID, first.BackupProxyID) + first.BackupProxyID = nil + first.FallbackMode = service.FallbackModeNone + s.Require().NoError(s.repo.Update(s.ctx, first)) + first, err = s.repo.GetByID(s.ctx, firstID) + s.Require().NoError(err) + s.Require().Nil(first.BackupProxyID) + second, err := s.repo.GetByID(s.ctx, secondID) + s.Require().NoError(err) + s.Require().Equal(&backupID, second.BackupProxyID) + for _, id := range []int64{backupID, replacementID} { + backup, err := s.repo.GetByID(s.ctx, id) + s.Require().NoError(err) + s.Require().Nil(backup.BackupProxyID) + } +} + +func (s *ProxyExpirySuite) TestBackupReferencesSupportDirectedChains() { + lastID := s.mkProxy("chain-last", service.FallbackModeNone, nil, nil) + middleID := s.mkProxy("chain-middle", service.FallbackModeProxy, nil, &lastID) + firstID := s.mkProxy("chain-first", service.FallbackModeProxy, nil, &middleID) + for _, pair := range [][2]int64{{firstID, middleID}, {middleID, lastID}} { + primary, err := s.repo.GetByID(s.ctx, pair[0]) + s.Require().NoError(err) + s.Require().Equal(&pair[1], primary.BackupProxyID) + } + last, err := s.repo.GetByID(s.ctx, lastID) + s.Require().NoError(err) + s.Require().Nil(last.BackupProxyID) +} diff --git a/backend/internal/repository/proxy_repeated_expiry_integration_test.go b/backend/internal/repository/proxy_repeated_expiry_integration_test.go new file mode 100644 index 0000000000..6c84309234 --- /dev/null +++ b/backend/internal/repository/proxy_repeated_expiry_integration_test.go @@ -0,0 +1,69 @@ +//go:build integration + +package repository + +import ( + "encoding/json" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +func (s *ProxyExpirySuite) TestSweep_RepeatedExpiryPreservesOriginalProxy() { + for _, direct := range []bool{false, true} { + s.Run(map[bool]string{false: "next backup", true: "direct"}[direct], func() { + now := time.Now() + past := now.Add(-time.Hour) + soon := now.Add(time.Hour) + later := now.Add(48 * time.Hour) + originalID := s.mkProxy("repeated-original", service.FallbackModeProxy, &past, nil) + middleID := s.mkProxy("repeated-middle", service.FallbackModeDirect, &soon, nil) + lastID := s.mkProxy("repeated-last", service.FallbackModeNone, &later, nil) + // Populate directed foreign keys directly so this regression does not depend + // on the separate ORM backup-edge fix. + _, err := s.tx.ExecContext(s.ctx, `UPDATE proxies SET backup_proxy_id=$1 WHERE id=$2`, middleID, originalID) + s.Require().NoError(err) + if !direct { + _, err = s.tx.ExecContext(s.ctx, `UPDATE proxies SET fallback_mode='proxy',backup_proxy_id=$1 WHERE id=$2`, lastID, middleID) + s.Require().NoError(err) + } + accountID := s.mkAccountWithProxy(originalID) + changed, err := s.repo.SweepExpiredProxies(s.ctx, now) + s.Require().NoError(err) + s.Require().EqualValues(1, changed) + s.Require().Equal(&middleID, s.accountProxyID(accountID)) + + changed, err = s.repo.SweepExpiredProxies(s.ctx, now.Add(2*time.Hour)) + s.Require().NoError(err) + s.Require().EqualValues(1, changed, "accounts already in fallback must still be rerouted") + if direct { + s.Require().Nil(s.accountProxyID(accountID)) + } else { + s.Require().Equal(&lastID, s.accountProxyID(accountID)) + } + var origin *int64 + err = scanSingleRow(s.ctx, s.tx, `SELECT proxy_fallback_origin_id FROM accounts WHERE id=$1`, []any{accountID}, &origin) + s.Require().NoError(err) + s.Require().Equal(&originalID, origin, "manual revert must retain the first original proxy") + + var payloadRaw []byte + err = scanSingleRow(s.ctx, s.tx, `SELECT payload FROM scheduler_outbox WHERE event_type=$1 ORDER BY id DESC LIMIT 1`, []any{service.SchedulerOutboxEventAccountBulkChanged}, &payloadRaw) + s.Require().NoError(err) + var payload struct { + AccountIDs []int64 `json:"account_ids"` + } + s.Require().NoError(json.Unmarshal(payloadRaw, &payload)) + s.Require().Equal([]int64{accountID}, payload.AccountIDs) + + changed, err = s.repo.SweepExpiredProxies(s.ctx, now.Add(3*time.Hour)) + s.Require().NoError(err) + s.Require().Zero(changed, "repeating the scan must be idempotent") + accounts := newAccountRepositoryWithSQL(s.tx.Client(), s.tx, nil) + s.Require().NoError(accounts.RevertProxyFallback(s.ctx, accountID)) + s.Require().Equal(&originalID, s.accountProxyID(accountID)) + err = scanSingleRow(s.ctx, s.tx, `SELECT proxy_fallback_origin_id FROM accounts WHERE id=$1`, []any{accountID}, &origin) + s.Require().NoError(err) + s.Require().Nil(origin) + }) + } +} diff --git a/backend/internal/repository/proxy_repo.go b/backend/internal/repository/proxy_repo.go index 679b22b26f..07c92046bd 100644 --- a/backend/internal/repository/proxy_repo.go +++ b/backend/internal/repository/proxy_repo.go @@ -742,27 +742,29 @@ func (r *proxyRepository) sweepOneExpiredProxyOnExec(ctx context.Context, exec s rows *sql.Rows err error ) + // Match the current proxy even after an earlier fallback. Keep the first + // origin so manual revert still restores the originally assigned proxy. if target == nil { rows, err = exec.QueryContext(ctx, ` - UPDATE accounts SET proxy_id=NULL, proxy_fallback_origin_id=$1, + UPDATE accounts SET proxy_id=NULL, proxy_fallback_origin_id=COALESCE(proxy_fallback_origin_id,$1), extra=CASE WHEN type='apikey' AND extra ? 'upstream_billing_probe' THEN extra - 'upstream_billing_probe' ELSE extra END, updated_at=NOW() - WHERE proxy_id=$1 AND proxy_fallback_origin_id IS NULL AND deleted_at IS NULL + WHERE proxy_id=$1 AND deleted_at IS NULL RETURNING id`, proxyID) } else { rows, err = exec.QueryContext(ctx, ` - UPDATE accounts SET proxy_id=$2, proxy_fallback_origin_id=$1, + UPDATE accounts SET proxy_id=$2, proxy_fallback_origin_id=COALESCE(proxy_fallback_origin_id,$1), extra=CASE WHEN type='apikey' AND extra ? 'upstream_billing_probe' THEN extra - 'upstream_billing_probe' ELSE extra END, updated_at=NOW() - WHERE proxy_id=$1 AND proxy_fallback_origin_id IS NULL AND deleted_at IS NULL + WHERE proxy_id=$1 AND deleted_at IS NULL RETURNING id`, proxyID, *target) } if err != nil { 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 1f0ea83d34..7d0eb8957b 100644 --- a/backend/internal/repository/proxy_repo_upstream_billing_probe_test.go +++ b/backend/internal/repository/proxy_repo_upstream_billing_probe_test.go @@ -29,11 +29,8 @@ func TestProxyUpdateInvalidatesBoundProbeSnapshotsAndEnqueuesOutboxAtomically(t WillReturnRows(sqlmock.NewRows([]string{"protocol", "host", "port", "username", "password", "status"}). AddRow("http", "old.example", 8080, "user", "pass", service.StatusActive)) mock.ExpectExec(`(?s)UPDATE "proxies" SET`).WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec(`UPDATE "proxies" SET "backup_proxy_id" = NULL WHERE "backup_proxy_id" = \$1`). - 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'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek'\).*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', 'kimi', 'zhipu', 'deepseek', 'minimax'\).*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)")). @@ -72,11 +69,8 @@ func TestProxyUpdateRollsBackWhenProbeInvalidationOutboxFails(t *testing.T) { WillReturnRows(sqlmock.NewRows([]string{"protocol", "host", "port", "username", "password", "status"}). AddRow("http", "old.example", 8080, "", "", service.StatusActive)) mock.ExpectExec(`(?s)UPDATE "proxies" SET`).WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec(`UPDATE "proxies" SET "backup_proxy_id" = NULL WHERE "backup_proxy_id" = \$1`). - 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'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic', 'kimi', 'zhipu', 'deepseek'\).*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', 'kimi', 'zhipu', 'deepseek', 'minimax'\).*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)")). @@ -105,9 +99,6 @@ func TestProxyUpdateSkipsProbeInvalidationForNonIdentityChange(t *testing.T) { WillReturnRows(sqlmock.NewRows([]string{"protocol", "host", "port", "username", "password", "status"}). AddRow("http", "same.example", 8080, "", "", service.StatusActive)) mock.ExpectExec(`(?s)UPDATE "proxies" SET`).WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec(`UPDATE "proxies" SET "backup_proxy_id" = NULL WHERE "backup_proxy_id" = \$1`). - WithArgs(int64(9)). - WillReturnResult(sqlmock.NewResult(0, 0)) expectProxyUpdateReload(mock, 9, "same.example", "", "") mock.ExpectCommit() diff --git a/backend/internal/repository/redeem_cache.go b/backend/internal/repository/redeem_cache.go index 831aaf570a..9d0430724a 100644 --- a/backend/internal/repository/redeem_cache.go +++ b/backend/internal/repository/redeem_cache.go @@ -10,11 +10,22 @@ import ( ) const ( - redeemRateLimitKeyPrefix = "redeem:ratelimit:" + redeemRateLimitKeyPrefix = "redeem:ratelimit:v2:" redeemLockKeyPrefix = "redeem:lock:" - redeemRateLimitDuration = 24 * time.Hour + redeemRateLimitWindow = 10 * time.Minute ) +var incrementRedeemAttemptScript = redis.NewScript(` +local current = redis.call('INCR', KEYS[1]) +local ttl = redis.call('PTTL', KEYS[1]) + +if current == 1 or ttl == -1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) +end + +return current +`) + // redeemRateLimitKey generates the Redis key for redeem attempt rate limiting. func redeemRateLimitKey(userID int64) string { return fmt.Sprintf("%s%d", redeemRateLimitKeyPrefix, userID) @@ -44,11 +55,12 @@ func (c *redeemCache) GetRedeemAttemptCount(ctx context.Context, userID int64) ( func (c *redeemCache) IncrementRedeemAttemptCount(ctx context.Context, userID int64) error { key := redeemRateLimitKey(userID) - pipe := c.rdb.Pipeline() - pipe.Incr(ctx, key) - pipe.Expire(ctx, key, redeemRateLimitDuration) - _, err := pipe.Exec(ctx) - return err + return incrementRedeemAttemptScript.Run( + ctx, + c.rdb, + []string{key}, + redeemRateLimitWindow.Milliseconds(), + ).Err() } func (c *redeemCache) AcquireRedeemLock(ctx context.Context, code string, ttl time.Duration) (bool, error) { diff --git a/backend/internal/repository/redeem_cache_integration_test.go b/backend/internal/repository/redeem_cache_integration_test.go index 6398a80141..fb7e207431 100644 --- a/backend/internal/repository/redeem_cache_integration_test.go +++ b/backend/internal/repository/redeem_cache_integration_test.go @@ -39,7 +39,35 @@ func (s *RedeemCacheSuite) TestIncrementAndGetRedeemAttemptCount() { ttl, err := s.rdb.TTL(s.ctx, key).Result() require.NoError(s.T(), err, "TTL") - s.AssertTTLWithin(ttl, 1*time.Second, redeemRateLimitDuration) + s.AssertTTLWithin(ttl, redeemRateLimitWindow-time.Second, redeemRateLimitWindow) +} + +func (s *RedeemCacheSuite) TestIncrementDoesNotRefreshFixedWindow() { + userID := int64(3) + key := redeemRateLimitKey(userID) + shortTTL := 5 * time.Minute + require.NoError(s.T(), s.cache.IncrementRedeemAttemptCount(s.ctx, userID)) + require.NoError(s.T(), s.rdb.PExpire(s.ctx, key, shortTTL).Err()) + require.NoError(s.T(), s.cache.IncrementRedeemAttemptCount(s.ctx, userID)) + ttl, err := s.rdb.PTTL(s.ctx, key).Result() + require.NoError(s.T(), err) + s.AssertTTLWithin(ttl, shortTTL-time.Second, shortTTL) +} + +func (s *RedeemCacheSuite) TestIncrementRepairsMissingTTL() { + userID := int64(4) + key := redeemRateLimitKey(userID) + require.NoError(s.T(), s.rdb.Set(s.ctx, key, 5, 0).Err()) + ttl, err := s.rdb.PTTL(s.ctx, key).Result() + require.NoError(s.T(), err) + require.Equal(s.T(), time.Duration(-1), ttl) + require.NoError(s.T(), s.cache.IncrementRedeemAttemptCount(s.ctx, userID)) + count, err := s.cache.GetRedeemAttemptCount(s.ctx, userID) + require.NoError(s.T(), err) + require.Equal(s.T(), 6, count) + ttl, err = s.rdb.PTTL(s.ctx, key).Result() + require.NoError(s.T(), err) + s.AssertTTLWithin(ttl, redeemRateLimitWindow-time.Second, redeemRateLimitWindow) } func (s *RedeemCacheSuite) TestMultipleIncrements() { diff --git a/backend/internal/repository/redeem_cache_test.go b/backend/internal/repository/redeem_cache_test.go index 9b547b7485..8e637241ef 100644 --- a/backend/internal/repository/redeem_cache_test.go +++ b/backend/internal/repository/redeem_cache_test.go @@ -18,22 +18,22 @@ func TestRedeemRateLimitKey(t *testing.T) { { name: "normal_user_id", userID: 123, - expected: "redeem:ratelimit:123", + expected: "redeem:ratelimit:v2:123", }, { name: "zero_user_id", userID: 0, - expected: "redeem:ratelimit:0", + expected: "redeem:ratelimit:v2:0", }, { name: "negative_user_id", userID: -1, - expected: "redeem:ratelimit:-1", + expected: "redeem:ratelimit:v2:-1", }, { name: "max_int64", userID: math.MaxInt64, - expected: "redeem:ratelimit:9223372036854775807", + expected: "redeem:ratelimit:v2:9223372036854775807", }, } diff --git a/backend/internal/repository/session_limit_cache.go b/backend/internal/repository/session_limit_cache.go index 1711256937..4b38e58f50 100644 --- a/backend/internal/repository/session_limit_cache.go +++ b/backend/internal/repository/session_limit_cache.go @@ -216,6 +216,15 @@ func (c *sessionLimitCache) RegisterSession(ctx context.Context, accountID int64 return result == 1, nil } +// UnregisterSession 立即移除会话注册(不等待空闲超时) +// 请求最终失败时调用:上游从未服务该会话,继续占槽会卡住 max_sessions 受限的账号 +func (c *sessionLimitCache) UnregisterSession(ctx context.Context, accountID int64, sessionUUID string) error { + if sessionUUID == "" { + return nil + } + return c.rdb.ZRem(ctx, sessionLimitKey(accountID), sessionUUID).Err() +} + // RefreshSession 刷新会话时间戳 func (c *sessionLimitCache) RefreshSession(ctx context.Context, accountID int64, sessionUUID string, idleTimeout time.Duration) error { if sessionUUID == "" { diff --git a/backend/internal/repository/session_limit_cache_integration_test.go b/backend/internal/repository/session_limit_cache_integration_test.go new file mode 100644 index 0000000000..7f07300cb3 --- /dev/null +++ b/backend/internal/repository/session_limit_cache_integration_test.go @@ -0,0 +1,92 @@ +//go:build integration + +package repository + +import ( + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/suite" +) + +type SessionLimitCacheSuite struct { + IntegrationRedisSuite + cache service.SessionLimitCache +} + +func (s *SessionLimitCacheSuite) SetupTest() { + s.IntegrationRedisSuite.SetupTest() + s.cache = NewSessionLimitCache(s.rdb, 5) +} + +// TestUnregisterSession_FreesSlotForNewSession 复现并验证修复场景: +// max_sessions=1 的账号上,会话 A 注册后若请求转发失败,会话槽会被 A 占住 +// 整个空闲超时窗口(默认 5 分钟),导致窗口内新会话 B 全部被拒。 +// UnregisterSession 必须立即释放槽位,让 B 无需等待空闲超时即可注册。 +func (s *SessionLimitCacheSuite) TestUnregisterSession_FreesSlotForNewSession() { + const accountID = int64(101) + maxSessions := 1 + idleTimeout := 5 * time.Minute + + // 会话 A 注册成功(请求开始转发) + allowed, err := s.cache.RegisterSession(s.ctx, accountID, "session-A", maxSessions, idleTimeout) + s.RequireNoError(err, "RegisterSession A") + s.True(allowed, "首个会话应被允许") + + // 转发失败后的新会话 B 在空闲超时窗口内被拒(修复前的行为) + allowed, err = s.cache.RegisterSession(s.ctx, accountID, "session-B", maxSessions, idleTimeout) + s.RequireNoError(err, "RegisterSession B before release") + s.False(allowed, "槽位被 A 占用时新会话应被拒绝") + + // 请求最终失败:立即释放 A 的会话注册(不等待空闲超时) + s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "session-A"), "UnregisterSession A") + + // A 释放后 B 立即可注册,无需等待空闲超时 + allowed, err = s.cache.RegisterSession(s.ctx, accountID, "session-B", maxSessions, idleTimeout) + s.RequireNoError(err, "RegisterSession B after release") + s.True(allowed, "释放后新会话应立即可注册") + + count, err := s.cache.GetActiveSessionCount(s.ctx, accountID) + s.RequireNoError(err, "GetActiveSessionCount") + s.Equal(1, count, "释放后活跃会话数应为 1(仅 B)") +} + +// TestUnregisterSession_IdempotentAndMissing 验证释放操作幂等: +// 重复释放与释放不存在的会话均不应报错。 +func (s *SessionLimitCacheSuite) TestUnregisterSession_IdempotentAndMissing() { + const accountID = int64(102) + + s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "missing"), "释放不存在的会话") + s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "missing"), "重复释放") + + s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, ""), "空 sessionUUID 应为 no-op") +} + +// TestUnregisterSession_OnlyTargetsSpecifiedSession 验证释放只影响目标会话, +// 不影响同账号上的其他活跃会话。 +func (s *SessionLimitCacheSuite) TestUnregisterSession_OnlyTargetsSpecifiedSession() { + const accountID = int64(103) + maxSessions := 2 + idleTimeout := 5 * time.Minute + + for _, sid := range []string{"session-A", "session-B"} { + allowed, err := s.cache.RegisterSession(s.ctx, accountID, sid, maxSessions, idleTimeout) + s.RequireNoError(err, "RegisterSession "+sid) + s.True(allowed) + } + + s.RequireNoError(s.cache.UnregisterSession(s.ctx, accountID, "session-A"), "UnregisterSession A") + + active, err := s.cache.IsSessionActive(s.ctx, accountID, "session-A") + s.RequireNoError(err, "IsSessionActive A") + s.False(active, "A 应已释放") + + active, err = s.cache.IsSessionActive(s.ctx, accountID, "session-B") + s.RequireNoError(err, "IsSessionActive B") + s.True(active, "B 不应受影响") +} + +func TestSessionLimitCacheSuite(t *testing.T) { + suite.Run(t, new(SessionLimitCacheSuite)) +} diff --git a/backend/internal/repository/usage_log_repo_insert.go b/backend/internal/repository/usage_log_repo_insert.go index f70706d481..e004433f3d 100644 --- a/backend/internal/repository/usage_log_repo_insert.go +++ b/backend/internal/repository/usage_log_repo_insert.go @@ -82,6 +82,7 @@ var usageLogInsertArgTypes = [...]string{ "text", // billing_tier "text", // billing_mode "numeric", // account_stats_cost + "text", // upstream_request_id "text", // session_id "boolean", // native_compaction_v2 "timestamptz", // created_at @@ -282,6 +283,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -291,7 +293,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, - $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61 + $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62 ) ON CONFLICT (request_id, api_key_id) DO NOTHING RETURNING id, created_at @@ -741,6 +743,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -835,6 +838,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -898,6 +902,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -1001,6 +1006,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -1090,6 +1096,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -1153,6 +1160,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) ( billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -1224,6 +1232,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared billing_tier, billing_mode, account_stats_cost, + upstream_request_id, session_id, native_compaction_v2, created_at @@ -1233,7 +1242,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, - $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61 + $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57, $58, $59, $60, $61, $62 ) ON CONFLICT (request_id, api_key_id) DO NOTHING `, prepared.args...) @@ -1275,6 +1284,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { modelMappingChain := nullString(log.ModelMappingChain) billingTier := nullString(log.BillingTier) billingMode := nullString(log.BillingMode) + upstreamRequestID := nullString(log.UpstreamRequestID) sessionID := nullString(log.SessionID) requestedModel := strings.TrimSpace(log.RequestedModel) if requestedModel == "" { @@ -1353,6 +1363,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared { billingTier, billingMode, log.AccountStatsCost, // account_stats_cost + upstreamRequestID, // upstream_request_id sessionID, // session_id log.NativeCompactionV2, createdAt, diff --git a/backend/internal/repository/usage_log_repo_insert_shape_unit_test.go b/backend/internal/repository/usage_log_repo_insert_shape_unit_test.go new file mode 100644 index 0000000000..74f6aafb60 --- /dev/null +++ b/backend/internal/repository/usage_log_repo_insert_shape_unit_test.go @@ -0,0 +1,145 @@ +//go:build unit + +package repository + +import ( + "context" + "database/sql" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +var ( + usageLogStaticInsertShapeRe = regexp.MustCompile(`(?s)INSERT INTO usage_logs \((.*?)\) VALUES \((.*?)\)`) + usageLogPlaceholderRe = regexp.MustCompile(`\$(\d+)`) +) + +// newSQLCapturingMock 返回把实际下发 SQL 记录到 captured 的 sqlmock;语句一律视为匹配, +// 参数仍由 WithArgs 校验。 +func newSQLCapturingMock(t *testing.T, captured *[]string) (*sql.DB, sqlmock.Sqlmock) { + t.Helper() + matcher := sqlmock.QueryMatcherFunc(func(_, actualSQL string) error { + *captured = append(*captured, actualSQL) + return nil + }) + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(matcher)) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + return db, mock +} + +// requireStaticInsertMatchesArgTypes 断言手写的 INSERT:列清单长度与 VALUES 占位符数量 +// 都等于 usageLogInsertArgTypes,且占位符恰为 $1..$N 各出现一次。 +func requireStaticInsertMatchesArgTypes(t *testing.T, query string) { + t.Helper() + m := usageLogStaticInsertShapeRe.FindStringSubmatch(query) + require.Len(t, m, 3, "unrecognised INSERT shape:\n%s", query) + + want := len(usageLogInsertArgTypes) + columns := 0 + for _, col := range strings.Split(m[1], ",") { + if strings.TrimSpace(col) != "" { + columns++ + } + } + require.Equal(t, want, columns, "INSERT column list must match usageLogInsertArgTypes") + + seen := make(map[int]struct{}, want) + for _, ph := range usageLogPlaceholderRe.FindAllStringSubmatch(m[2], -1) { + n, err := strconv.Atoi(ph[1]) + require.NoError(t, err) + _, dup := seen[n] + require.False(t, dup, "duplicate placeholder $%d", n) + seen[n] = struct{}{} + } + require.Len(t, seen, want, "VALUES placeholder count must match usageLogInsertArgTypes") + for i := 1; i <= want; i++ { + _, ok := seen[i] + require.True(t, ok, "missing placeholder $%d", i) + } +} + +// TestUsageLogStaticInsertShape_PlaceholdersMatchArgTypes 覆盖两条不经占位符生成器、 +// 直接手写 $1..$N 的 INSERT 路径,防止加列后漏补占位符只在集成测试才暴露。 +func TestUsageLogStaticInsertShape_PlaceholdersMatchArgTypes(t *testing.T) { + upstreamRequestID := "20260902080329-oneapi" + log := &service.UsageLog{ + UserID: 1, + APIKeyID: 2, + AccountID: 3, + RequestID: "client:insert-shape", + UpstreamRequestID: &upstreamRequestID, + Model: "claude-3", + InputTokens: 10, + CreatedAt: time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC), + } + prepared := prepareUsageLogInsert(log) + args := anySliceToDriverValues(prepared.args) + + t.Run("createSingle", func(t *testing.T) { + var captured []string + db, mock := newSQLCapturingMock(t, &captured) + repo := &usageLogRepository{sql: db} + + mock.ExpectQuery("INSERT INTO usage_logs"). + WithArgs(args...). + WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(int64(1), log.CreatedAt)) + + inserted, err := repo.Create(context.Background(), log) + require.NoError(t, err) + require.True(t, inserted) + require.NoError(t, mock.ExpectationsWereMet()) + require.Len(t, captured, 1) + requireStaticInsertMatchesArgTypes(t, captured[0]) + }) + + t.Run("execUsageLogInsertNoResult", func(t *testing.T) { + var captured []string + db, mock := newSQLCapturingMock(t, &captured) + + mock.ExpectExec("INSERT INTO usage_logs"). + WithArgs(args...). + WillReturnResult(sqlmock.NewResult(0, 1)) + + require.NoError(t, execUsageLogInsertNoResult(context.Background(), db, prepared)) + require.NoError(t, mock.ExpectationsWereMet()) + require.Len(t, captured, 1) + requireStaticInsertMatchesArgTypes(t, captured[0]) + }) +} + +// TestPrepareUsageLogInsert_UpstreamRequestIDArgWiring 把 upstream_request_id 钉在 +// session_id 之前,与参数类型表保持同位;缺失时落 NULL 而不是空串。 +func TestPrepareUsageLogInsert_UpstreamRequestIDArgWiring(t *testing.T) { + upstreamRequestID := "req_upstream_123" + prepared := prepareUsageLogInsert(&service.UsageLog{ + UserID: 1, + APIKeyID: 2, + RequestID: "client:wiring", + Model: "gpt-5", + UpstreamRequestID: &upstreamRequestID, + CreatedAt: time.Now().UTC(), + }) + require.Len(t, prepared.args, len(usageLogInsertArgTypes)) + + idx := len(prepared.args) - 4 + arg, ok := prepared.args[idx].(sql.NullString) + require.True(t, ok, "upstream_request_id arg should be sql.NullString, got %T", prepared.args[idx]) + require.True(t, arg.Valid) + require.Equal(t, upstreamRequestID, arg.String) + require.Equal(t, "text", usageLogInsertArgTypes[idx]) + + absent := prepareUsageLogInsert(&service.UsageLog{UserID: 1, APIKeyID: 2, RequestID: "client:absent", Model: "gpt-5", CreatedAt: time.Now().UTC()}) + nullArg, ok := absent.args[idx].(sql.NullString) + require.True(t, ok) + require.False(t, nullArg.Valid, "absent upstream request id must be NULL") + + require.Contains(t, usageLogSelectColumns, "upstream_request_id") +} diff --git a/backend/internal/repository/usage_log_repo_query.go b/backend/internal/repository/usage_log_repo_query.go index 556dd3ef88..5f637e1e52 100644 --- a/backend/internal/repository/usage_log_repo_query.go +++ b/backend/internal/repository/usage_log_repo_query.go @@ -19,7 +19,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/service" ) -const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, native_compaction_v2, created_at" +const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, upstream_response_model, upstream_model_mismatch, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, requested_reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, upstream_request_id, session_id, native_compaction_v2, created_at" func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) { query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1" @@ -499,6 +499,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e billingTier sql.NullString billingMode sql.NullString accountStatsCost sql.NullFloat64 + upstreamRequestID sql.NullString sessionID sql.NullString nativeCompactionV2 bool createdAt time.Time @@ -564,6 +565,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e &billingTier, &billingMode, &accountStatsCost, + &upstreamRequestID, &sessionID, &nativeCompactionV2, &createdAt, @@ -700,6 +702,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e if sessionID.Valid { log.SessionID = &sessionID.String } + if upstreamRequestID.Valid { + log.UpstreamRequestID = &upstreamRequestID.String + } return log, nil } diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go index 434bb7163f..87d6c8672c 100644 --- a/backend/internal/repository/usage_log_repo_request_type_test.go +++ b/backend/internal/repository/usage_log_repo_request_type_test.go @@ -99,6 +99,7 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) { sqlmock.AnyArg(), // billing_tier sqlmock.AnyArg(), // billing_mode sqlmock.AnyArg(), // account_stats_cost + sqlmock.AnyArg(), // upstream_request_id sqlmock.AnyArg(), // session_id log.NativeCompactionV2, createdAt, @@ -193,6 +194,7 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) { sqlmock.AnyArg(), // billing_tier sqlmock.AnyArg(), // billing_mode sqlmock.AnyArg(), // account_stats_cost + sqlmock.AnyArg(), // upstream_request_id sqlmock.AnyArg(), // session_id log.NativeCompactionV2, createdAt, @@ -955,6 +957,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, sql.NullString{}, sql.NullFloat64{}, + sql.NullString{}, // upstream_request_id sql.NullString{}, false, // native_compaction_v2 now, @@ -1034,6 +1037,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // billing_tier sql.NullString{}, // billing_mode sql.NullFloat64{}, // account_stats_cost + sql.NullString{}, // upstream_request_id sql.NullString{}, // session_id false, // native_compaction_v2 now, @@ -1096,6 +1100,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // billing_tier sql.NullString{}, // billing_mode sql.NullFloat64{}, // account_stats_cost + sql.NullString{}, // upstream_request_id sql.NullString{}, // session_id true, // native_compaction_v2 now, @@ -1159,6 +1164,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) { sql.NullString{}, // billing_tier sql.NullString{}, // billing_mode sql.NullFloat64{}, // account_stats_cost + sql.NullString{}, // upstream_request_id sql.NullString{}, // session_id false, // native_compaction_v2 now, diff --git a/backend/internal/repository/user_msg_queue_cache.go b/backend/internal/repository/user_msg_queue_cache.go index 9b7707614c..8dfce95123 100644 --- a/backend/internal/repository/user_msg_queue_cache.go +++ b/backend/internal/repository/user_msg_queue_cache.go @@ -186,10 +186,12 @@ func (c *userMsgQueueCache) ReconcileExpiredLockCandidates(ctx context.Context, if err != nil { return 0, err } - members, err := c.rdb.ZRangeByScore(ctx, umqLockIndexKey, &redis.ZRangeBy{ - Min: "-inf", - Max: strconv.FormatInt(nowMs, 10), - Count: int64(maxCount), + members, err := c.rdb.ZRangeArgs(ctx, redis.ZRangeArgs{ + Key: umqLockIndexKey, + Start: "-inf", + Stop: strconv.FormatInt(nowMs, 10), + ByScore: true, + Count: int64(maxCount), }).Result() if err != nil { return 0, fmt.Errorf("umq read lock index: %w", err) diff --git a/backend/internal/repository/user_platform_quota_repo_integration_test.go b/backend/internal/repository/user_platform_quota_repo_integration_test.go index 1bd4b08786..c9433adc87 100644 --- a/backend/internal/repository/user_platform_quota_repo_integration_test.go +++ b/backend/internal/repository/user_platform_quota_repo_integration_test.go @@ -116,11 +116,12 @@ func TestUserPlatformQuotaRepository_BulkInsertInitial_CNProvidersAllowed(t *tes {UserID: userID, Platform: "kimi", DailyLimitUSD: &daily}, {UserID: userID, Platform: "zhipu"}, {UserID: userID, Platform: "deepseek"}, + {UserID: userID, Platform: "minimax"}, } require.NoError(t, repo.BulkInsertInitial(txCtx, records), - "kimi/zhipu/deepseek 平台应可写入(迁移 224 后 CHECK 约束已含国产供应商)") + "kimi/zhipu/deepseek/minimax 平台应可写入(CHECK 约束已含国产供应商)") - for _, platform := range []string{"kimi", "zhipu", "deepseek"} { + for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax"} { rec, err := repo.GetByUserPlatform(txCtx, userID, platform) require.NoError(t, err) require.NotNil(t, rec, "%s 配额行应已写入", platform) diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index e6904f833c..eae41bde41 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -137,6 +137,7 @@ var ProviderSet = wire.NewSet( NewRefreshTokenCache, NewErrorPassthroughCache, NewTLSFingerprintProfileCache, + NewChannelCache, NewContentModerationHashCache, // Encryptors diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index d6a96e5ecf..7ef6745960 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -863,7 +863,7 @@ func TestAPIContracts(t *testing.T) { "force_email_on_third_party_signup": false, "default_concurrency": 5, "default_balance": 1.25, - "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, + "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, "auth_source_default_email_platform_quotas": null, "auth_source_default_github_platform_quotas": null, "auth_source_default_google_platform_quotas": null, @@ -992,6 +992,7 @@ func TestAPIContracts(t *testing.T) { "channel_monitor_mode": "v1", "channel_monitor_hide_throughput": true, "channel_monitor_show_quota": false, + "channel_monitor_hide_user_ranking": false, "channel_monitor_default_interval_seconds": 60, "available_channels_enabled": false, "model_plaza_enabled": false, @@ -1180,7 +1181,7 @@ func TestAPIContracts(t *testing.T) { "purchase_subscription_url": "", "table_default_page_size": 20, "table_page_size_options": [10, 20, 50], - "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, + "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, "auth_source_default_email_platform_quotas": null, "auth_source_default_github_platform_quotas": null, "auth_source_default_google_platform_quotas": null, @@ -1305,6 +1306,7 @@ func TestAPIContracts(t *testing.T) { "channel_monitor_mode": "v1", "channel_monitor_hide_throughput": true, "channel_monitor_show_quota": false, + "channel_monitor_hide_user_ranking": false, "channel_monitor_default_interval_seconds": 60, "available_channels_enabled": false, "model_plaza_enabled": false, @@ -1476,7 +1478,7 @@ func newContractDeps(t *testing.T) *contractDeps { settingRepo := newStubSettingRepo() settingService := service.NewSettingService(settingRepo, cfg) - adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + adminService := service.NewAdminService(nil, userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil, nil) apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService) usageHandler := handler.NewUsageHandler(usageService, apiKeyService, nil, nil) @@ -1789,6 +1791,10 @@ func (stubGroupRepo) DeleteCascade(ctx context.Context, id int64) ([]int64, erro return nil, errors.New("not implemented") } +func (stubGroupRepo) DeleteCascadeIfEmpty(ctx context.Context, id int64) ([]int64, error) { + return nil, errors.New("not implemented") +} + func (stubGroupRepo) List(ctx context.Context, params pagination.PaginationParams) ([]service.Group, *pagination.PaginationResult, error) { return nil, nil, errors.New("not implemented") } @@ -2941,7 +2947,7 @@ var ( _ service.UserRepository = (*stubUserRepo)(nil) _ service.APIKeyRepository = (*stubApiKeyRepo)(nil) _ service.APIKeyCache = (*stubApiKeyCache)(nil) - _ service.GroupRepository = (*stubGroupRepo)(nil) + _ service.AdminGroupRepository = (*stubGroupRepo)(nil) _ service.UserSubscriptionRepository = (*stubUserSubscriptionRepo)(nil) _ service.UsageLogRepository = (*stubUsageLogRepo)(nil) _ service.SettingRepository = (*stubSettingRepo)(nil) diff --git a/backend/internal/server/middleware/group_model_allowlist.go b/backend/internal/server/middleware/group_model_allowlist.go new file mode 100644 index 0000000000..3f5e61e585 --- /dev/null +++ b/backend/internal/server/middleware/group_model_allowlist.go @@ -0,0 +1,165 @@ +package middleware + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" + "github.com/Wei-Shaw/sub2api/internal/pkg/requestmodel" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +// GroupModelAllowlist 是分组级模型白名单准入中间件。 +// +// 挂载位置:每条网关链的 apiKeyAuth 之后、compositeTarget 之前—— +// 保证校验发生在合成路由改写与调度之前,且只看客户端书写的公开模型名。 +// +// 行为: +// - 快速路径:未绑定分组或白名单未开启时直接放行,不读请求体。 +// - Responses WebSocket 入口跳过(首帧与后续 turn 由 ResponsesWebSocket 逐帧 +// 校验);Grok Realtime 的升级请求模型固定在查询参数里,仍走中间件校验, +// 其他路由伪造 Upgrade 头不得绕过校验。 +// - GET/DELETE:从路由参数(model / modelAction)或查询参数(/realtime 的 +// model)提取模型;提取不到放行,由 handler 决定是否报「model is required」。 +// - 带请求体的方法:读体(PrereadBody 回填,下游零拷贝)、提取 JSON +// `model`/`session.model` 或 multipart `model`/`session` 后回填请求体。 +// - 拒绝:按入口协议格式返回 404,并标记运维业务限流原因 +// local_model_configuration 与 ingress 拒绝原因 model_not_allowed。 +func GroupModelAllowlist() gin.HandlerFunc { + return func(c *gin.Context) { + apiKey, ok := GetAPIKeyFromContext(c) + if !ok || apiKey == nil || apiKey.Group == nil || !apiKey.Group.ModelAllowlistEnabled() { + c.Next() + return + } + allowlist := apiKey.Group.ModelAllowlist + if c.Request == nil { + c.Next() + return + } + + if isResponsesWebSocketRoute(c) { + // Responses WS 长连接由 ResponsesWebSocket 校验首帧与每个 response.create。 + c.Next() + return + } + // models 收集该请求全部可被下游解析器绑定到的模型值(路径参数/查询参数 + // 单值;请求体候选集含重复键与大小写变体),逐一校验,任一未命中即拒绝。 + var models []string + if model := groupModelAllowlistModelFromParams(c); model != "" { + models = []string{model} + } else { + switch c.Request.Method { + case http.MethodPost, http.MethodPut, http.MethodPatch: + candidates, done := groupModelAllowlistModelsFromBody(c) + if !done { + // 请求体读取失败(如超限 413)已写出响应。 + return + } + models = candidates + } + if len(models) == 0 { + // Grok Realtime 升级请求把模型固定在查询参数里。 + if model := strings.TrimSpace(c.Query("model")); model != "" { + models = []string{model} + } + } + } + + blocked := "" + for _, candidate := range models { + if !allowlist.Allows(candidate) { + blocked = candidate + break + } + } + if blocked == "" { + c.Next() + return + } + + service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration) + MarkIngressRejected(c, IngressRejectModelNotAllowed) + groupModelAllowlistErrorWriter(c)(c, http.StatusNotFound, fmt.Sprintf("Model %q is not available for this group", blocked)) + c.Abort() + } +} + +// isResponsesWebSocketRoute 判断当前请求是否命中 OpenAI Responses WebSocket +// 入口。只有这三条路由的模型在升级后的帧里,交给 handler 逐帧校验;其他路由 +// 即使携带 WebSocket Upgrade 头也必须经过本中间件校验。 +func isResponsesWebSocketRoute(c *gin.Context) bool { + if c.Request == nil || c.Request.Method != http.MethodGet { + return false + } + switch c.FullPath() { + case "/v1/responses", "/responses", "/backend-api/codex/responses": + return true + } + return false +} + +// groupModelAllowlistModelsFromBody 读取请求体并提取客户端模型名,随后把请求体 +// 回填(PrereadBody),保证后续 handler 零拷贝重读。读取失败按现有合成中间件 +// 的方式返回 400/413(返回 false 表示已写出响应并 Abort)。 +// +// 下游同时存在 gjson(首个、大小写敏感)、encoding/json 绑定(末值、大小写 +// 不敏感)与 multipart 表单(首/末字段)三类解析器,这里返回「任一解析器可能 +// 绑定到的全部模型值」,调用方必须逐一校验,任一未命中即拒绝。 +func groupModelAllowlistModelsFromBody(c *gin.Context) ([]string, bool) { + body, err := httputil.ReadRequestBodyWithPrealloc(c.Request) + if err != nil { + status := http.StatusBadRequest + message := "Failed to read request body" + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + status = http.StatusRequestEntityTooLarge + message = "Request body is too large" + } + c.JSON(status, gin.H{"error": gin.H{"type": "invalid_request_error", "message": message}}) + c.Abort() + return nil, false + } + requestmodel.ResetRequestBody(c.Request, body) + return requestmodel.FromBodyCandidates(c.FullPath(), c.GetHeader("Content-Type"), body), true +} + +// groupModelAllowlistModelFromParams 从路由参数提取模型名:Gemini 原生 URL 的 +// `model` / `modelAction`(去掉 `:action` 后缀)。提取不到返回空串, +// 由调用方决定是否继续读请求体或查询参数。 +func groupModelAllowlistModelFromParams(c *gin.Context) string { + if model := strings.TrimSpace(c.Param("model")); model != "" { + return model + } + if modelAction := strings.TrimSpace(c.Param("modelAction")); modelAction != "" { + modelAction = strings.TrimPrefix(modelAction, "/") + if idx := strings.LastIndex(modelAction, ":"); idx >= 0 { + return strings.TrimSpace(modelAction[:idx]) + } + return modelAction + } + return "" +} + +// groupModelAllowlistErrorWriter 按入口协议选择错误格式: +// Gemini 原生(/v1beta、/antigravity/v1beta)用 Google 格式; +// Messages 入口(含根路径别名与 /antigravity/v1)用 Anthropic 格式; +// 其余(OpenAI 兼容入口)用 OpenAI 格式。 +func groupModelAllowlistErrorWriter(c *gin.Context) GatewayErrorWriter { + path := "" + if c.Request != nil && c.Request.URL != nil { + path = c.Request.URL.Path + } + switch { + case strings.HasPrefix(path, "/v1beta") || strings.HasPrefix(path, "/antigravity/v1beta"): + return GoogleErrorWriter + case strings.Contains(path, "/messages"): + return AnthropicErrorWriter + default: + return OpenAIErrorWriter + } +} diff --git a/backend/internal/server/middleware/group_model_allowlist_test.go b/backend/internal/server/middleware/group_model_allowlist_test.go new file mode 100644 index 0000000000..4015dbc822 --- /dev/null +++ b/backend/internal/server/middleware/group_model_allowlist_test.go @@ -0,0 +1,501 @@ +package middleware + +import ( + "bytes" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" + "github.com/Wei-Shaw/sub2api/internal/service" + + "github.com/gin-gonic/gin" +) + +func newGroupModelAllowlistTestRouter(apiKey *service.APIKey, pathPrefix string) (*gin.Engine, *[]string) { + gin.SetMode(gin.TestMode) + router := gin.New() + var calls []string + router.Use(func(c *gin.Context) { + if apiKey != nil { + c.Set(string(ContextKeyAPIKey), apiKey) + } + c.Next() + }) + router.Use(GroupModelAllowlist()) + register := func(method, path string) { + router.Handle(method, path, func(c *gin.Context) { + calls = append(calls, "handler:"+path) + c.Status(http.StatusOK) + }) + } + register(http.MethodPost, pathPrefix+"/messages") + register(http.MethodPost, pathPrefix+"/responses") + register(http.MethodPost, pathPrefix+"/chat/completions") + register(http.MethodPost, pathPrefix+"/embeddings") + register(http.MethodGet, pathPrefix+"/models/:model") + register(http.MethodPost, pathPrefix+"/models/*modelAction") + register(http.MethodGet, pathPrefix+"/realtime") + register(http.MethodPost, pathPrefix+"/images/edits") + register(http.MethodPost, pathPrefix+"/live") + return router, &calls +} + +func allowlistAPIKey(enabled bool, models ...string) *service.APIKey { + return &service.APIKey{ + Group: &service.Group{ + Platform: service.PlatformAnthropic, + ModelAllowlist: service.GroupModelAllowlist{ + Enabled: enabled, + Models: models, + }, + }, + } +} + +func doJSON(t *testing.T, router *gin.Engine, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} + +// readTrackingBody 记录请求体是否被读取,用于断言快速路径零读取。 +type readTrackingBody struct { + io.Reader + read bool +} + +func (b *readTrackingBody) Read(p []byte) (int, error) { + b.read = true + return b.Reader.Read(p) +} + +func (b *readTrackingBody) Close() error { return nil } + +func TestGroupModelAllowlistDisabledDoesNotReadBody(t *testing.T) { + router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(false, "claude-sonnet-4.5"), "/v1") + + body := &readTrackingBody{Reader: strings.NewReader(`{"model":"claude-opus-4.6"}`)} + req := httptest.NewRequest(http.MethodPost, "/v1/messages", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if len(*calls) != 1 { + t.Fatalf("expected handler to run once, got %v", *calls) + } + if body.read { + t.Fatal("allowlist disabled: middleware must not read the request body") + } +} + +// 白名单开启但请求不携带模型时同样不应读体之外产生副作用:读体是必要开销, +// 这里验证读取后请求体被完整回填(handler 可零拷贝重读)。 +func TestGroupModelAllowlistEnabledModelFreeRequestRestoresBody(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(ContextKeyAPIKey), allowlistAPIKey(true, "claude-sonnet-4.5")) + c.Next() + }) + router.Use(GroupModelAllowlist()) + router.POST("/v1/messages", func(c *gin.Context) { + body, err := httputil.ReadRequestBodyWithPrealloc(c.Request) + if err != nil { + t.Errorf("handler reread body: %v", err) + c.Status(http.StatusInternalServerError) + return + } + if string(body) != `{"input":"no model field"}` { + t.Errorf("handler saw wrong body: %s", body) + } + c.Status(http.StatusOK) + }) + + w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"input":"no model field"}`) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +// 413 传递:请求体超过 MaxBytesReader 上限时按现有合成中间件方式返回 413。 +func TestGroupModelAllowlistRequestBodyTooLargePassesThrough413(t *testing.T) { + router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1") + + bigBody := `{"model":"claude-opus-4.6","padding":"` + strings.Repeat("x", 512) + `"}` + req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(bigBody)) + req.Header.Set("Content-Type", "application/json") + req.Body = http.MaxBytesReader(httptest.NewRecorder(), req.Body, 64) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "Request body is too large") { + t.Fatalf("expected too-large message, got %s", w.Body.String()) + } + if len(*calls) != 0 { + t.Fatalf("expected handler not to run, got %v", *calls) + } +} + +func TestGroupModelAllowlistJSONBodyAllowed(t *testing.T) { + router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1") + + w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"model":"claude-sonnet-4.5"}`) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if len(*calls) != 1 { + t.Fatalf("expected handler to run once, got %v", *calls) + } +} + +func TestGroupModelAllowlistJSONBodyDeniedOpenAIFormat(t *testing.T) { + router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1") + + w := doJSON(t, router, http.MethodPost, "/v1/responses", `{"model":"gpt-5.4"}`) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"model_not_found"`) { + t.Fatalf("expected OpenAI error format, got %s", w.Body.String()) + } + if !strings.Contains(w.Body.String(), `gpt-5.4\" is not available for this group`) { + t.Fatalf("expected model in message, got %s", w.Body.String()) + } + if len(*calls) != 0 { + t.Fatalf("expected handler not to run, got %v", *calls) + } +} + +func TestGroupModelAllowlistMessagesDeniedAnthropicFormat(t *testing.T) { + router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1") + + w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"model":"claude-opus-4.6"}`) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"not_found_error"`) { + t.Fatalf("expected Anthropic not_found_error format, got %s", w.Body.String()) + } +} + +func TestGroupModelAllowlistGeminiDeniedGoogleFormat(t *testing.T) { + router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gemini-2.5-pro"), "/v1beta") + + w := doJSON(t, router, http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", `{}`) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), `"NOT_FOUND"`) { + t.Fatalf("expected Google NOT_FOUND format, got %s", w.Body.String()) + } +} + +func TestGroupModelAllowlistGeminiWildcardAndPrefixForms(t *testing.T) { + router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gemini-2.5-*"), "/v1beta") + + w := doJSON(t, router, http.MethodPost, "/v1beta/models/models/gemini-2.5-flash:generateContent", `{}`) + if w.Code != http.StatusOK { + t.Fatalf("models/ prefixed model should match wildcard entry, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGroupModelAllowlistGeminiGetModelRouteParam(t *testing.T) { + router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gemini-2.5-pro"), "/v1beta") + + req := httptest.NewRequest(http.MethodGet, "/v1beta/models/gemini-2.5-flash", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for unlisted GET model, got %d", w.Code) + } + + req = httptest.NewRequest(http.MethodGet, "/v1beta/models/gemini-2.5-pro", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for listed GET model, got %d", w.Code) + } +} + +func TestGroupModelAllowlistRealtimeQueryParameter(t *testing.T) { + router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "grok-4.6"), "/v1") + + req := httptest.NewRequest(http.MethodGet, "/v1/realtime?model=grok-4.6", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for listed realtime model, got %d: %s", w.Code, w.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, "/v1/realtime?model=grok-4.20", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for unlisted realtime model, got %d", w.Code) + } +} + +func TestGroupModelAllowlistMultipartModelField(t *testing.T) { + router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gpt-image-1"), "/v1") + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("model", "gpt-image-1") + _ = writer.WriteField("prompt", "cat") + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGroupModelAllowlistMultipartDenied(t *testing.T) { + router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gpt-image-1"), "/v1") + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("model", "dall-e-3") + _ = writer.WriteField("prompt", "cat") + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGroupModelAllowlistNoModelPassesThrough(t *testing.T) { + router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1") + + w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"input":"no model field"}`) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 when no model extractable, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGroupModelAllowlistBodyRestoredForHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(ContextKeyAPIKey), allowlistAPIKey(true, "claude-sonnet-4.5")) + c.Next() + }) + router.Use(GroupModelAllowlist()) + router.POST("/v1/messages", func(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + t.Errorf("handler reread body: %v", err) + c.Status(http.StatusInternalServerError) + return + } + if !strings.Contains(string(body), `"claude-sonnet-4.5"`) { + t.Errorf("handler saw wrong body: %s", body) + } + c.Status(http.StatusOK) + }) + + w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"model":"claude-sonnet-4.5","input":"hi"}`) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGroupModelAllowlistWebSocketUpgradeSkipsBodyExtraction(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(ContextKeyAPIKey), allowlistAPIKey(true, "claude-sonnet-4.5")) + c.Next() + }) + router.Use(GroupModelAllowlist()) + router.GET("/v1/responses", func(c *gin.Context) { + c.Status(http.StatusSwitchingProtocols) + }) + + req := httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusSwitchingProtocols { + t.Fatalf("expected WS upgrade to reach handler, got %d: %s", w.Code, w.Body.String()) + } +} + +// 伪造 WebSocket Upgrade 头的普通请求不得绕过白名单校验。 +func TestGroupModelAllowlistForgedUpgradeHeaderDoesNotBypass(t *testing.T) { + router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1") + + req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"claude-opus-4.6"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 despite forged upgrade headers, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "claude-opus-4.6") { + t.Fatalf("expected blocked model in message, got %s", w.Body.String()) + } + if len(*calls) != 0 { + t.Fatalf("expected handler not to run, got %v", *calls) + } +} + +// Grok Realtime 升级请求的模型在查询参数里,携带 Upgrade 头也必须校验。 +func TestGroupModelAllowlistRealtimeUpgradeStillChecksQueryModel(t *testing.T) { + router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "grok-4.6"), "/v1") + + req := httptest.NewRequest(http.MethodGet, "/v1/realtime?model=grok-4.20", nil) + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for unlisted realtime model with upgrade headers, got %d: %s", w.Code, w.Body.String()) + } +} + +// Live 入口的 handler 只读 session.model:准入必须以 session.model 为准, +// 与顶层 model 不一致时按实际执行模型校验。 +func TestGroupModelAllowlistLiveUsesSessionModel(t *testing.T) { + router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gpt-realtime-model"), "/v1") + + // session.model 不在白名单:即使顶层 model 命中也必须拒绝。 + w := doJSON(t, router, http.MethodPost, "/v1/live", `{"model":"gpt-realtime-model","session":{"model":"blocked-model"},"sdp":"v=0"}`) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 when session.model is blocked, got %d: %s", w.Code, w.Body.String()) + } + + // session.model 命中:顶层 model 不在白名单也不影响(handler 不读它)。 + w = doJSON(t, router, http.MethodPost, "/v1/live", `{"model":"blocked-model","session":{"model":"gpt-realtime-model"},"sdp":"v=0"}`) + if w.Code != http.StatusOK { + t.Fatalf("expected 200 when session.model is allowlisted, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestGroupModelAllowlistMarksRejectionReasons(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(string(ContextKeyAPIKey), allowlistAPIKey(true, "claude-sonnet-4.5")) + c.Next() + }) + router.Use(GroupModelAllowlist()) + router.POST("/v1/responses", func(c *gin.Context) { c.Status(http.StatusOK) }) + + w := doJSON(t, router, http.MethodPost, "/v1/responses", `{"model":"gpt-5.4"}`) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } + // IngressRejectModelNotAllowed 与业务限流原因由 ops 中间件在真实链路落日志, + // 此处至少验证拒绝原因可从 context 读取(MarkIngressRejected 生效)。 +} + +func TestGroupModelAllowlistNilGroupPasses(t *testing.T) { + router, calls := newGroupModelAllowlistTestRouter(&service.APIKey{}, "/v1") + w := doJSON(t, router, http.MethodPost, "/v1/messages", `{"model":"whatever"}`) + if w.Code != http.StatusOK || len(*calls) != 1 { + t.Fatalf("expected passthrough for key without group, got %d", w.Code) + } +} + +// encoding/json 绑定系 handler(如批量生图 ShouldBindJSON)对键名大小写不敏感: +// {"Model":"blocked"} 会绑定为 blocked,准入必须拒绝。 +func TestGroupModelAllowlistCaseVariantModelKeyRejected(t *testing.T) { + router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1") + + w := doJSON(t, router, http.MethodPost, "/v1/responses", `{"Model":"claude-opus-4.6"}`) + if w.Code != http.StatusNotFound { + t.Fatalf("case-variant model key must be validated, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "claude-opus-4.6") { + t.Fatalf("expected blocked model in message, got %s", w.Body.String()) + } + if len(*calls) != 0 { + t.Fatalf("expected handler not to run, got %v", *calls) + } +} + +// encoding/json 绑定对重复键取末值、gjson 取首个:两个值都必须通过校验。 +func TestGroupModelAllowlistDuplicateModelKeysRejected(t *testing.T) { + router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "claude-sonnet-4.5"), "/v1") + + w := doJSON(t, router, http.MethodPost, "/v1/responses", `{"model":"claude-sonnet-4.5","model":"claude-opus-4.6"}`) + if w.Code != http.StatusNotFound { + t.Fatalf("duplicate model keys must all be validated, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "claude-opus-4.6") { + t.Fatalf("expected the disallowed duplicate to be reported, got %s", w.Body.String()) + } +} + +// 图片编辑的 multipart 解析器对重复 model 字段取末值:全部字段值都必须校验。 +func TestGroupModelAllowlistMultipartDuplicateModelFieldsRejected(t *testing.T) { + router, _ := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gpt-image-1"), "/v1") + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("model", "gpt-image-1") + _ = writer.WriteField("model", "gpt-image-1.5") + _ = writer.WriteField("prompt", "cat") + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("duplicate multipart model fields must all be validated, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "gpt-image-1.5") { + t.Fatalf("expected the disallowed field value to be reported, got %s", w.Body.String()) + } +} + +// 全部候选都在白名单内时放行(重复但同值的字段不误伤)。 +func TestGroupModelAllowlistDuplicateIdenticalModelsAllowed(t *testing.T) { + router, calls := newGroupModelAllowlistTestRouter(allowlistAPIKey(true, "gpt-image-1"), "/v1") + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("model", "gpt-image-1") + _ = writer.WriteField("model", "gpt-image-1") + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/v1/images/edits", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("identical duplicate fields should pass, got %d: %s", w.Code, w.Body.String()) + } + if len(*calls) != 1 { + t.Fatalf("expected handler to run once, got %v", *calls) + } +} diff --git a/backend/internal/server/middleware/ingress_reject.go b/backend/internal/server/middleware/ingress_reject.go index e61350d781..7c7af027a5 100644 --- a/backend/internal/server/middleware/ingress_reject.go +++ b/backend/internal/server/middleware/ingress_reject.go @@ -28,6 +28,7 @@ const ( IngressRejectGroupUnassigned IngressRejectReason = "group_unassigned" IngressRejectInvalidAuthRateLimited IngressRejectReason = "invalid_auth_rate_limited" IngressRejectAPIKeyAuthOverloaded IngressRejectReason = "api_key_auth_overloaded" + IngressRejectModelNotAllowed IngressRejectReason = "model_not_allowed" ) const ingressRejectReasonContextKey = "ingress_reject_reason" diff --git a/backend/internal/server/middleware/middleware.go b/backend/internal/server/middleware/middleware.go index bb89c819f9..288571b789 100644 --- a/backend/internal/server/middleware/middleware.go +++ b/backend/internal/server/middleware/middleware.go @@ -100,11 +100,19 @@ func abortWithOpenAIQuotaError(c *gin.Context, statusCode int, message string) { // GatewayErrorWriter 定义网关错误响应格式(不同协议使用不同格式) type GatewayErrorWriter func(c *gin.Context, status int, message string) -// AnthropicErrorWriter 按 Anthropic API 规范输出错误 +// AnthropicErrorWriter 按 Anthropic API 规范输出错误;error.type 随状态码映射 +// (404 -> not_found_error,403 -> permission_error,其余 api_error)。 func AnthropicErrorWriter(c *gin.Context, status int, message string) { + errorType := "api_error" + switch status { + case http.StatusNotFound: + errorType = "not_found_error" + case http.StatusForbidden: + errorType = "permission_error" + } c.JSON(status, gin.H{ "type": "error", - "error": gin.H{"type": "permission_error", "message": message}, + "error": gin.H{"type": errorType, "message": message}, }) } @@ -119,6 +127,17 @@ func GoogleErrorWriter(c *gin.Context, status int, message string) { }) } +// OpenAIErrorWriter 按 OpenAI API 规范输出模型级错误(model_not_found)。 +func OpenAIErrorWriter(c *gin.Context, status int, message string) { + c.JSON(status, gin.H{ + "error": gin.H{ + "message": message, + "type": "invalid_request_error", + "code": "model_not_found", + }, + }) +} + // RequireGroupAssignment 检查 API Key 是否已分配到分组, // 如果未分组且系统设置不允许未分组 Key 调度则返回 403。 func RequireGroupAssignment(settingService *service.SettingService, writeError GatewayErrorWriter) gin.HandlerFunc { diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index d23a21c62c..c16ef695e0 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -333,7 +333,7 @@ func registerGroupRoutes(admin *gin.RouterGroup, h *handler.Handlers) { groups.GET("/capacity-summary", h.Admin.Group.GetCapacitySummary) groups.GET("/live-capability", h.Admin.Group.GetLiveCapability) groups.PUT("/sort-order", h.Admin.Group.UpdateSortOrder) - groups.GET("/:id/models-list-candidates", h.Admin.Group.GetModelsListCandidates) + groups.GET("/:id/model-allowlist-candidates", h.Admin.Group.GetGroupModelAllowlistCandidates) groups.GET("/:id/composite-routes", h.Admin.Group.ListCompositeRoutes) groups.POST("/:id/composite-routes", h.Admin.Group.CreateCompositeRoute) groups.POST("/:id/composite-routes/preview", h.Admin.Group.PreviewCompositeRoute) @@ -372,6 +372,8 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS) accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS) accounts.PUT("/:id", h.Admin.Account.Update) + accounts.GET("/:id/grok-media-eligibility", h.Admin.Account.GetGrokMediaEligibility) + accounts.PUT("/:id/grok-media-eligibility", h.Admin.Account.UpdateGrokMediaEligibility) accounts.PUT("/:id/upstream-billing-probe", h.Admin.Account.SetUpstreamBillingProbeEnabled) accounts.POST("/:id/upstream-billing-probe", h.Admin.Account.ProbeUpstreamBilling) accounts.GET("/:id/ollama-cloud-usage", h.Admin.Account.GetOllamaCloudUsage) diff --git a/backend/internal/server/routes/composite_platform_test.go b/backend/internal/server/routes/composite_platform_test.go index 3e64d2fbb8..3b660a951e 100644 --- a/backend/internal/server/routes/composite_platform_test.go +++ b/backend/internal/server/routes/composite_platform_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/requestmodel" servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -191,7 +192,7 @@ func TestCompositeRequestModelFromMultipartLiveSession(t *testing.T) { require.NoError(t, writer.WriteField("session", `{"model":"live-alias"}`)) require.NoError(t, writer.Close()) - require.Equal(t, "live-alias", compositeRequestModelFromBody(writer.FormDataContentType(), body.Bytes())) + require.Equal(t, "live-alias", requestmodel.FromBody(writer.FormDataContentType(), body.Bytes())) } func TestCompositeCodexControlPathsUseResponsesRoutes(t *testing.T) { @@ -312,3 +313,58 @@ func TestCompositeGeminiTargetPlatformMiddlewareUsesPathRoute(t *testing.T) { require.Equal(t, http.StatusNoContent, w.Code) } + +// Live 入口顶层 model 与 session.model 不一致时,合成路由必须按 session.model +// 分发与改写(与白名单准入、Live handler 一致),不得命中顶层别名的映射并把 +// session 模型覆盖为别名上游。 +func TestCompositeLiveRouteDispatchesBySessionModelNotTopLevelAlias(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + resolver := service.NewCompositeRouteResolver(compositeRouteRepoStub{ + routes: []service.CompositeModelRoute{ + { + ID: 1, + GroupID: 1, + PublicModel: "live-alias", + MatchType: service.CompositeRouteMatchExact, + TargetPlatform: service.PlatformOpenAI, + UpstreamModel: "gpt-alias-upstream", + Endpoint: service.CompositeRouteEndpointAny, + Priority: 100, + Enabled: true, + }, + }, + }) + router.Use(gin.HandlerFunc(servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) { + groupID := int64(1) + c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{ + GroupID: &groupID, + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + c.Next() + }))) + router.Use(compositeTargetPlatformMiddleware(resolver)) + router.POST("/backend-api/codex/realtime/calls", func(c *gin.Context) { + upstream, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()) + require.True(t, ok) + require.Equal(t, "gpt-realtime-1", upstream, + "Live dispatch must resolve the session model, never the top-level alias upstream") + body, err := io.ReadAll(c.Request.Body) + require.NoError(t, err) + require.JSONEq(t, `{"model":"live-alias","session":{"model":"gpt-realtime-1"},"sdp":"v=0"}`, string(body), + "session.model must stay untouched when dispatch resolves by session model") + c.Status(http.StatusNoContent) + }) + + req := httptest.NewRequest( + http.MethodPost, + "/backend-api/codex/realtime/calls", + strings.NewReader(`{"model":"live-alias","session":{"model":"gpt-realtime-1"},"sdp":"v=0"}`), + ) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusNoContent, w.Code) +} diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index fe753b2389..d38f4bf050 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -1,18 +1,14 @@ package routes import ( - "bytes" "errors" - "io" - "mime" - "mime/multipart" "net/http" - "strconv" "strings" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler" pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" + "github.com/Wei-Shaw/sub2api/internal/pkg/requestmodel" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" @@ -45,11 +41,15 @@ func RegisterGatewayRoutes( requireGroupAnthropic := middleware.RequireGroupAssignment(settingService, middleware.AnthropicErrorWriter) requireGroupGoogle := middleware.RequireGroupAssignment(settingService, middleware.GoogleErrorWriter) + // 分组级模型白名单准入:在 apiKeyAuth 之后、compositeTarget 之前, + // 保证校验发生在合成路由改写与调度之前,且只看客户端书写的模型名。 + groupModelAllowlist := middleware.GroupModelAllowlist() + isOpenAIResponsesCompatibleGatewayPlatform := func(c *gin.Context) bool { switch getGroupPlatform(c) { case service.PlatformOpenAI, service.PlatformGrok, - service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek: - // 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)与 openai/grok 一样经 OpenAI 网关转发。 + service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax: + // 国产 OpenAI 兼容供应商与 openai/grok 一样经 OpenAI 网关转发。 return true default: return false @@ -57,7 +57,7 @@ func RegisterGatewayRoutes( } countTokensHandler := func(c *gin.Context) { switch getGroupPlatform(c) { - case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek: + case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax: h.OpenAIGateway.CountTokens(c) case service.PlatformGrok: h.OpenAIGateway.GrokCountTokens(c) @@ -189,6 +189,7 @@ func RegisterGatewayRoutes( gateway.Use(endpointNorm) gateway.Use(gin.HandlerFunc(apiKeyAuth)) gateway.GET("/sub2api/billing", h.Gateway.KeyBillingInfo) + gateway.Use(groupModelAllowlist) gateway.Use(compositeTarget) gateway.Use(requireGroupAnthropic) { @@ -341,6 +342,7 @@ func RegisterGatewayRoutes( gemini.Use(opsErrorLogger) gemini.Use(endpointNorm) gemini.Use(middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg)) + gemini.Use(groupModelAllowlist) gemini.Use(compositeGeminiTarget) gemini.Use(requireGroupGoogle) { @@ -358,16 +360,21 @@ func RegisterGatewayRoutes( } h.Gateway.Responses(c) } - r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, responsesHandler) - r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, guardResponsesSubpath(responsesHandler)) - r.POST("/alpha/search", textBodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.OpenAIGateway.AlphaSearch) - r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) { + // 根路径别名共用中间件链:白名单准入在 apiKeyAuth 之后、compositeTarget + // 之前,避免逐条路由手工维护链导致漏挂。 + rootRoute := func(method, path string, limit gin.HandlerFunc, handler gin.HandlerFunc) { + r.Handle(method, path, limit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), groupModelAllowlist, compositeTarget, requireGroupAnthropic, handler) + } + rootRoute(http.MethodPost, "/responses", bodyLimit, responsesHandler) + rootRoute(http.MethodPost, "/responses/*subpath", bodyLimit, guardResponsesSubpath(responsesHandler)) + rootRoute(http.MethodPost, "/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch) + rootRoute(http.MethodGet, "/responses", bodyLimit, func(c *gin.Context) { h.OpenAIGateway.ResponsesWebSocket(c) }) - r.GET("/models", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, modelsHandler) - r.POST("/messages/count_tokens", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, countTokensHandler) + rootRoute(http.MethodGet, "/models", bodyLimit, modelsHandler) + rootRoute(http.MethodPost, "/messages/count_tokens", bodyLimit, countTokensHandler) codexDirect := r.Group("/backend-api/codex") - codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic) + codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), groupModelAllowlist, compositeTarget, requireGroupAnthropic) { codexDirect.POST("/realtime/calls", h.OpenAIGateway.Live) codexDirect.GET("/:call_id", h.OpenAIGateway.LiveSideband) @@ -380,14 +387,14 @@ func RegisterGatewayRoutes( codexDirect.GET("/models", codexModelsHandler) } // OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform - r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) { + rootRoute(http.MethodPost, "/chat/completions", bodyLimit, func(c *gin.Context) { if isOpenAIResponsesCompatibleGatewayPlatform(c) { h.OpenAIGateway.ChatCompletions(c) return } h.Gateway.ChatCompletions(c) }) - r.POST("/embeddings", textBodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) { + rootRoute(http.MethodPost, "/embeddings", textBodyLimit, func(c *gin.Context) { if !isOpenAIOnlyEndpointGatewayPlatform(c) { service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusNotFound, gin.H{ @@ -400,23 +407,23 @@ func RegisterGatewayRoutes( } h.OpenAIGateway.Embeddings(c) }) - r.POST("/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, imagesHandler) - r.POST("/images/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, imagesHandler) - r.POST("/images/generations/async", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Submit) - r.POST("/images/edits/async", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Submit) - r.GET("/images/tasks/:task_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Get) - r.POST("/videos", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoGenerationHandler) - r.POST("/videos/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoGenerationHandler) - r.POST("/videos/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoEditHandler) - r.POST("/videos/extensions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoExtensionHandler) - r.GET("/videos/generations/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler) - r.GET("/videos/edits/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler) - r.GET("/videos/extensions/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler) - r.GET("/videos/generations/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler) - r.GET("/videos/edits/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler) - r.GET("/videos/extensions/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler) - r.GET("/videos/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler) - r.GET("/videos/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler) + rootRoute(http.MethodPost, "/images/generations", bodyLimit, imagesHandler) + rootRoute(http.MethodPost, "/images/edits", bodyLimit, imagesHandler) + rootRoute(http.MethodPost, "/images/generations/async", bodyLimit, h.AsyncImage.Submit) + rootRoute(http.MethodPost, "/images/edits/async", bodyLimit, h.AsyncImage.Submit) + rootRoute(http.MethodGet, "/images/tasks/:task_id", bodyLimit, h.AsyncImage.Get) + rootRoute(http.MethodPost, "/videos", bodyLimit, videoGenerationHandler) + rootRoute(http.MethodPost, "/videos/generations", bodyLimit, videoGenerationHandler) + rootRoute(http.MethodPost, "/videos/edits", bodyLimit, videoEditHandler) + rootRoute(http.MethodPost, "/videos/extensions", bodyLimit, videoExtensionHandler) + rootRoute(http.MethodGet, "/videos/generations/:request_id/content", bodyLimit, videoContentHandler) + rootRoute(http.MethodGet, "/videos/edits/:request_id/content", bodyLimit, videoContentHandler) + rootRoute(http.MethodGet, "/videos/extensions/:request_id/content", bodyLimit, videoContentHandler) + rootRoute(http.MethodGet, "/videos/generations/:request_id", bodyLimit, videoStatusHandler) + rootRoute(http.MethodGet, "/videos/edits/:request_id", bodyLimit, videoStatusHandler) + rootRoute(http.MethodGet, "/videos/extensions/:request_id", bodyLimit, videoStatusHandler) + rootRoute(http.MethodGet, "/videos/:request_id", bodyLimit, videoStatusHandler) + rootRoute(http.MethodGet, "/videos/:request_id/content", bodyLimit, videoContentHandler) rootVoiceHandler := func(endpoint string) gin.HandlerFunc { return func(c *gin.Context) { @@ -428,9 +435,9 @@ func RegisterGatewayRoutes( h.OpenAIGateway.GrokVoice(c, endpoint) } } - r.POST("/tts", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootVoiceHandler("tts")) - r.POST("/stt", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootVoiceHandler("stt")) - r.POST("/custom-voices", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootVoiceHandler("custom-voices")) + rootRoute(http.MethodPost, "/tts", bodyLimit, rootVoiceHandler("tts")) + rootRoute(http.MethodPost, "/stt", bodyLimit, rootVoiceHandler("stt")) + rootRoute(http.MethodPost, "/custom-voices", bodyLimit, rootVoiceHandler("custom-voices")) rootCustomVoicePathHandler := func(c *gin.Context) { if getGroupPlatform(c) != service.PlatformGrok { service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) @@ -439,12 +446,12 @@ func RegisterGatewayRoutes( } h.OpenAIGateway.GrokVoice(c, grokCustomVoiceEndpoint(c)) } - r.GET("/custom-voices", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootVoiceHandler("custom-voices")) - r.GET("/custom-voices/:voice_id/audio", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootCustomVoicePathHandler) - r.GET("/custom-voices/:voice_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootCustomVoicePathHandler) - r.PATCH("/custom-voices/:voice_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootCustomVoicePathHandler) - r.DELETE("/custom-voices/:voice_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootCustomVoicePathHandler) - r.GET("/realtime", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) { + rootRoute(http.MethodGet, "/custom-voices", bodyLimit, rootVoiceHandler("custom-voices")) + rootRoute(http.MethodGet, "/custom-voices/:voice_id/audio", bodyLimit, rootCustomVoicePathHandler) + rootRoute(http.MethodGet, "/custom-voices/:voice_id", bodyLimit, rootCustomVoicePathHandler) + rootRoute(http.MethodPatch, "/custom-voices/:voice_id", bodyLimit, rootCustomVoicePathHandler) + rootRoute(http.MethodDelete, "/custom-voices/:voice_id", bodyLimit, rootCustomVoicePathHandler) + rootRoute(http.MethodGet, "/realtime", bodyLimit, func(c *gin.Context) { if getGroupPlatform(c) != service.PlatformGrok { service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Realtime API is not supported for this platform"}}) @@ -452,7 +459,7 @@ func RegisterGatewayRoutes( } h.OpenAIGateway.GrokRealtime(c) }) - r.POST("/web_search", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) { + rootRoute(http.MethodPost, "/web_search", bodyLimit, func(c *gin.Context) { if getGroupPlatform(c) != service.PlatformGrok { service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Web Search API is not supported for this platform"}}) @@ -460,7 +467,7 @@ func RegisterGatewayRoutes( } h.Gateway.WebSearch(c) }) - r.POST("/x_search", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) { + rootRoute(http.MethodPost, "/x_search", bodyLimit, func(c *gin.Context) { if getGroupPlatform(c) != service.PlatformGrok { service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "X Search API is not supported for this platform"}}) @@ -480,6 +487,7 @@ func RegisterGatewayRoutes( antigravityV1.Use(endpointNorm) antigravityV1.Use(middleware.ForcePlatform(service.PlatformAntigravity)) antigravityV1.Use(gin.HandlerFunc(apiKeyAuth)) + antigravityV1.Use(groupModelAllowlist) antigravityV1.Use(requireGroupAnthropic) { antigravityV1.POST("/messages", h.Gateway.Messages) @@ -495,6 +503,7 @@ func RegisterGatewayRoutes( antigravityV1Beta.Use(endpointNorm) antigravityV1Beta.Use(middleware.ForcePlatform(service.PlatformAntigravity)) antigravityV1Beta.Use(middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg)) + antigravityV1Beta.Use(groupModelAllowlist) antigravityV1Beta.Use(requireGroupGoogle) { antigravityV1Beta.GET("/models", h.Gateway.GeminiV1BetaListModels) @@ -555,7 +564,10 @@ func compositeTargetPlatformMiddleware(resolver *service.CompositeRouteResolver) return } - model := compositeRequestModelFromBody(c.GetHeader("Content-Type"), body) + // Live 入口按 session.model 分发并改写(与白名单准入、Live handler 的 + // 解析保持一致),避免顶层 model 别名与 session 模型不一致时改错对象。 + routePath := c.FullPath() + model := requestmodel.FromBodyForRoute(routePath, c.GetHeader("Content-Type"), body) if model != "" { decision, err := resolver.Resolve(c.Request.Context(), apiKey.Group.ID, model, compositeRouteEndpointForPath(c.Request.URL.Path)) if err != nil { @@ -566,7 +578,7 @@ func compositeTargetPlatformMiddleware(resolver *service.CompositeRouteResolver) if decision.Matched { c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision)) if upstreamModel := strings.TrimSpace(decision.UpstreamModel); upstreamModel != "" && upstreamModel != model && gjson.ValidBytes(body) { - if _, modelPath := compositeJSONRequestModel(body); modelPath != "" { + if _, modelPath := requestmodel.JSONModelPathForRoute(routePath, body); modelPath != "" { if rewritten, rewriteErr := sjson.SetBytes(body, modelPath, upstreamModel); rewriteErr == nil { body = rewritten } @@ -574,68 +586,11 @@ func compositeTargetPlatformMiddleware(resolver *service.CompositeRouteResolver) } } } - resetRequestBody(c, body) + requestmodel.ResetRequestBody(c.Request, body) c.Next() } } -func compositeRequestModelFromBody(contentType string, body []byte) string { - if model, _ := compositeJSONRequestModel(body); model != "" { - return model - } - return compositeMultipartModelFromBody(contentType, body) -} - -func compositeJSONRequestModel(body []byte) (string, string) { - for _, path := range []string{"model", "session.model"} { - model := gjson.GetBytes(body, path) - if model.Type != gjson.String { - continue - } - if value := strings.TrimSpace(model.String()); value != "" { - return value, path - } - } - return "", "" -} - -func compositeMultipartModelFromBody(contentType string, body []byte) string { - mediaType, params, err := mime.ParseMediaType(strings.TrimSpace(contentType)) - if err != nil || !strings.EqualFold(mediaType, "multipart/form-data") { - return "" - } - boundary := strings.TrimSpace(params["boundary"]) - if boundary == "" { - return "" - } - reader := multipart.NewReader(bytes.NewReader(body), boundary) - for { - part, err := reader.NextPart() - if errors.Is(err, io.EOF) { - return "" - } - if err != nil { - return "" - } - fieldName := part.FormName() - if part.FileName() != "" || (fieldName != "model" && fieldName != "session") { - continue - } - data, err := io.ReadAll(part) - if err != nil { - return "" - } - switch fieldName { - case "model": - return strings.TrimSpace(string(data)) - case "session": - if model, _ := compositeJSONRequestModel(data); model != "" { - return model - } - } - } -} - func compositeGeminiTargetPlatformMiddleware(resolver *service.CompositeRouteResolver) gin.HandlerFunc { if resolver == nil { resolver = service.NewCompositeRouteResolver(nil) @@ -696,12 +651,6 @@ func compositeGeminiModelFromParams(c *gin.Context) string { return modelAction } -func resetRequestBody(c *gin.Context, body []byte) { - c.Request.Body = io.NopCloser(bytes.NewReader(body)) - c.Request.ContentLength = int64(len(body)) - c.Request.Header.Set("Content-Length", strconv.Itoa(len(body))) -} - func compositeRouteEndpointForPath(path string) string { switch { case strings.Contains(path, "/messages/count_tokens"): diff --git a/backend/internal/server/routes/gateway_model_allowlist_test.go b/backend/internal/server/routes/gateway_model_allowlist_test.go new file mode 100644 index 0000000000..1fd154d709 --- /dev/null +++ b/backend/internal/server/routes/gateway_model_allowlist_test.go @@ -0,0 +1,208 @@ +package routes + +import ( + "net/http" + "net/http/httptest" + "os" + "regexp" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/handler" + servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func newGatewayRoutesTestRouterWithGroup(group *service.Group) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + RegisterGatewayRoutes( + router, + &handler.Handlers{ + Gateway: &handler.GatewayHandler{}, + OpenAIGateway: &handler.OpenAIGatewayHandler{}, + AsyncImage: handler.NewAsyncImageHandler(nil, nil), + }, + servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) { + groupID := int64(1) + c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{ + GroupID: &groupID, + Group: group, + }) + c.Next() + }), + nil, + nil, + nil, + nil, + nil, + &config.Config{ + Gateway: config.GatewayConfig{ + MaxBodySize: 1024 * 1024, + TextMaxBodySize: 1024 * 1024, + }, + }, + ) + return router +} + +func allowlistGroup(platform string, enabled bool, models ...string) *service.Group { + return &service.Group{ + Platform: platform, + ModelAllowlist: service.GroupModelAllowlist{ + Enabled: enabled, + Models: models, + }, + } +} + +// TestGatewayRoutesGroupModelAllowlistMountedOnEveryGatewayRoute follows the +// source-level route assertion convention of prompt_audit_route_coverage_test.go: +// every gateway chain must mount groupModelAllowlist after api key auth and +// before the composite rewrite (gateway.go + rootRoute helper). +func TestGatewayRoutesGroupModelAllowlistMountedOnEveryGatewayRoute(t *testing.T) { + routeSource, err := os.ReadFile("gateway.go") + require.NoError(t, err) + source := string(routeSource) + + // rootRoute helper:apiKeyAuth 之后、compositeTarget 之前。 + rootHelper := regexp.MustCompile(regexp.QuoteMeta(`r.Handle(method, path, limit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), groupModelAllowlist, compositeTarget, requireGroupAnthropic, handler)`)) + require.Regexp(t, rootHelper, source, + "root alias helper must place the allowlist between apiKeyAuth and compositeTarget") + + chains := []struct { + group string + auth string + marker string + composite string + }{ + {group: "gateway", auth: "gin.HandlerFunc(apiKeyAuth)", marker: "gateway.Use(groupModelAllowlist)", composite: "gateway.Use(compositeTarget)"}, + {group: "gemini", auth: "middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg)", marker: "gemini.Use(groupModelAllowlist)", composite: "gemini.Use(compositeGeminiTarget)"}, + {group: "antigravityV1", auth: "gin.HandlerFunc(apiKeyAuth)", marker: "antigravityV1.Use(groupModelAllowlist)", composite: "antigravityV1.Use(requireGroupAnthropic)"}, + {group: "antigravityV1Beta", auth: "middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg)", marker: "antigravityV1Beta.Use(groupModelAllowlist)", composite: "antigravityV1Beta.Use(requireGroupGoogle)"}, + } + for _, chain := range chains { + re := regexp.MustCompile( + regexp.QuoteMeta(chain.group+".Use("+chain.auth) + + `[\s\S]{0,400}?` + regexp.QuoteMeta(chain.marker) + + `[\s\S]{0,400}?` + regexp.QuoteMeta(chain.composite)) + require.Regexp(t, re, source, + "%s chain must mount groupModelAllowlist after auth and before %s", chain.group, chain.composite) + } + + // codexDirect 链是一条 Use 调用,直接断言顺序。 + codexDirect := regexp.MustCompile(regexp.QuoteMeta(`codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), groupModelAllowlist, compositeTarget, requireGroupAnthropic)`)) + require.Regexp(t, codexDirect, source, "codexDirect chain must mount the allowlist after auth and before compositeTarget") + + // 所有带 apiKeyAuth 的根路径路由必须收敛到 rootRoute,避免漏挂。 + stray := regexp.MustCompile(`\br\.(GET|POST|PUT|PATCH|DELETE)\("[^"]+",[^(]*apiKeyAuth`) + require.NotRegexp(t, stray, source, + "root alias routes must use rootRoute so the allowlist cannot be forgotten") +} + +// TestGatewayRoutesGroupModelAllowlistBlocksCompositeModelBeforeRewrite asserts +// admission is decided on the client-written public model before the composite +// route middleware can rewrite it to an upstream model. +func TestGatewayRoutesGroupModelAllowlistBlocksCompositeModelBeforeRewrite(t *testing.T) { + router := newGatewayRoutesTestRouterWithGroup(allowlistGroup(service.PlatformComposite, true, "claude-*")) + + req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"grok-4.3","messages":[{"role":"user","content":"hi"}]}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusNotFound, w.Code) + require.Contains(t, w.Body.String(), "not_found_error") + require.Contains(t, w.Body.String(), "grok-4.3") +} + +func TestGatewayRoutesGroupModelAllowlistAllowsListedCompositeModel(t *testing.T) { + router := newGatewayRoutesTestRouterWithGroup(allowlistGroup(service.PlatformComposite, true, "claude-*")) + + req := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(`{"model":"claude-sonnet-4.5","messages":[{"role":"user","content":"hi"}]}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.NotEqual(t, http.StatusNotFound, w.Code, "allowlisted model must pass admission: %s", w.Body.String()) +} + +func TestGatewayRoutesGroupModelAllowlistCoversRootAliasRoutes(t *testing.T) { + router := newGatewayRoutesTestRouterWithGroup(allowlistGroup(service.PlatformOpenAI, true, "gpt-5.4")) + + paths := []struct { + method string + path string + body string + }{ + {http.MethodPost, "/responses", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/responses/compact", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/chat/completions", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/embeddings", `{"model":"gpt-4.1","input":"hi"}`}, + {http.MethodPost, "/images/generations", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/images/edits", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/videos/generations", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/messages/count_tokens", `{"model":"gpt-4.1","messages":[]}`}, + {http.MethodPost, "/tts", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/stt", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/alpha/search", `{"model":"gpt-4.1"}`}, + {http.MethodGet, "/realtime?model=gpt-4.1", ""}, + {http.MethodPost, "/v1/responses", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/v1/messages", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/v1/messages/count_tokens", `{"model":"gpt-4.1","messages":[]}`}, + {http.MethodPost, "/v1/chat/completions", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/v1/embeddings", `{"model":"gpt-4.1","input":"hi"}`}, + {http.MethodPost, "/v1/images/generations", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/v1/videos/generations", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/v1/live", `{"session":{"model":"gpt-4.1"},"sdp":"v=0"}`}, + {http.MethodPost, "/backend-api/codex/responses", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/backend-api/codex/realtime/calls", `{"session":{"model":"gpt-4.1"},"sdp":"v=0"}`}, + {http.MethodPost, "/antigravity/v1/messages", `{"model":"gemini-2.5-pro","messages":[]}`}, + } + + for _, tc := range paths { + req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)) + if tc.body != "" { + req.Header.Set("Content-Type", "application/json") + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusNotFound, w.Code, "%s %s should be denied by the allowlist, got body: %s", tc.method, tc.path, w.Body.String()) + require.Contains(t, w.Body.String(), "not available for this group", "%s %s", tc.method, tc.path) + } +} + +func TestGatewayRoutesGroupModelAllowlistSkipsWebSocketUpgrade(t *testing.T) { + router := newGatewayRoutesTestRouterWithGroup(allowlistGroup(service.PlatformOpenAI, true, "gpt-5.4")) + + req := httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.NotEqual(t, http.StatusNotFound, w.Code, + "WS upgrade requests must be admitted and per-frame checked in the handler, got: %s", w.Body.String()) +} + +func TestGatewayRoutesGroupModelAllowlistModelFreeRoutesUnaffected(t *testing.T) { + router := newGatewayRoutesTestRouterWithGroup(allowlistGroup(service.PlatformGrok, true, "grok-4.6")) + + // 不携带模型的入口天然不受白名单影响(状态查询/内容下载/模型列表)。 + for _, path := range []string{ + "/v1/videos/generations/req-1", + "/v1/videos/req-1/content", + "/v1/images/tasks/task-1", + "/v1/custom-voices", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.NotContains(t, w.Body.String(), "not available for this group", + "%s should not be blocked by the allowlist middleware, got: %s", path, w.Body.String()) + } +} diff --git a/backend/internal/server/routes/gateway_models_pinned_test.go b/backend/internal/server/routes/gateway_models_pinned_test.go new file mode 100644 index 0000000000..5fab905059 --- /dev/null +++ b/backend/internal/server/routes/gateway_models_pinned_test.go @@ -0,0 +1,94 @@ +package routes + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/handler" + servermiddleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type pinnedModelsRoutesRepository struct { + service.AccountRepository + account service.Account +} + +func (r *pinnedModelsRoutesRepository) ListByGroup(context.Context, int64) ([]service.Account, error) { + return []service.Account{r.account}, nil +} + +type pinnedModelsRoutesUpstream struct { + service.HTTPUpstream + ordinaryCalls atomic.Int32 + codexCalls atomic.Int32 +} + +func (u *pinnedModelsRoutesUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + body := `{"data":[{"id":"ordinary-upstream-model"}]}` + if req.URL.Query().Has("client_version") { + u.codexCalls.Add(1) + body = `{"models":[{"slug":"gpt-5.5"}]}` + } else { + u.ordinaryCalls.Add(1) + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}, nil +} + +func TestGatewayRoutesPinnedModelsDispatchesOrdinaryAndCodexRequests(t *testing.T) { + gin.SetMode(gin.TestMode) + repo := &pinnedModelsRoutesRepository{account: service.Account{ + ID: 7, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Status: service.StatusActive, Schedulable: true, + Credentials: map[string]any{"api_key": "test-models-key", "base_url": "https://models.example/v1"}, + }} + upstream := &pinnedModelsRoutesUpstream{} + cfg := &config.Config{RunMode: config.RunModeSimple} + s := service.NewOpenAIGatewayService(repo, nil, nil, nil, nil, nil, nil, cfg, + nil, nil, nil, nil, nil, upstream, nil, nil, nil, nil, nil, nil, nil, nil) + h := &handler.Handlers{ + Gateway: handler.NewGatewayHandler(nil, s, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, cfg, nil), + OpenAIGateway: handler.NewOpenAIGatewayHandler(s, nil, nil, nil, nil, nil, nil, nil, cfg), + AsyncImage: handler.NewAsyncImageHandler(nil, nil), + } + group := &service.Group{ID: 1, Platform: service.PlatformOpenAI, + CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{7}}} + router := gin.New() + RegisterGatewayRoutes(router, h, servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) { + c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{GroupID: &group.ID, Group: group}) + c.Next() + }), nil, nil, nil, nil, nil, cfg) + for _, path := range []string{"/v1/models", "/models", "/v1/models?client_version=", "/models?client_version="} { + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + require.Equal(t, http.StatusOK, w.Code, "%s: %s", path, w.Body.String()) + var response struct { + Object string `json:"object"` + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response)) + require.Equal(t, "list", response.Object) + require.Len(t, response.Data, 1) + require.Equal(t, "ordinary-upstream-model", response.Data[0].ID) + } + for _, path := range []string{"/v1/models?client_version=" + service.CodexCanonicalClientVersion(), "/models?client_version=" + service.CodexCanonicalClientVersion(), "/backend-api/codex/models"} { + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + require.Equal(t, http.StatusOK, w.Code, "%s: %s", path, w.Body.String()) + require.Contains(t, w.Body.String(), `"slug":"gpt-5.5"`) + require.NotContains(t, w.Body.String(), `"data"`) + } + require.EqualValues(t, 1, upstream.ordinaryCalls.Load()) + require.EqualValues(t, 1, upstream.codexCalls.Load()) +} diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 15fd4d16ce..a9e3b700e8 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -285,17 +285,20 @@ func (a *Account) IsDeepseek() bool { return a.Platform == PlatformDeepseek } -// IsCNProvider 报告是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)。 +func (a *Account) IsMiniMax() bool { + return a.Platform == PlatformMiniMax +} + +// IsCNProvider 报告是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek/minimax)。 func (a *Account) IsCNProvider() bool { return a != nil && IsCNProvider(a.Platform) } // IsOpenAICompatible 报告账号是否走 OpenAI 网关(OpenAI 协议族)。 -// openai/grok 原生走 OpenAI 网关;kimi/zhipu/deepseek 同为 OpenAI Chat Completions +// openai/grok 原生走 OpenAI 网关;国产供应商同为 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) + return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok || a.IsCNProvider()) } func (a *Account) GeminiOAuthType() string { @@ -1372,6 +1375,8 @@ func (a *Account) GetOpenAIBaseURL() string { return DefaultZhipuPayGBaseURL case PlatformDeepseek: return DefaultDeepseekBaseURL + case PlatformMiniMax: + return DefaultMiniMaxBaseURL default: return "https://api.openai.com" } @@ -1397,7 +1402,7 @@ func (a *Account) IsCodingPlan() bool { // GetAPIProtocol 返回国产供应商账号的上游 API 协议。存储于 // credentials["api_protocol"];缺失或与平台不匹配时回退 chat_completions -// (与既有行为完全一致)。responses 协议仅 deepseek / kimi 支持(官方原生 +// (与既有行为完全一致)。responses 协议仅 deepseek / kimi / minimax 支持(官方原生 // Responses 端点,适配 Codex);zhipu 无此端点。 func (a *Account) GetAPIProtocol() string { if a == nil || !a.IsCNProvider() { @@ -1420,13 +1425,13 @@ func (a *Account) GetAPIProtocol() string { // SupportsNativeCNResponses 报告该国产供应商是否提供原生 Responses 端点。 // DeepSeek 官方为 /responses(无 /v1);Kimi 按量付费与 Coding Plan 均为 -// /v1/responses(moonshot.cn / kimi.com/coding)。 +// /v1/responses(moonshot.cn / kimi.com/coding);MiniMax 为 /v1/responses。 func (a *Account) SupportsNativeCNResponses() bool { if a == nil { return false } switch a.Platform { - case PlatformDeepseek, PlatformKimi: + case PlatformDeepseek, PlatformKimi, PlatformMiniMax: return true default: return false @@ -1487,6 +1492,8 @@ func (a *Account) defaultCNProtocolBaseURL(protocol string) string { return DefaultZhipuAnthropicBaseURL case PlatformDeepseek: return DefaultDeepseekAnthropicBaseURL + case PlatformMiniMax: + return DefaultMiniMaxAnthropicBaseURL } case APIProtocolChatCompletions, APIProtocolResponses: switch a.Platform { @@ -1502,6 +1509,8 @@ func (a *Account) defaultCNProtocolBaseURL(protocol string) string { return DefaultZhipuPayGBaseURL case PlatformDeepseek: return DefaultDeepseekBaseURL + case PlatformMiniMax: + return DefaultMiniMaxBaseURL } } return "" @@ -1538,6 +1547,8 @@ func (a *Account) GetAnthropicProtocolBaseURL() string { return DefaultZhipuAnthropicBaseURL case PlatformDeepseek: return DefaultDeepseekAnthropicBaseURL + case PlatformMiniMax: + return DefaultMiniMaxAnthropicBaseURL default: return "" } @@ -1565,6 +1576,8 @@ func (a *Account) GetOpenAIFormatBaseURL() string { return DefaultZhipuPayGBaseURL case PlatformDeepseek: return DefaultDeepseekBaseURL + case PlatformMiniMax: + return DefaultMiniMaxBaseURL default: return a.GetOpenAIBaseURL() } @@ -1579,9 +1592,9 @@ func (a *Account) GetCNAPIKey() string { return a.GetCredential("api_key") } -// GetCodingPlanProvider 根据 base_url 识别 Coding Plan 供应商(kimi / zhipu), +// GetCodingPlanProvider 根据 base_url 识别 Coding Plan 供应商(kimi / zhipu / minimax), // 用于路由到对应的额度查询端点。非 coding 模式或无法识别时返回空串。 -// 判定规则与 cc-switch coding_plan.rs::detect_provider 保持一致。 +// 只认官方域名:自定义中转不得把第三方 Key 发往厂商官方额度端点。 func (a *Account) GetCodingPlanProvider() string { if a == nil || a.GetAccountMode() != AccountModeCoding { return "" @@ -1592,6 +1605,10 @@ func (a *Account) GetCodingPlanProvider() string { return PlatformKimi case strings.Contains(baseURL, "bigmodel.cn"), strings.Contains(baseURL, "api.z.ai"): return PlatformZhipu + case strings.Contains(baseURL, "minimax.io"), + strings.Contains(baseURL, "minimaxi.com"), + strings.Contains(baseURL, "minimax.com"): + return PlatformMiniMax default: return "" } @@ -1850,9 +1867,10 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa } // 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. +// new image/video generation requests. Explicit evidence of a forbidden or +// free account blocks media, while an incomplete successful billing response +// remains eligible for backwards compatibility. An explicit operator +// override takes precedence over probe data. func (a *Account) GrokMediaGenerationEligibility() (bool, string) { if a == nil || !a.IsGrok() { return false, "not_grok" @@ -1878,7 +1896,12 @@ func (a *Account) GrokMediaGenerationEligibility() (bool, string) { return false, "billing_free_tier" } if !grokBillingHasAuthoritativeQuota(billing) { - return false, "billing_inconclusive" + // Billing endpoints can return 200 with an account-specific schema that + // omits plan/quota fields (for example, some SuperGrok accounts). An + // incomplete observation is not proof of ineligibility; keep the account + // routable and expose the reason for diagnostics. Operators can still + // quarantine a known-bad account with grok_media_eligible=false. + return true, "billing_inconclusive" } return true, "eligible" } diff --git a/backend/internal/service/account_grok_media_eligibility_test.go b/backend/internal/service/account_grok_media_eligibility_test.go index 47d1f2b94f..b53d88dc59 100644 --- a/backend/internal/service/account_grok_media_eligibility_test.go +++ b/backend/internal/service/account_grok_media_eligibility_test.go @@ -46,6 +46,7 @@ func TestGrokMediaGenerationEligibility(t *testing.T) { {name: "non grok account", account: &Account{Platform: PlatformOpenAI}, want: false, wantReason: "not_grok"}, {name: "non oauth grok account stays eligible", account: &Account{Platform: PlatformGrok, Type: AccountTypeAPIKey}, want: true, wantReason: "non_oauth"}, {name: "unobserved oauth fails closed", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}, want: false, wantReason: "billing_unobserved"}, + {name: "inconclusive successful billing remains eligible", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: &xai.BillingSummary{StatusCode: http.StatusOK, Partial: true}}}, want: true, wantReason: "billing_inconclusive"}, {name: "weekly paid usage is eligible without inferring from period type", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: weeklyAllowance}}, want: true, wantReason: "eligible"}, {name: "billing forbidden is rejected", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: forbiddenBilling}}, want: false, wantReason: "billing_forbidden"}, {name: "weekly billing forbidden is rejected after partial success", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: weeklyForbidden}}, want: false, wantReason: "billing_forbidden"}, @@ -65,7 +66,7 @@ func TestGrokMediaGenerationEligibility(t *testing.T) { } } -func TestGrokMediaCapabilityKeepsOnlyUnobservedOAuthAsProbeCandidate(t *testing.T) { +func TestGrokMediaCapabilityKeepsUnobservedAndInconclusiveOAuthAsCandidates(t *testing.T) { unobserved := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth} eligible, reason := unobserved.GrokMediaGenerationEligibility() require.False(t, eligible) @@ -80,7 +81,10 @@ func TestGrokMediaCapabilityKeepsOnlyUnobservedOAuthAsProbeCandidate(t *testing. Partial: true, }}, } - require.False(t, inconclusive.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityGrokMediaGeneration)) + eligible, reason = inconclusive.GrokMediaGenerationEligibility() + require.True(t, eligible) + require.Equal(t, "billing_inconclusive", reason) + require.True(t, inconclusive.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityGrokMediaGeneration)) } func TestGrokMediaCapabilityFiltersOnlyGeneration(t *testing.T) { diff --git a/backend/internal/service/account_header_override.go b/backend/internal/service/account_header_override.go index 078d98d832..3118d159fc 100644 --- a/backend/internal/service/account_header_override.go +++ b/backend/internal/service/account_header_override.go @@ -78,7 +78,7 @@ func (a *Account) IsHeaderOverrideEligible() bool { return false } switch a.Platform { - case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek: + case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: return a.Type == AccountTypeAPIKey case PlatformGrok: return a.Type == AccountTypeAPIKey || a.Type == AccountTypeOAuth diff --git a/backend/internal/service/account_scheduling_threshold_eval.go b/backend/internal/service/account_scheduling_threshold_eval.go index f8b245c51c..fb0696bed8 100644 --- a/backend/internal/service/account_scheduling_threshold_eval.go +++ b/backend/internal/service/account_scheduling_threshold_eval.go @@ -58,10 +58,8 @@ func EvaluateAccountSchedulingThreshold(account *Account, thresholds map[string] winner = pickLatestResetSchedulingCandidate(anthropicThresholdCandidates(account), threshold, now) case PlatformGrok: winner = pickLatestResetSchedulingCandidate(grokThresholdCandidates(account), threshold, now) - case PlatformKimi: - winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, PlatformKimi), threshold, now) - case PlatformZhipu: - winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, PlatformZhipu), threshold, now) + case PlatformKimi, PlatformZhipu, PlatformMiniMax: + winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, decision.Platform), threshold, now) default: return decision } diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index c8ae6dc06f..d7fac72223 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -515,7 +515,7 @@ func (s *AccountService) TestCredentials(ctx context.Context, id int64) error { case PlatformGrok: // Grok OAuth credentials are validated via token exchange/refresh and request-path probes. return nil - case PlatformKimi, PlatformZhipu, PlatformDeepseek: + case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: // 国产 OpenAI 兼容供应商:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。 return nil default: diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index 8ac3dba383..f27d5bb367 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -3,6 +3,7 @@ package service import ( "context" "strings" + "time" ) // resolveAccountStatsCost 计算账号统计定价费用。 @@ -17,6 +18,8 @@ import ( // upstreamModel 是最终发往上游的模型 ID。 // totalCost 是本次请求的客户计费(倍率前),用于优先级 2。 // serviceTier 是最终参与用户计费的 OpenAI 服务层级,用于优先级 3。 +// pricingAt 与本次客户计费使用同一时刻,避免跨峰谷请求的成本与售价错位。 +// reasoningEffort 是最终转发等级;Fable 5.1 max 默认按 3 倍额度消耗。 func resolveAccountStatsCost( ctx context.Context, channelService *ChannelService, @@ -28,7 +31,13 @@ func resolveAccountStatsCost( requestCount int, totalCost float64, serviceTier string, + pricingAt time.Time, + reasoningEfforts ...string, ) *float64 { + reasoningEffort := "" + if len(reasoningEfforts) > 0 { + reasoningEffort = reasoningEfforts[0] + } if channelService == nil || upstreamModel == "" { return nil } @@ -40,7 +49,7 @@ func resolveAccountStatsCost( platform := channelService.GetGroupPlatform(ctx, groupID) // 优先级 1:自定义规则(始终尝试) - if cost := tryCustomRules(channel, accountID, groupID, platform, upstreamModel, tokens, requestCount); cost != nil { + if cost := tryCustomRules(channel, accountID, groupID, platform, upstreamModel, tokens, requestCount, reasoningEffort); cost != nil { return cost } @@ -55,7 +64,7 @@ func resolveAccountStatsCost( // 优先级 3:模型定价文件(LiteLLM)默认价格 if billingService != nil { - return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier) + return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, pricingAt, reasoningEffort) } return nil @@ -63,12 +72,23 @@ func resolveAccountStatsCost( // tryModelFilePricing 使用模型定价文件(LiteLLM/fallback)中的价格计算费用。 // 与用户计费共用同一条定价管线,避免这里维护第二份"单价 × token 数"实现后, -// 每加一个定价特性都要手工镜像一次。channelPricing 为 nil,保持优先级 3 的 -// 语义:只取模型定价文件,不引入渠道自定义定价。 -func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string) *float64 { - breakdown, err := billingService.CalculateCostWithServiceTier( - model, tokens, 1, normalizeBillingServiceTier(serviceTier), - ) +// 每加一个定价特性都要手工镜像一次。解析器不配置渠道或分组,保持优先级 3 的 +// 语义:只取模型定价文件,不引入自定义售价。 +func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, pricingAt time.Time, reasoningEfforts ...string) *float64 { + reasoningEffort := "" + if len(reasoningEfforts) > 0 { + reasoningEffort = reasoningEfforts[0] + } + breakdown, err := billingService.CalculateCostUnified(CostInput{ + Ctx: context.Background(), + Model: model, + Tokens: tokens, + RateMultiplier: 1, + ServiceTier: normalizeBillingServiceTier(serviceTier), + ReasoningEffort: reasoningEffort, + PricingAt: pricingAt, + Resolver: NewModelPricingResolver(nil, billingService), + }) if err != nil || breakdown == nil || breakdown.TotalCost <= 0 { return nil } @@ -79,7 +99,12 @@ func tryModelFilePricing(billingService *BillingService, model string, tokens Us func tryCustomRules( channel *Channel, accountID, groupID int64, platform, model string, tokens UsageTokens, requestCount int, + reasoningEfforts ...string, ) *float64 { + reasoningEffort := "" + if len(reasoningEfforts) > 0 { + reasoningEffort = reasoningEfforts[0] + } modelLower := strings.ToLower(model) for _, rule := range channel.AccountStatsPricingRules { if !matchAccountStatsRule(&rule, accountID, groupID) { @@ -89,7 +114,11 @@ func tryCustomRules( if pricing == nil { continue // 规则匹配但模型不在规则定价中,继续下一条 } - return calculateStatsCost(pricing, tokens, requestCount) + cost := calculateStatsCost(pricing, tokens, requestCount) + if cost != nil { + *cost *= maxReasoningEffortBillingMultiplier(model, reasoningEffort, nil) + } + return cost } return nil } @@ -233,6 +262,7 @@ func applyAccountStatsCost( upstreamModel, requestedModel string, tokens UsageTokens, totalCost float64, + pricingAt time.Time, ) { model := upstreamModel if model == "" { @@ -243,10 +273,14 @@ func applyAccountStatsCost( requestCount = usageLog.ImageCount } serviceTier := "" + reasoningEffort := "" if usageLog != nil && usageLog.ServiceTier != nil { serviceTier = *usageLog.ServiceTier } + if usageLog != nil && usageLog.ReasoningEffort != nil { + reasoningEffort = *usageLog.ReasoningEffort + } usageLog.AccountStatsCost = resolveAccountStatsCost( - ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, + ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, pricingAt, reasoningEffort, ) } diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index 97628f58c0..7d4d8c7d03 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -472,12 +472,24 @@ func TestTryModelFilePricing_Success(t *testing.T) { }, }) tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "") + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) require.NotNil(t, result) // 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2 require.InDelta(t, 0.2, *result, 1e-12) } +func TestTryModelFilePricing_Fable51MaxEffortUsesTripleQuota(t *testing.T) { + bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ + "claude-fable-5-1": {InputPricePerToken: 0.001}, + }) + tokens := UsageTokens{InputTokens: 100} + standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "xhigh") + max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "max") + require.NotNil(t, standard) + require.NotNil(t, max) + require.InDelta(t, *standard*3, *max, 1e-12) +} + func TestTryModelFilePricing_AppliesLongContextPricing(t *testing.T) { bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{ "gpt-5.6-sol": { @@ -491,7 +503,7 @@ func TestTryModelFilePricing_AppliesLongContextPricing(t *testing.T) { }) tokens := UsageTokens{InputTokens: 101, OutputTokens: 10, CacheReadTokens: 5} - result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "") + result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "", time.Time{}) require.NotNil(t, result) // Input and cache-read use the 2x input tier; output uses the 1.5x tier. @@ -530,7 +542,7 @@ func TestTryModelFilePricing_AppliesServiceTierPricing(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier) + result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier, time.Time{}) require.NotNil(t, result) require.InDelta(t, tt.want, *result, 1e-12) }) @@ -560,7 +572,7 @@ func TestTryModelFilePricing_CombinesPriorityAndLongContextPricing(t *testing.T) CacheReadTokens: 5, } - result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority") + result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority", time.Time{}) require.NotNil(t, result) // priority 单价先应用,再叠加长上下文输入 2x、输出 1.5x。 @@ -571,7 +583,7 @@ func TestTryModelFilePricing_PricingNotFound(t *testing.T) { // "nonexistent-model" does not match any fallback pattern bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{}) tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} - result := tryModelFilePricing(bs, "nonexistent-model", tokens, "") + result := tryModelFilePricing(bs, "nonexistent-model", tokens, "", time.Time{}) require.Nil(t, result) } @@ -581,7 +593,7 @@ func TestTryModelFilePricing_NilFallback(t *testing.T) { "claude-sonnet-4": nil, }) tokens := UsageTokens{InputTokens: 100} - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "") + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) require.Nil(t, result) } @@ -593,7 +605,7 @@ func TestTryModelFilePricing_ZeroCost(t *testing.T) { }, }) tokens := UsageTokens{} // all zero tokens → cost = 0 → nil - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "") + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) require.Nil(t, result) } @@ -610,7 +622,7 @@ func TestTryModelFilePricing_WithImageOutput(t *testing.T) { OutputTokens: 50, ImageOutputTokens: 10, } - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "") + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) require.NotNil(t, result) // ImageOutputTokens 是 OutputTokens 的子集,先扣除再按图片单价计。 // 100*0.001 + (50-10)*0.002 + 10*0.01 = 0.1 + 0.08 + 0.1 = 0.28 @@ -632,13 +644,111 @@ func TestTryModelFilePricing_WithCacheTokens(t *testing.T) { CacheCreationTokens: 200, CacheReadTokens: 300, } - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "") + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) require.NotNil(t, result) // 100*0.001 + 50*0.002 + 200*0.003 + 300*0.0005 // = 0.1 + 0.1 + 0.6 + 0.15 = 0.95 require.InDelta(t, 0.95, *result, 1e-12) } +func TestTryModelFilePricing_DeepSeekPeakPricing(t *testing.T) { + weekday := func(hour, minute int) time.Time { + return time.Date(2026, time.August, 24, hour, minute, 0, 0, time.UTC) + } + for _, model := range []struct { + name string + input, output, cacheReadPrice float64 + }{ + {"deepseek-v4-flash", 2.2e-7, 6.6e-7, 7e-9}, + {"deepseek-v4-pro", 6.6e-7, 1.98e-6, 2.2e-8}, + } { + for _, usage := range []struct { + name string + tokens UsageTokens + }{ + {"input", UsageTokens{InputTokens: 1000}}, + {"output", UsageTokens{OutputTokens: 500}}, + {"cache_read", UsageTokens{CacheReadTokens: 1000}}, + {"mixed", UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheReadTokens: 1000}}, + } { + t.Run(model.name+"/"+usage.name, func(t *testing.T) { + bs := newTestBillingService() + tokens := usage.tokens + baseCost := float64(tokens.InputTokens)*model.input + + float64(tokens.OutputTokens)*model.output + float64(tokens.CacheReadTokens)*model.cacheReadPrice + for _, slot := range []struct { + name string + at time.Time + multiplier float64 + }{ + {"before_morning_peak", weekday(0, 59), 1}, + {"morning_peak_start", weekday(1, 0), 2}, + {"morning_peak_last_minute", weekday(3, 59), 2}, + {"morning_peak_end", weekday(4, 0), 1}, + {"afternoon_peak_start", weekday(6, 0), 2}, + {"afternoon_peak_last_minute", weekday(9, 59), 2}, + {"afternoon_peak_end", weekday(10, 0), 1}, + {"saturday", time.Date(2026, time.August, 22, 2, 0, 0, 0, time.UTC), 1}, + {"sunday", time.Date(2026, time.August, 23, 7, 0, 0, 0, time.UTC), 1}, + } { + t.Run(slot.name, func(t *testing.T) { + cost := tryModelFilePricing(bs, model.name, tokens, "", slot.at) + require.NotNil(t, cost) + require.InDelta(t, baseCost*slot.multiplier, *cost, 1e-12) + }) + } + }) + } + } +} + +func TestResolveAccountStatsCost_DeepSeekPricingPriority(t *testing.T) { + peak := time.Date(2026, time.August, 24, 2, 0, 0, 0, time.UTC) + for _, tt := range []struct { + name string + customRule bool + applyPricing bool + noChannel bool + want float64 + }{ + {name: "catalog", want: 1000 * 2.2e-7 * 2}, + {name: "custom_rule", customRule: true, want: 1}, + {name: "custom_rule_before_customer_price", customRule: true, applyPricing: true, want: 1}, + {name: "customer_price", applyPricing: true, want: 0.75}, + {name: "no_channel", noChannel: true}, + } { + t.Run(tt.name, func(t *testing.T) { + channel := &Channel{ + ID: 1, Status: StatusActive, ApplyPricingToAccountStats: tt.applyPricing, + ModelPricing: []ChannelModelPricing{{ + Models: []string{"deepseek-v4-flash"}, InputPrice: testPtrFloat64(0.02), + }}, + } + if tt.customRule { + channel.AccountStatsPricingRules = []AccountStatsPricingRule{{ + AccountIDs: []int64{1}, + Pricing: []ChannelModelPricing{{ + Models: []string{"deepseek-v4-flash"}, InputPrice: testPtrFloat64(0.001), + }}, + }} + } + cs := newTestChannelServiceForStats(t, channel, 10, PlatformDeepseek) + groupID := int64(10) + if tt.noChannel { + groupID = 99 + } + cost := resolveAccountStatsCost(context.Background(), cs, newTestBillingService(), + 1, groupID, "deepseek-v4-flash", UsageTokens{InputTokens: 1000}, 1, 0.75, "", peak) + if tt.noChannel { + require.Nil(t, cost) + return + } + require.NotNil(t, cost) + require.InDelta(t, tt.want, *cost, 1e-12) + }) + } +} + // --------------------------------------------------------------------------- // resolveAccountStatsCost — integration tests covering the 4-level priority chain // --------------------------------------------------------------------------- @@ -649,7 +759,7 @@ func TestResolveAccountStatsCost_NilChannelService(t *testing.T) { nil, // channelService is nil newTestBillingServiceWithPrices(map[string]*ModelPricing{}), 1, 1, "claude-sonnet-4", - UsageTokens{InputTokens: 100}, 1, 0.5, "", + UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, ) require.Nil(t, result) } @@ -665,7 +775,7 @@ func TestResolveAccountStatsCost_EmptyUpstreamModel(t *testing.T) { cs, newTestBillingServiceWithPrices(map[string]*ModelPricing{}), 1, 1, "", // empty upstream model - UsageTokens{InputTokens: 100}, 1, 0.5, "", + UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, ) require.Nil(t, result) } @@ -682,7 +792,7 @@ func TestResolveAccountStatsCost_GetChannelForGroupReturnsNil(t *testing.T) { cs, newTestBillingServiceWithPrices(map[string]*ModelPricing{}), 1, 99, "claude-sonnet-4", // groupID 99 has no channel - UsageTokens{InputTokens: 100}, 1, 0.5, "", + UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, ) require.Nil(t, result) } @@ -713,7 +823,7 @@ func TestResolveAccountStatsCost_HitsCustomRule(t *testing.T) { context.Background(), cs, nil, // billingService not needed when custom rule hits 1, 10, "claude-sonnet-4", - tokens, 1, 999.0, "priority", // 自定义账号价格不叠加服务层级倍率 + tokens, 1, 999.0, "priority", time.Time{}, // 自定义账号价格不叠加服务层级倍率 ) require.NotNil(t, result) // 100*0.01 + 50*0.02 = 1.0 + 1.0 = 2.0 @@ -735,7 +845,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_UsesTotalCost(t *tes context.Background(), cs, nil, 1, 10, "claude-sonnet-4", - tokens, 1, 0.75, "priority", // 已完成用户计费,不再重复应用服务层级倍率 + tokens, 1, 0.75, "priority", time.Time{}, // 已完成用户计费,不再重复应用服务层级倍率 ) require.NotNil(t, result) require.InDelta(t, 0.75, *result, 1e-12) @@ -753,7 +863,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_ZeroTotalCost_Return context.Background(), cs, nil, 1, 10, "claude-sonnet-4", - UsageTokens{}, 1, 0.0, "", // totalCost = 0 + UsageTokens{}, 1, 0.0, "", time.Time{}, // totalCost = 0 ) require.Nil(t, result) } @@ -780,7 +890,7 @@ func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) { context.Background(), cs, bs, 1, 10, "claude-sonnet-4", - tokens, 1, 999.0, "", // totalCost ignored + tokens, 1, 999.0, "", time.Time{}, // totalCost ignored ) require.NotNil(t, result) // 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2 @@ -801,7 +911,7 @@ func TestResolveAccountStatsCost_FallbackHonorsAnthropicFast(t *testing.T) { context.Background(), cs, bs, 1, 10, "claude-opus-5", UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000}, - 1, 0, "fast", + 1, 0, "fast", time.Time{}, ) require.NotNil(t, result) require.InDelta(t, 60, *result, 1e-12) @@ -820,7 +930,7 @@ func TestResolveAccountStatsCost_Gemini36FlashTierUsesFallbackPricing(t *testing context.Background(), cs, bs, 1, 10, "gemini-3.6-flash-low", - UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", + UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", time.Time{}, ) require.NotNil(t, result) require.InDelta(t, 9.15, *result, 1e-12) @@ -844,7 +954,7 @@ func TestResolveAccountStatsCost_AllMiss_ReturnsNil(t *testing.T) { context.Background(), cs, bs, 1, 10, "totally-unknown-model", - tokens, 1, 0.0, "", + tokens, 1, 0.0, "", time.Time{}, ) require.Nil(t, result) } @@ -861,7 +971,7 @@ func TestResolveAccountStatsCost_NilBillingService_SkipsLiteLLM(t *testing.T) { context.Background(), cs, nil, // billingService is nil 1, 10, "claude-sonnet-4", - UsageTokens{InputTokens: 100}, 1, 0.0, "", + UsageTokens{InputTokens: 100}, 1, 0.0, "", time.Time{}, ) require.Nil(t, result) } @@ -894,7 +1004,7 @@ func TestResolveAccountStatsCost_CustomRulePriorityOverApplyPricing(t *testing.T context.Background(), cs, nil, 1, 10, "claude-sonnet-4", - tokens, 1, 99.0, "", // totalCost = 99.0 (would be used if ApplyPricing wins) + tokens, 1, 99.0, "", time.Time{}, // totalCost = 99.0 (would be used if ApplyPricing wins) ) require.NotNil(t, result) // Custom rule: 100*0.05 = 5.0 (NOT 99.0 from totalCost) @@ -922,7 +1032,7 @@ func TestApplyAccountStatsCost_UsesUsageLogServiceTier(t *testing.T) { applyAccountStatsCost( context.Background(), usageLog, cs, bs, 1, 10, "gpt-5.6-sol", "gpt-5.6-sol", - UsageTokens{InputTokens: 100, OutputTokens: 50}, 999, + UsageTokens{InputTokens: 100, OutputTokens: 50}, 999, time.Time{}, ) require.NotNil(t, usageLog.AccountStatsCost) diff --git a/backend/internal/service/account_test_models_test.go b/backend/internal/service/account_test_models_test.go new file mode 100644 index 0000000000..789bb33bc3 --- /dev/null +++ b/backend/internal/service/account_test_models_test.go @@ -0,0 +1,64 @@ +package service + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFetchOpenAIAccountModelsOAuthPopulatesPickerFields(t *testing.T) { + _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"new-oauth-model"},{"slug":"gpt-6-astra"}]}`) + gateway := &OpenAIGatewayService{} + svc := &AccountTestService{openaiGatewayService: gateway} + account := newCodexModelsTestAccount() + ctx := context.Background() + + before, err := gateway.FetchOpenAIModelsList(ctx, account) + require.NoError(t, err) + models, err := svc.FetchOpenAIAccountModels(ctx, account) + require.NoError(t, err) + require.Len(t, models, 2) + for i, id := range []string{"new-oauth-model", "gpt-6-astra"} { + require.Equal(t, id, models[i].ID) + require.Equal(t, id, models[i].DisplayName) + require.Equal(t, "model", models[i].Type) + } + after, err := gateway.FetchOpenAIModelsList(ctx, account) + require.NoError(t, err) + require.Equal(t, before.Body, after.Body, "picker fields must not change the shared catalog") + require.NotContains(t, string(after.Body), "display_name") + require.EqualValues(t, 1, calls.Load(), "picker must reuse the shared discovery cache") +} + +func TestFetchOpenAIAccountModelsAPIKeyPopulatesPickerFields(t *testing.T) { + gateway := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return ordinaryModelsUpstreamResponse(`{"data":[ + {"id":"new-api-model","owned_by":"provider","created":123}, + {"id":"blank-label","display_name":" ","type":""}, + {"id":"named-model","display_name":"Provider Model","type":"model"} + ]}`), nil + }}) + svc := &AccountTestService{openaiGatewayService: gateway} + models, err := svc.FetchOpenAIAccountModels(context.Background(), newCodexModelsAPIKeyTestAccount("https://models.example/v1")) + require.NoError(t, err) + require.Len(t, models, 3) + for i, name := range []string{"new-api-model", "blank-label", "Provider Model"} { + require.Equal(t, name, models[i].DisplayName) + require.Equal(t, "model", models[i].Type) + } + require.Equal(t, "provider", models[0].OwnedBy) + require.EqualValues(t, 123, models[0].Created) + require.Equal(t, "named-model", models[2].ID) +} + +func TestFetchOpenAIAccountModelsPreservesEmptyCatalog(t *testing.T) { + gateway := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return ordinaryModelsUpstreamResponse(`{"data":[]}`), nil + }}) + svc := &AccountTestService{openaiGatewayService: gateway} + models, err := svc.FetchOpenAIAccountModels(context.Background(), newCodexModelsAPIKeyTestAccount("https://models.example/v1")) + require.NoError(t, err) + require.Empty(t, models, "an empty upstream catalog must not become a static model list") +} diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 4e304eca10..dc6788f2de 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -151,6 +151,7 @@ type AccountTestService struct { modelMetadataRegistry map[string]modelsDevProvider modelMetadataRegistryAt time.Time pluginManager *PluginManager + openaiGatewayService *OpenAIGatewayService agentIdentityTaskMu sync.Mutex agentIdentityWS agentIdentityWSConnectionInvalidator // grokWSDialer is optional; realtime account tests use the default OpenAI-style @@ -170,6 +171,41 @@ func (s *AccountTestService) SetPluginManager(pluginManager *PluginManager) { } } +func (s *AccountTestService) SetOpenAIGatewayService(gateway *OpenAIGatewayService) { + if s != nil { + s.openaiGatewayService = gateway + } +} + +// FetchOpenAIAccountModels uses the shared cached discovery path for the test picker. +func (s *AccountTestService) FetchOpenAIAccountModels(ctx context.Context, account *Account) ([]openai.Model, error) { + if s == nil || s.openaiGatewayService == nil { + return nil, errors.New("OpenAI model discovery service is unavailable") + } + response, err := s.openaiGatewayService.FetchOpenAIModelsList(ctx, account) + if err != nil { + return nil, err + } + var payload struct { + Data []openai.Model `json:"data"` + } + if err := json.Unmarshal(response.Body, &payload); err != nil { + return nil, fmt.Errorf("decode OpenAI account models: %w", err) + } + // Standard model catalogs do not require the fields used by the admin picker. + // Populate them here without changing the shared discovery response or cache. + for i := range payload.Data { + model := &payload.Data[i] + if strings.TrimSpace(model.DisplayName) == "" { + model.DisplayName = model.ID + } + if strings.TrimSpace(model.Type) == "" { + model.Type = "model" + } + } + return payload.Data, nil +} + // NewAccountTestService creates a new AccountTestService func NewAccountTestService( accountRepo AccountRepository, @@ -438,7 +474,9 @@ func (s *AccountTestService) testClaudeAccountConnection(c *gin.Context, account req.Header.Set("Authorization", "Bearer "+authToken) } else { req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader) - setAnthropicAPIKeyAuthHeader(req.Header, account, authToken) + // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer, + // 其余保持 extra/default 行为。 + setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetBaseURL()) } // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go index db7b2418e1..c0491fce4f 100644 --- a/backend/internal/service/account_test_service_cn_adaptive.go +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -81,7 +81,9 @@ func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Co req.Header.Set(key, value) } req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader) - setAnthropicAPIKeyAuthHeader(req.Header, account, authToken) + // Ollama Cloud Anthropic 兼容端点按 adaptive 实际选用的 Anthropic + // base_url 强制 Bearer,其余保持 extra/default 行为。 + setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetCNProtocolBaseURL(APIProtocolAnthropic)) account.ApplyHeaderOverrides(req.Header) resp, err := s.doCNProviderAdaptiveRequest(req, account) @@ -260,7 +262,9 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a for key, value := range claude.DefaultHeaders { req.Header.Set(key, value) } - setAnthropicAPIKeyAuthHeader(req.Header, account, authToken) + // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,其余保持 + // extra/default 行为。 + setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetAnthropicProtocolBaseURL()) account.ApplyHeaderOverrides(req.Header) resp, err := s.doCNProviderAdaptiveRequest(req, account) diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index f421090d3a..d7c02e3f50 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" @@ -21,6 +22,11 @@ import ( // Account management implementations func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string, sortBy, sortOrder string) ([]Account, int64, error) { + if groupID > 0 { + if err := s.ValidateAccountGroupBindings(ctx, []int64{groupID}); err != nil { + return nil, 0, err + } + } params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} accounts, result, err := s.accountRepo.ListWithFilters(ctx, params, platform, accountType, status, search, groupID, privacyMode) if err != nil { @@ -284,6 +290,9 @@ func (s *adminServiceImpl) DuplicateAccount(ctx context.Context, id int64, actor } autoPauseOnExpired := source.AutoPauseOnExpired groups, groupIDs := duplicateAccountGroups(source) + if err := s.ValidateAccountGroupBindings(ctx, groupIDs); err != nil { + return nil, err + } proxyID := source.ProxyID if source.ProxyFallbackOriginID != nil { // Proxy fallback is transient runtime state; duplicate the configured origin. @@ -474,6 +483,9 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou if err != nil { return nil, err } + if err := ValidateUpstreamRequestIDHeaderExtra(accountExtra); err != nil { + return nil, err + } // 绑定分组 groupIDs := input.GroupIDs @@ -509,6 +521,9 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou if err != nil { return nil, err } + if err := s.ValidateAccountGroupBindings(ctx, groupIDs); err != nil { + return nil, err + } if err := s.accountRepo.Create(ctx, account); err != nil { return nil, err } @@ -571,6 +586,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if err != nil { return nil, err } + if err := ValidateUpstreamRequestIDHeaderExtra(normalizedExtra); err != nil { + return nil, err + } } previousProbeIdentity := upstreamBillingProbeIdentity(account) previousOllamaUsageIdentity := ollamaCloudUsageIdentity(account) @@ -798,6 +816,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil { return nil, err } + if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil { + return nil, err + } // 检查混合渠道风险(除非用户已确认) if !input.SkipMixedChannelCheck { @@ -929,6 +950,9 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil { return nil, err } + if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil { + return nil, err + } } openAISettings, err := normalizeBulkOpenAISettings(input) if err != nil { @@ -1346,6 +1370,9 @@ func (s *adminServiceImpl) CreateShadow(ctx context.Context, parentID int64, opt } } } + if err := s.ValidateAccountGroupBindings(ctx, groupIDs); err != nil { + return nil, err + } // 4. 构造影子账号(安全不变量:Credentials 恒不含 auth token,仅含 model_mapping)。 // name 为空时默认 "<母账号名> (Spark)"——否则空 name 会在 ent(name NotEmpty)处变成裸 500 @@ -1518,6 +1545,35 @@ func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs [ return nil } +// ValidateAccountGroupBindings is the shared fail-closed policy boundary for +// every account path that accepts explicit group bindings. +func (s *adminServiceImpl) ValidateAccountGroupBindings(ctx context.Context, groupIDs []int64) error { + if len(groupIDs) == 0 || s.cfg == nil || s.cfg.RunMode != config.RunModeSimple { + return nil + } + if s.groupRepo == nil { + return errors.New("group repository not configured") + } + seen := make(map[int64]struct{}, len(groupIDs)) + for _, groupID := range groupIDs { + if groupID <= 0 { + return fmt.Errorf("get group: %w", ErrGroupNotFound) + } + if _, ok := seen[groupID]; ok { + continue + } + seen[groupID] = struct{}{} + group, err := s.groupRepo.GetByIDLite(ctx, groupID) + if err != nil { + return fmt.Errorf("get group: %w", err) + } + if !IsGroupBindableInSimpleMode(group) { + return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups cannot be bound in simple mode") + } + } + return nil +} + // CheckMixedChannelRisk checks whether target groups contain mixed channels for the current account platform. func (s *adminServiceImpl) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error { return s.checkMixedChannelRisk(ctx, currentAccountID, currentAccountPlatform, groupIDs) diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 8fcb6dd83b..fd28f681cb 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -9,6 +9,7 @@ import ( "time" dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" @@ -20,9 +21,26 @@ import ( ) // Group management implementations +func (s *adminServiceImpl) ValidateSimpleModeGroupOperation(operation AdminGroupOperation) error { + return ValidateSimpleModeGroupOperation(s.cfg, operation) +} + func (s *adminServiceImpl) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} - groups, result, err := s.groupRepo.ListWithFilters(ctx, params, platform, status, search, isExclusive) + var groups []Group + var result *pagination.PaginationResult + var err error + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { + repo, ok := s.groupRepo.(interface { + ListBindableWithFilters(context.Context, pagination.PaginationParams, string, string, string, *bool) ([]Group, *pagination.PaginationResult, error) + }) + if !ok { + return nil, 0, errors.New("group repository does not support simple-mode filtering") + } + groups, result, err = repo.ListBindableWithFilters(ctx, params, platform, status, search, isExclusive) + } else { + groups, result, err = s.groupRepo.ListWithFilters(ctx, params, platform, status, search, isExclusive) + } if err != nil { return nil, 0, err } @@ -45,7 +63,21 @@ func (s *adminServiceImpl) GetAllGroupsIncludingInactive(ctx context.Context) ([ } func (s *adminServiceImpl) GetGroup(ctx context.Context, id int64) (*Group, error) { - return s.groupRepo.GetByID(ctx, id) + group, err := s.groupRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if err := s.validateSimpleModeGroupAccess(group); err != nil { + return nil, err + } + return group, nil +} + +func (s *adminServiceImpl) validateSimpleModeGroupAccess(group *Group) error { + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple && !IsGroupBindableInSimpleMode(group) { + return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups are not supported in simple mode") + } + return nil } func (s *adminServiceImpl) GetGroupModelsListCandidates(ctx context.Context, id int64, platform string) ([]string, error) { @@ -101,6 +133,9 @@ func (s *adminServiceImpl) GetGroupModelsListCandidates(ctx context.Context, id } func (s *adminServiceImpl) ListCompositeRoutes(ctx context.Context, groupID int64) ([]CompositeModelRoute, error) { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil { + return nil, err + } if err := s.requireCompositeGroup(ctx, groupID); err != nil { return nil, err } @@ -111,6 +146,9 @@ func (s *adminServiceImpl) ListCompositeRoutes(ctx context.Context, groupID int6 } func (s *adminServiceImpl) CreateCompositeRoute(ctx context.Context, groupID int64, input CompositeRouteInput) (*CompositeModelRoute, error) { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil { + return nil, err + } if err := s.requireCompositeGroup(ctx, groupID); err != nil { return nil, err } @@ -128,6 +166,9 @@ func (s *adminServiceImpl) CreateCompositeRoute(ctx context.Context, groupID int } func (s *adminServiceImpl) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input CompositeRouteInput) (*CompositeModelRoute, error) { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil { + return nil, err + } if err := s.requireCompositeGroup(ctx, groupID); err != nil { return nil, err } @@ -151,6 +192,9 @@ func (s *adminServiceImpl) UpdateCompositeRoute(ctx context.Context, groupID, ro } func (s *adminServiceImpl) DeleteCompositeRoute(ctx context.Context, groupID, routeID int64) error { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil { + return err + } if err := s.requireCompositeGroup(ctx, groupID); err != nil { return err } @@ -166,6 +210,9 @@ func (s *adminServiceImpl) DeleteCompositeRoute(ctx context.Context, groupID, ro } func (s *adminServiceImpl) PreviewCompositeRoute(ctx context.Context, groupID int64, input CompositeRoutePreviewRequest) (*CompositeRouteDecision, error) { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationCompositeRoute); err != nil { + return nil, err + } if err := s.requireCompositeGroup(ctx, groupID); err != nil { return nil, err } @@ -267,7 +314,7 @@ func defaultAllowImageGenerationForPlatform(platform string) bool { func compositeDefaultModelsListCandidateIDs() []string { seen := make(map[string]struct{}) ids := make([]string, 0) - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} { + for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { for _, id := range defaultModelsListCandidateIDs(platform) { if _, ok := seen[id]; ok { continue @@ -308,12 +355,40 @@ func sanitizeGroupOpenAIFast(group *Group) { } } +func normalizeCreateGroupInputForSimpleMode(input *CreateGroupInput) { + if input == nil { + return + } + *input = CreateGroupInput{ + Name: input.Name, Description: input.Description, Platform: input.Platform, + RateMultiplier: 1, SubscriptionType: SubscriptionTypeStandard, + } +} + +func normalizeUpdateGroupInputForSimpleMode(input *UpdateGroupInput) { + if input == nil { + return + } + *input = UpdateGroupInput{Name: input.Name, Description: input.Description} +} + func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) { + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple && NormalizeGroupPlatform(input.Platform) == PlatformComposite { + return nil, infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups are not supported in simple mode") + } + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { + normalizeCreateGroupInputForSimpleMode(input) + } if input.RateMultiplier <= 0 { return nil, errors.New("rate_multiplier must be > 0") } platform := NormalizeGroupPlatform(input.Platform) + // 固定账号 manifest 配置:账号绑定发生在创建之后,创建时无法校验成员关系, + // 拒绝开启并在创建后的编辑里配置。 + if normalizeCodexModelsManifestConfig(platform, input.CodexModelsManifestConfig).Enabled { + return nil, infraerrors.New(http.StatusBadRequest, "INVALID_CODEX_MODELS_MANIFEST_CONFIG", "codex models manifest config cannot be enabled at group creation; configure it after creation in the group editor") + } modelPricing, err := normalizeGroupModelPricing(platform, input.ModelPricing) if err != nil { return nil, err @@ -469,6 +544,12 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn } } + // 白名单在创建路径同样收口:开启但为空、通配位置非法都会 400。 + modelAllowlist, err := normalizeGroupModelAllowlist(input.ModelAllowlist) + if err != nil { + return nil, err + } + group := &Group{ Name: input.Name, Description: input.Description, @@ -523,11 +604,14 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn RequirePrivacySet: input.RequirePrivacySet, DefaultMappedModel: input.DefaultMappedModel, MessagesDispatchModelConfig: normalizeOpenAIMessagesDispatchModelConfig(input.MessagesDispatchModelConfig), - ModelsListConfig: normalizeGroupModelsListConfig(input.ModelsListConfig), - RPMLimit: input.RPMLimit, - MaxReasoningEffort: maxReasoningEffort, - MaxReasoningEffortOverLimit: maxReasoningEffortOverLimit, - ReasoningEffortMappings: reasoningEffortMappings, + ModelAllowlist: modelAllowlist, + // 固定账号 manifest 配置:账号绑定发生在分组创建之后,创建路径禁止开启, + // 成员关系无从校验(前端创建对话框也不展示)。 + CodexModelsManifestConfig: normalizeCodexModelsManifestConfig(platform, input.CodexModelsManifestConfig), + RPMLimit: input.RPMLimit, + MaxReasoningEffort: maxReasoningEffort, + MaxReasoningEffortOverLimit: maxReasoningEffortOverLimit, + ReasoningEffortMappings: reasoningEffortMappings, } sanitizeGroupMessagesDispatchFields(group) sanitizeGroupOpenAIFast(group) @@ -661,6 +745,15 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if err != nil { return nil, err } + if err := s.validateSimpleModeGroupAccess(group); err != nil { + return nil, err + } + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple && input.Platform == PlatformComposite { + return nil, infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups are not supported in simple mode") + } + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { + normalizeUpdateGroupInputForSimpleMode(input) + } // 渠道缓存里存了 groupID → platform 的映射,改了平台要让它失效(见函数末尾) previousPlatform := group.Platform @@ -901,8 +994,15 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.MessagesDispatchModelConfig != nil { group.MessagesDispatchModelConfig = normalizeOpenAIMessagesDispatchModelConfig(*input.MessagesDispatchModelConfig) } - if input.ModelsListConfig != nil { - group.ModelsListConfig = normalizeGroupModelsListConfig(*input.ModelsListConfig) + if input.ModelAllowlist != nil { + modelAllowlist, err := normalizeGroupModelAllowlist(*input.ModelAllowlist) + if err != nil { + return nil, err + } + group.ModelAllowlist = modelAllowlist + } + if input.CodexModelsManifestConfig != nil { + group.CodexModelsManifestConfig = *input.CodexModelsManifestConfig } if input.RPMLimit != nil { group.RPMLimit = *input.RPMLimit @@ -934,6 +1034,15 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd group.AllowLive = false } sanitizeGroupReasoningEffortPolicy(group) + // 固定账号 manifest 配置:按最终平台归一化(切出 openai 平台时静默归零, + // 与 ForceOpenAIFast 同一收口);校验仅在本次显式携带配置时进行, + // 避免脏 ID 阻塞无关字段更新。 + group.CodexModelsManifestConfig = normalizeCodexModelsManifestConfig(group.Platform, group.CodexModelsManifestConfig) + if input.CodexModelsManifestConfig != nil { + if err := s.validateCodexModelsManifestConfig(ctx, id, group.CodexModelsManifestConfig); err != nil { + return nil, err + } + } if err := s.groupRepo.Update(ctx, group); err != nil { return nil, err @@ -1050,6 +1159,27 @@ func normalizeGroupModelPricing(platform string, pricing []ChannelModelPricing) } func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error { + return s.deleteGroup(ctx, id, false) +} + +func (s *adminServiceImpl) DeleteGroupIfEmpty(ctx context.Context, id int64) error { + return s.deleteGroup(ctx, id, true) +} + +func (s *adminServiceImpl) deleteGroup(ctx context.Context, id int64, requireEmpty bool) error { + if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { + group, err := s.groupRepo.GetByIDLite(ctx, id) + if err != nil { + return err + } + if err := s.validateSimpleModeGroupAccess(group); err != nil { + return err + } + } + if requireEmpty && s.emptyGroupDeleteRepo == nil { + return fmt.Errorf("guarded group deletion is unavailable") + } + var groupKeys []string if s.authCacheInvalidator != nil { keys, err := s.apiKeyRepo.ListKeysByGroupID(ctx, id) @@ -1058,7 +1188,13 @@ func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error { } } - affectedUserIDs, err := s.groupRepo.DeleteCascade(ctx, id) + var affectedUserIDs []int64 + var err error + if requireEmpty { + affectedUserIDs, err = s.emptyGroupDeleteRepo.DeleteCascadeIfEmpty(ctx, id) + } else { + affectedUserIDs, err = s.groupRepo.DeleteCascade(ctx, id) + } if err != nil { return err } @@ -1096,6 +1232,9 @@ func (s *adminServiceImpl) GetGroupAPIKeys(ctx context.Context, groupID int64, p } func (s *adminServiceImpl) GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error) { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationMultiplier); err != nil { + return nil, err + } if s.userGroupRateRepo == nil { return nil, nil } @@ -1103,6 +1242,9 @@ func (s *adminServiceImpl) GetGroupRateMultipliers(ctx context.Context, groupID } func (s *adminServiceImpl) ClearGroupRateMultipliers(ctx context.Context, groupID int64) error { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationMultiplier); err != nil { + return err + } if s.userGroupRateRepo == nil { return nil } @@ -1110,6 +1252,9 @@ func (s *adminServiceImpl) ClearGroupRateMultipliers(ctx context.Context, groupI } func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationMultiplier); err != nil { + return err + } if s.userGroupRateRepo == nil { return nil } @@ -1122,6 +1267,9 @@ func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, gro } func (s *adminServiceImpl) ClearGroupRPMOverrides(ctx context.Context, groupID int64) error { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationRPMOverride); err != nil { + return err + } if s.userGroupRateRepo == nil { return nil } @@ -1136,6 +1284,9 @@ func (s *adminServiceImpl) ClearGroupRPMOverrides(ctx context.Context, groupID i } func (s *adminServiceImpl) BatchSetGroupRPMOverrides(ctx context.Context, groupID int64, entries []GroupRPMOverrideInput) error { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationRPMOverride); err != nil { + return err + } if s.userGroupRateRepo == nil { return nil } @@ -1155,6 +1306,9 @@ func (s *adminServiceImpl) BatchSetGroupRPMOverrides(ctx context.Context, groupI } func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationSort); err != nil { + return err + } return s.groupRepo.UpdateSortOrders(ctx, updates) } diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index b168f7773f..859d19d6f2 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -150,10 +150,12 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { RequirePrivacySet: source.RequirePrivacySet, DefaultMappedModel: source.DefaultMappedModel, MessagesDispatchModelConfig: cloneGroupMessagesDispatchModelConfig(source.MessagesDispatchModelConfig), - ModelsListConfig: GroupModelsListConfig{ - Enabled: source.ModelsListConfig.Enabled, - Models: append([]string(nil), source.ModelsListConfig.Models...), + ModelAllowlist: GroupModelAllowlist{ + Enabled: source.ModelAllowlist.Enabled, + Models: append([]string(nil), source.ModelAllowlist.Models...), }, + // 固定账号 manifest 配置指向源分组的账号 ID,复制后成员关系可能变化,重置为关闭且列表为空。 + CodexModelsManifestConfig: GroupCodexModelsManifestConfig{}, RPMLimit: source.RPMLimit, MaxReasoningEffort: source.MaxReasoningEffort, MaxReasoningEffortOverLimit: source.MaxReasoningEffortOverLimit, @@ -164,6 +166,9 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { // RecoverDuplicateGroup performs a read-only lookup for a copy that was already // committed for the same actor, source group, and idempotency key. func (s *adminServiceImpl) RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationDuplicate); err != nil { + return nil, err + } operationID := duplicateGroupOperationID(id, actorScope, operationKey) if operationID == "" { return nil, nil @@ -189,6 +194,9 @@ func (s *adminServiceImpl) RecoverDuplicateGroup(ctx context.Context, id int64, // account priorities. The repository commits the group, bindings, and outbox // event atomically so a failed binding never leaves an orphan group. func (s *adminServiceImpl) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) { + if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationDuplicate); err != nil { + return nil, err + } existing, err := s.RecoverDuplicateGroup(ctx, id, actorScope, operationKey) if err != nil { return nil, err diff --git a/backend/internal/service/admin_group_duplicate_test.go b/backend/internal/service/admin_group_duplicate_test.go index 75fd8ea173..d991590a06 100644 --- a/backend/internal/service/admin_group_duplicate_test.go +++ b/backend/internal/service/admin_group_duplicate_test.go @@ -61,7 +61,7 @@ func cloneGroupForDuplicateTest(group *Group) *Group { cloned.ModelRouting = cloneGroupModelRouting(group.ModelRouting) cloned.SupportedModelScopes = append([]string(nil), group.SupportedModelScopes...) cloned.MessagesDispatchModelConfig = cloneGroupMessagesDispatchModelConfig(group.MessagesDispatchModelConfig) - cloned.ModelsListConfig.Models = append([]string(nil), group.ModelsListConfig.Models...) + cloned.ModelAllowlist.Models = append([]string(nil), group.ModelAllowlist.Models...) cloned.AccountGroups = append([]AccountGroup(nil), group.AccountGroups...) return &cloned } @@ -177,7 +177,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing HaikuMappedModel: "gpt-5-mini", ExactModelMappings: map[string]string{"claude-special": "gpt-special"}, }, - ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"gpt-5.4", "gpt-5-mini"}}, + ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.4", "gpt-5-mini"}}, RPMLimit: 99, MaxReasoningEffort: "medium", MaxReasoningEffortOverLimit: ReasoningEffortOverLimitDeny, @@ -217,7 +217,7 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing require.Equal(t, source.MessagesDispatchModelConfig, duplicate.MessagesDispatchModelConfig) require.Equal(t, source.ForceOpenAIFast, duplicate.ForceOpenAIFast) require.Equal(t, source.FreeOpenAIFast, duplicate.FreeOpenAIFast) - require.Equal(t, source.ModelsListConfig, duplicate.ModelsListConfig) + require.Equal(t, source.ModelAllowlist, duplicate.ModelAllowlist) require.Equal(t, source.RPMLimit, duplicate.RPMLimit) require.Equal(t, source.MaxReasoningEffort, duplicate.MaxReasoningEffort) require.Equal(t, source.MaxReasoningEffortOverLimit, duplicate.MaxReasoningEffortOverLimit) @@ -235,14 +235,14 @@ func TestDuplicateGroupCopiesConfigurationDeeplyAndResetsRuntimeState(t *testing duplicate.VideoModelPrices[VideoPriceFamilyGrokImagineVideo15][VideoBillingResolution720P] = 999 duplicate.SupportedModelScopes[0] = "changed" duplicate.MessagesDispatchModelConfig.ExactModelMappings["claude-special"] = "changed" - duplicate.ModelsListConfig.Models[0] = "changed" + duplicate.ModelAllowlist.Models[0] = "changed" duplicate.ReasoningEffortMappings[0].To = "changed" *duplicate.DailyLimitUSD = 999 require.Equal(t, int64(13), source.ModelRouting["gpt-*"][0]) require.Equal(t, 0.14, source.VideoModelPrices[VideoPriceFamilyGrokImagineVideo15][VideoBillingResolution720P]) require.Equal(t, "claude", source.SupportedModelScopes[0]) require.Equal(t, "gpt-special", source.MessagesDispatchModelConfig.ExactModelMappings["claude-special"]) - require.Equal(t, "gpt-5.4", source.ModelsListConfig.Models[0]) + require.Equal(t, "gpt-5.4", source.ModelAllowlist.Models[0]) require.Equal(t, "xhigh", source.ReasoningEffortMappings[0].To) require.Equal(t, 11.0, *source.DailyLimitUSD) } diff --git a/backend/internal/service/admin_proxy.go b/backend/internal/service/admin_proxy.go index 9b5a9223c7..0475330a01 100644 --- a/backend/internal/service/admin_proxy.go +++ b/backend/internal/service/admin_proxy.go @@ -56,6 +56,9 @@ func (s *adminServiceImpl) GetProxiesByIDs(ctx context.Context, ids []int64) ([] } func (s *adminServiceImpl) CreateProxy(ctx context.Context, input *CreateProxyInput) (*Proxy, error) { + if !isJSONTimeInRange(input.ExpiresAt) { + return nil, infraerrors.BadRequest("PROXY_EXPIRY_INVALID", "proxy expiry year must be between 0 and 9999") + } // 规范化 fallback_mode mode := input.FallbackMode if mode == "" { @@ -91,28 +94,34 @@ func (s *adminServiceImpl) CreateProxy(ctx context.Context, input *CreateProxyIn } func (s *adminServiceImpl) UpdateProxy(ctx context.Context, id int64, input *UpdateProxyInput) (*Proxy, error) { + if !isJSONTimeInRange(input.ExpiresAt) { + return nil, infraerrors.BadRequest("PROXY_EXPIRY_INVALID", "proxy expiry year must be between 0 and 9999") + } // 校验:backup_proxy_id 不能是自身 if input.BackupProxyID != nil && *input.BackupProxyID == id { return nil, infraerrors.BadRequest("PROXY_BACKUP_SELF", "backup proxy cannot be itself") } - // 规范化 fallback_mode - mode := input.FallbackMode - if mode == "" { - mode = FallbackModeNone - } - // 校验:mode=proxy 必须有 backup - if mode == FallbackModeProxy && input.BackupProxyID == nil { - return nil, infraerrors.BadRequest("PROXY_BACKUP_REQUIRED", "backup proxy required when fallback_mode=proxy") - } - if input.ExpiryWarnDays < 0 { - return nil, infraerrors.BadRequest("PROXY_WARN_DAYS_INVALID", "expiry_warn_days must be >= 0") - } - proxy, err := s.proxyRepo.GetByID(ctx, id) if err != nil { return nil, err } + // Merge only supplied fields, then validate the resulting fallback configuration. + mode := proxy.FallbackMode + if input.FallbackMode != "" { + mode = input.FallbackMode + } + backupID := proxy.BackupProxyID + if input.BackupProxyID != nil || input.ClearBackupID { + backupID = input.BackupProxyID + } + if mode == FallbackModeProxy && backupID == nil { + return nil, infraerrors.BadRequest("PROXY_BACKUP_REQUIRED", "backup proxy required when fallback_mode=proxy") + } + if input.ExpiryWarnDays != nil && *input.ExpiryWarnDays < 0 { + return nil, infraerrors.BadRequest("PROXY_WARN_DAYS_INVALID", "expiry_warn_days must be >= 0") + } + if input.Name != "" { proxy.Name = input.Name } @@ -134,11 +143,14 @@ func (s *adminServiceImpl) UpdateProxy(ctx context.Context, id int64, input *Upd if input.Status != "" { proxy.Status = input.Status } - // 透传有效期与回退字段 - proxy.ExpiresAt = input.ExpiresAt + if input.ExpiresAt != nil || input.ClearExpiresAt { + proxy.ExpiresAt = input.ExpiresAt + } proxy.FallbackMode = mode - proxy.BackupProxyID = input.BackupProxyID - proxy.ExpiryWarnDays = input.ExpiryWarnDays + proxy.BackupProxyID = backupID + if input.ExpiryWarnDays != nil { + proxy.ExpiryWarnDays = *input.ExpiryWarnDays + } if err := s.proxyRepo.Update(ctx, proxy); err != nil { return nil, err diff --git a/backend/internal/service/admin_proxy_date_validation_test.go b/backend/internal/service/admin_proxy_date_validation_test.go new file mode 100644 index 0000000000..f04e8609ee --- /dev/null +++ b/backend/internal/service/admin_proxy_date_validation_test.go @@ -0,0 +1,41 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestAdminProxyRejectsOutOfRangeExpiry(t *testing.T) { + for _, year := range []int{-1, 10000} { + expiry := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) + t.Run(expiry.String(), func(t *testing.T) { + // A nil repository ensures rejection happens before any persistence or probe. + svc := &adminServiceImpl{} + _, err := svc.CreateProxy(context.Background(), &CreateProxyInput{ExpiresAt: &expiry}) + require.Error(t, err) + require.Contains(t, err.Error(), "proxy expiry year") + _, err = svc.UpdateProxy(context.Background(), 1, &UpdateProxyInput{ExpiresAt: &expiry}) + require.Error(t, err) + require.Contains(t, err.Error(), "proxy expiry year") + }) + } +} + +func TestAdminProxyAcceptsJSONExpiryBoundaries(t *testing.T) { + for _, year := range []int{0, 9999} { + expiry := time.Date(year, 12, 31, 23, 59, 59, 0, time.UTC) + repo := &updatingProxyRepoStub{proxyRepoStub: &proxyRepoStub{}, proxy: &Proxy{ID: 9}} + svc := &adminServiceImpl{proxyRepo: repo} + got, err := svc.UpdateProxy(context.Background(), 9, &UpdateProxyInput{ExpiresAt: &expiry}) + require.NoError(t, err) + _, err = json.Marshal(got) + require.NoError(t, err) + require.Equal(t, &expiry, repo.proxy.ExpiresAt) + } +} diff --git a/backend/internal/service/admin_proxy_partial_update_test.go b/backend/internal/service/admin_proxy_partial_update_test.go new file mode 100644 index 0000000000..78960c4901 --- /dev/null +++ b/backend/internal/service/admin_proxy_partial_update_test.go @@ -0,0 +1,81 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestAdminProxyPartialUpdatePreservesOmittedSettings(t *testing.T) { + for _, input := range []*UpdateProxyInput{{Status: "inactive"}, {Name: "renamed"}, {Host: "new.example"}} { + expiry := time.Now().Add(24 * time.Hour) + backup := int64(10) + original := &Proxy{ID: 9, Name: "original", Host: "old.example", Status: StatusActive, ExpiresAt: &expiry, FallbackMode: FallbackModeProxy, BackupProxyID: &backup, ExpiryWarnDays: 7} + repo := &updatingProxyRepoStub{proxyRepoStub: &proxyRepoStub{}, proxy: original} + svc := &adminServiceImpl{proxyRepo: repo} + got, err := svc.UpdateProxy(context.Background(), 9, input) + require.NoError(t, err) + require.Equal(t, original.ExpiresAt, got.ExpiresAt) + require.Equal(t, original.FallbackMode, got.FallbackMode) + require.Equal(t, original.BackupProxyID, got.BackupProxyID) + require.Equal(t, original.ExpiryWarnDays, got.ExpiryWarnDays) + require.Equal(t, 1, repo.updateCalls) + } +} + +func TestAdminProxyPartialUpdateClearsAndSetsSettings(t *testing.T) { + expiry := time.Now().Add(time.Hour) + backup := int64(10) + zero := 0 + repo := &updatingProxyRepoStub{proxyRepoStub: &proxyRepoStub{}, proxy: &Proxy{ID: 9, ExpiresAt: &expiry, FallbackMode: FallbackModeProxy, BackupProxyID: &backup, ExpiryWarnDays: 7}} + svc := &adminServiceImpl{proxyRepo: repo} + got, err := svc.UpdateProxy(context.Background(), 9, &UpdateProxyInput{ + ClearExpiresAt: true, FallbackMode: FallbackModeNone, ClearBackupID: true, ExpiryWarnDays: &zero, + }) + require.NoError(t, err) + require.Nil(t, got.ExpiresAt) + require.Nil(t, got.BackupProxyID) + require.Equal(t, FallbackModeNone, got.FallbackMode) + require.Zero(t, got.ExpiryWarnDays) + days := 3 + got, err = svc.UpdateProxy(context.Background(), 9, &UpdateProxyInput{ + ExpiresAt: &expiry, FallbackMode: FallbackModeProxy, BackupProxyID: &backup, ExpiryWarnDays: &days, + }) + require.NoError(t, err) + require.Equal(t, &expiry, got.ExpiresAt) + require.Equal(t, &backup, got.BackupProxyID) + require.Equal(t, FallbackModeProxy, got.FallbackMode) + require.Equal(t, days, got.ExpiryWarnDays) +} + +func TestAdminProxyPartialUpdateValidatesMergedFallback(t *testing.T) { + backup := int64(10) + self := int64(9) + negative := -1 + for _, tc := range []struct { + name string + input UpdateProxyInput + wantError bool + }{ + {name: "reuse existing backup", input: UpdateProxyInput{FallbackMode: FallbackModeProxy}}, + {name: "clear required backup", input: UpdateProxyInput{ClearBackupID: true}, wantError: true}, + {name: "self backup", input: UpdateProxyInput{BackupProxyID: &self}, wantError: true}, + {name: "negative warning", input: UpdateProxyInput{ExpiryWarnDays: &negative}, wantError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + repo := &updatingProxyRepoStub{proxyRepoStub: &proxyRepoStub{}, proxy: &Proxy{ID: 9, FallbackMode: FallbackModeProxy, BackupProxyID: &backup}} + svc := &adminServiceImpl{proxyRepo: repo} + _, err := svc.UpdateProxy(context.Background(), 9, &tc.input) + if tc.wantError { + require.Error(t, err) + require.Zero(t, repo.updateCalls) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 392f8d5d46..7a3644a597 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -6,6 +6,7 @@ import ( "time" dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) @@ -48,6 +49,7 @@ type AdminService interface { RecoverDuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*Group, error) UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error) DeleteGroup(ctx context.Context, id int64) error + DeleteGroupIfEmpty(ctx context.Context, id int64) error ListCompositeRoutes(ctx context.Context, groupID int64) ([]CompositeModelRoute, error) CreateCompositeRoute(ctx context.Context, groupID int64, input CompositeRouteInput) (*CompositeModelRoute, error) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input CompositeRouteInput) (*CompositeModelRoute, error) @@ -79,6 +81,7 @@ type AdminService interface { GetAccount(ctx context.Context, id int64) (*Account, error) GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) + ValidateAccountGroupBindings(ctx context.Context, groupIDs []int64) error // DuplicateAccount creates an independent account from an existing account's configuration. // First-class runtime columns are intentionally reset by the normal account creation path. DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*Account, error) @@ -137,6 +140,24 @@ type AdminService interface { ResetAccountQuota(ctx context.Context, id int64) error } +type AdminGroupOperation string + +const ( + AdminGroupOperationBasic AdminGroupOperation = "basic" + AdminGroupOperationDuplicate AdminGroupOperation = "duplicate" + AdminGroupOperationCompositeRoute AdminGroupOperation = "composite_route" + AdminGroupOperationMultiplier AdminGroupOperation = "multiplier" + AdminGroupOperationRPMOverride AdminGroupOperation = "rpm_override" + AdminGroupOperationSort AdminGroupOperation = "sort" +) + +func ValidateSimpleModeGroupOperation(cfg *config.Config, operation AdminGroupOperation) error { + if cfg != nil && cfg.RunMode == config.RunModeSimple && operation != AdminGroupOperationBasic { + return infraerrors.New(http.StatusForbidden, "SIMPLE_MODE_OPERATION_UNSUPPORTED", "This operation is not supported in simple mode") + } + return nil +} + // CreateUserInput represents input for creating a new user via admin operations. type CreateUserInput struct { Email string @@ -272,14 +293,16 @@ type CreateGroupInput struct { RequireOAuthOnly bool RequirePrivacySet bool MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig - ModelsListConfig GroupModelsListConfig + ModelAllowlist GroupModelAllowlist + // CodexModelsManifestConfig 固定账号 manifest 配置;创建路径禁止开启,仅编辑可配置。 + CodexModelsManifestConfig GroupCodexModelsManifestConfig // RPMLimit 分组 RPM 上限(0 = 不限制) RPMLimit int - // MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。 + // MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。 MaxReasoningEffort string // MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。 MaxReasoningEffortOverLimit string - // ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。 + // ReasoningEffortMappings Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。 ReasoningEffortMappings []ReasoningEffortMapping // 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数,nil 按 0 处理) ProfitControlEnabled bool @@ -351,7 +374,9 @@ type UpdateGroupInput struct { RequireOAuthOnly *bool RequirePrivacySet *bool MessagesDispatchModelConfig *OpenAIMessagesDispatchModelConfig - ModelsListConfig *GroupModelsListConfig + ModelAllowlist *GroupModelAllowlist + // CodexModelsManifestConfig nil 表示不修改;非 openai 平台会被归一化为关闭。 + CodexModelsManifestConfig *GroupCodexModelsManifestConfig // RPMLimit 分组 RPM 上限(0 = 不限制),nil 表示未提供不改动。 RPMLimit *int // MaxReasoningEffort 空字符串表示清除上限;nil 表示未提供不改动。 @@ -509,6 +534,8 @@ type CreateProxyInput struct { ExpiryWarnDays int } +// UpdateProxyInput preserves omitted expiry/backup values; Clear flags explicitly +// remove them. A nil ExpiryWarnDays preserves the current warning period. type UpdateProxyInput struct { Name string Protocol string @@ -518,9 +545,11 @@ type UpdateProxyInput struct { Password string Status string ExpiresAt *time.Time + ClearExpiresAt bool FallbackMode string BackupProxyID *int64 - ExpiryWarnDays int + ClearBackupID bool + ExpiryWarnDays *int } type GenerateRedeemCodesInput struct { @@ -654,9 +683,11 @@ var ErrRPMStatusUnavailable = infraerrors.New(http.StatusNotImplemented, "RPM_ST // adminServiceImpl implements AdminService type adminServiceImpl struct { + cfg *config.Config userRepo UserRepository groupRepo GroupRepository groupDuplicateRepo GroupDuplicateRepository + emptyGroupDeleteRepo EmptyGroupDeleteRepository accountRepo AccountRepository accountDuplicateRepo AccountDuplicateRepository accountBillingRepo AccountBillingSettingsRepository @@ -698,6 +729,7 @@ type userGroupRateBatchReader interface { // NewAdminService creates a new AdminService func NewAdminService( + cfg *config.Config, userRepo UserRepository, groupRepo AdminGroupRepository, accountRepo AdminAccountRepository, @@ -722,9 +754,11 @@ func NewAdminService( channelCacheInvalidator ChannelCacheInvalidator, ) AdminService { return &adminServiceImpl{ + cfg: cfg, userRepo: userRepo, groupRepo: groupRepo, groupDuplicateRepo: groupRepo, + emptyGroupDeleteRepo: groupRepo, accountRepo: accountRepo, accountDuplicateRepo: accountRepo, accountBillingRepo: accountRepo, diff --git a/backend/internal/service/admin_service_composite_group_test.go b/backend/internal/service/admin_service_composite_group_test.go index 5463f9e513..257d001804 100644 --- a/backend/internal/service/admin_service_composite_group_test.go +++ b/backend/internal/service/admin_service_composite_group_test.go @@ -213,7 +213,7 @@ func TestAdminService_CNProviderModelsListCandidatesKeepClaudeDefaults(t *testin for _, model := range claude.DefaultModels { want = append(want, model.ID) } - for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { require.Equal(t, want, defaultModelsListCandidateIDs(platform), "platform=%s", platform) } } diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index 5925a59dda..316505d537 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -247,6 +247,7 @@ type groupRepoStub struct { affectedUserIDs []int64 deleteErr error deleteCalls []int64 + guardedCalls []int64 } func (s *groupRepoStub) Create(ctx context.Context, group *Group) error { @@ -274,6 +275,11 @@ func (s *groupRepoStub) DeleteCascade(ctx context.Context, id int64) ([]int64, e return s.affectedUserIDs, s.deleteErr } +func (s *groupRepoStub) DeleteCascadeIfEmpty(ctx context.Context, id int64) ([]int64, error) { + s.guardedCalls = append(s.guardedCalls, id) + return s.affectedUserIDs, s.deleteErr +} + func (s *groupRepoStub) List(ctx context.Context, params pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) { panic("unexpected List call") } @@ -703,6 +709,22 @@ func TestAdminService_DeleteGroup_Error(t *testing.T) { require.ErrorIs(t, err, deleteErr) } +func TestAdminService_DeleteGroupIfEmpty_UsesGuardedCascade(t *testing.T) { + repo := &groupRepoStub{deleteErr: ErrGroupNotEmpty} + svc := &adminServiceImpl{groupRepo: repo, emptyGroupDeleteRepo: repo} + + err := svc.DeleteGroupIfEmpty(context.Background(), 42) + require.ErrorIs(t, err, ErrGroupNotEmpty) + require.Equal(t, []int64{42}, repo.guardedCalls) + require.Empty(t, repo.deleteCalls) +} + +func TestAdminService_DeleteGroupIfEmpty_MissingCapabilityReturnsError(t *testing.T) { + svc := &adminServiceImpl{groupRepo: &groupRepoStub{}} + + require.ErrorContains(t, svc.DeleteGroupIfEmpty(context.Background(), 42), "guarded group deletion is unavailable") +} + func TestAdminService_DeleteProxy_Success(t *testing.T) { repo := &proxyRepoStub{} svc := &adminServiceImpl{proxyRepo: repo} diff --git a/backend/internal/service/admin_service_duplicate_account_test.go b/backend/internal/service/admin_service_duplicate_account_test.go index 7bd31778d7..b04e7d7e6b 100644 --- a/backend/internal/service/admin_service_duplicate_account_test.go +++ b/backend/internal/service/admin_service_duplicate_account_test.go @@ -11,6 +11,7 @@ import ( "time" "unicode/utf8" + "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/stretchr/testify/require" ) @@ -266,6 +267,28 @@ func TestDuplicateAccountPreservesUngroupedState(t *testing.T) { require.NotContains(t, repo.groupsOf, duplicate.ID) } +func TestDuplicateAccountSimpleModeRejectsCompositeGroupBinding(t *testing.T) { + ctx := context.Background() + repo := newDuplicateAccountRepoStub() + groupRepo := &groupRepoStubForAdmin{getByIDByID: map[int64]*Group{ + 9: {ID: 9, Platform: PlatformComposite}, + }} + svc := &adminServiceImpl{ + cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: groupRepo, + accountRepo: repo, accountDuplicateRepo: repo, + } + source := &Account{ + Name: "composite-bound", Platform: PlatformAnthropic, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "secret"}, GroupIDs: []int64{9}, + } + require.NoError(t, repo.Create(ctx, source)) + + _, err := svc.DuplicateAccount(ctx, source.ID, "admin:1", "") + + require.Equal(t, "SIMPLE_MODE_GROUP_NOT_BINDABLE", infraerrors.Reason(err)) + require.Len(t, repo.accounts, 1) +} + func TestDuplicateAccountAtomicCreateFailureLeavesNoOrphan(t *testing.T) { ctx := context.Background() repo := newDuplicateAccountRepoStub() diff --git a/backend/internal/service/admin_service_group_model_allowlist_test.go b/backend/internal/service/admin_service_group_model_allowlist_test.go new file mode 100644 index 0000000000..fc5a14cfe2 --- /dev/null +++ b/backend/internal/service/admin_service_group_model_allowlist_test.go @@ -0,0 +1,125 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "testing" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/require" +) + +func TestAdminService_CreateGroup_RejectsEmptyEnabledModelAllowlist(t *testing.T) { + repo := &groupRepoStubForAdmin{createID: 51} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "allowlist-empty-group", + Platform: PlatformOpenAI, + RateMultiplier: 1, + ModelAllowlist: GroupModelAllowlist{Enabled: true}, + }) + + require.Error(t, err) + appErr := infraerrors.FromError(err) + require.Equal(t, int32(http.StatusBadRequest), appErr.Code) + require.Equal(t, "INVALID_MODEL_ALLOWLIST", appErr.Reason) + require.Nil(t, repo.created, "拒绝时不得落库") +} + +func TestAdminService_CreateGroup_RejectsInvalidAllowlistWildcard(t *testing.T) { + repo := &groupRepoStubForAdmin{createID: 51} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "allowlist-wildcard-group", + Platform: PlatformOpenAI, + RateMultiplier: 1, + ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4"}}, + }) + + require.Error(t, err) + appErr := infraerrors.FromError(err) + require.Equal(t, int32(http.StatusBadRequest), appErr.Code) + require.Equal(t, "INVALID_MODEL_ALLOWLIST", appErr.Reason) + require.Nil(t, repo.created, "拒绝时不得落库") +} + +func TestAdminService_CreateGroup_NormalizesModelAllowlist(t *testing.T) { + repo := &groupRepoStubForAdmin{createID: 52} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "allowlist-normalized-group", + Platform: PlatformOpenAI, + RateMultiplier: 1, + ModelAllowlist: GroupModelAllowlist{ + Enabled: true, + Models: []string{" gpt-5.4 ", "GPT-5.4", "claude-*", " "}, + }, + }) + + require.NoError(t, err) + require.NotNil(t, repo.created) + require.True(t, repo.created.ModelAllowlist.Enabled) + require.Equal(t, []string{"gpt-5.4", "claude-*"}, repo.created.ModelAllowlist.Models) +} + +func TestAdminService_UpdateGroup_RejectsEmptyEnabledModelAllowlist(t *testing.T) { + existing := &Group{ID: 1, Name: "existing", Platform: PlatformOpenAI, Status: StatusActive} + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ + ModelAllowlist: &GroupModelAllowlist{Enabled: true}, + }) + + require.Error(t, err) + appErr := infraerrors.FromError(err) + require.Equal(t, int32(http.StatusBadRequest), appErr.Code) + require.Equal(t, "INVALID_MODEL_ALLOWLIST", appErr.Reason) + require.Nil(t, repo.updated, "拒绝时不得落库") +} + +func TestAdminService_UpdateGroup_RejectsInvalidAllowlistWildcard(t *testing.T) { + existing := &Group{ID: 1, Name: "existing", Platform: PlatformOpenAI, Status: StatusActive} + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ + ModelAllowlist: &GroupModelAllowlist{Enabled: true, Models: []string{"foo-*bar"}}, + }) + + require.Error(t, err) + appErr := infraerrors.FromError(err) + require.Equal(t, int32(http.StatusBadRequest), appErr.Code) + require.Equal(t, "INVALID_MODEL_ALLOWLIST", appErr.Reason) + require.Nil(t, repo.updated, "拒绝时不得落库") +} + +func TestAdminService_UpdateGroup_NormalizesAndResetsModelAllowlist(t *testing.T) { + existing := &Group{ + ID: 1, Name: "existing", Platform: PlatformOpenAI, Status: StatusActive, + ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.4"}}, + } + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{groupRepo: repo} + + // 归一化:按小写去重保序(保留首次出现的原始拼写)。 + updated := GroupModelAllowlist{Enabled: true, Models: []string{" GPT-5.4 ", "claude-*"}} + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ModelAllowlist: &updated}) + require.NoError(t, err) + require.NotNil(t, repo.updated) + require.Equal(t, []string{"GPT-5.4", "claude-*"}, repo.updated.ModelAllowlist.Models) + + // 关闭且清空条目也应被接受(关闭白名单)。 + repo.updated = nil + disabled := GroupModelAllowlist{Enabled: false} + _, err = svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ModelAllowlist: &disabled}) + require.NoError(t, err) + require.NotNil(t, repo.updated) + require.False(t, repo.updated.ModelAllowlist.Enabled) + require.Empty(t, repo.updated.ModelAllowlist.Models) +} diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index e14c2a0b39..7d5774d794 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -7,6 +7,7 @@ import ( "net/http" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/stretchr/testify/require" @@ -89,6 +90,10 @@ func (s *groupRepoStubForAdmin) DeleteCascade(_ context.Context, _ int64) ([]int panic("unexpected DeleteCascade call") } +func (s *groupRepoStubForAdmin) DeleteCascadeIfEmpty(_ context.Context, _ int64) ([]int64, error) { + panic("unexpected DeleteCascadeIfEmpty call") +} + func (s *groupRepoStubForAdmin) List(_ context.Context, _ pagination.PaginationParams) ([]Group, *pagination.PaginationResult, error) { panic("unexpected List call") } @@ -117,6 +122,178 @@ func (s *groupRepoStubForAdmin) ListWithFilters(_ context.Context, params pagina return s.listWithFiltersGroups, result, nil } +func (s *groupRepoStubForAdmin) ListBindableWithFilters(ctx context.Context, params pagination.PaginationParams, platform, status, search string, isExclusive *bool) ([]Group, *pagination.PaginationResult, error) { + return s.ListWithFilters(ctx, params, platform, status, search, isExclusive) +} + +func TestAdminServiceSimpleModeValidatesRequestedGroupIDsDirectly(t *testing.T) { + groups := make(map[int64]*Group, 1002) + for id := int64(1); id <= 1001; id++ { + groups[id] = &Group{ID: id, Platform: PlatformAnthropic} + } + groups[1002] = &Group{ID: 1002, Platform: PlatformComposite} + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: &groupRepoStubForAdmin{getByIDByID: groups}} + + require.Error(t, svc.ValidateAccountGroupBindings(context.Background(), []int64{1, 1002})) + require.ErrorIs(t, svc.ValidateAccountGroupBindings(context.Background(), []int64{1, 2000}), ErrGroupNotFound) + require.NoError(t, svc.ValidateAccountGroupBindings(context.Background(), []int64{1, 1001})) +} + +func TestAdminServiceSimpleModeRejectsDirectCompositeGroupAccess(t *testing.T) { + repo := &groupRepoStubForAdmin{getByID: &Group{ID: 9, Platform: PlatformComposite}} + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo, emptyGroupDeleteRepo: repo} + + _, err := svc.GetGroup(context.Background(), 9) + require.Error(t, err) + _, err = svc.UpdateGroup(context.Background(), 9, &UpdateGroupInput{}) + require.Error(t, err) + require.Nil(t, repo.updated) + require.Error(t, svc.DeleteGroupIfEmpty(context.Background(), 9)) +} + +func TestAdminServiceSimpleModeRejectsAccountListCompositeFilter(t *testing.T) { + repo := &groupRepoStubForAdmin{getByID: &Group{ID: 9, Platform: PlatformComposite}} + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo} + _, _, err := svc.ListAccounts(context.Background(), 1, 20, "", "", "", "", 9, "", "", "") + require.Error(t, err) +} + +func TestAdminServiceSimpleModeRejectsAdvancedGroupOperationsDirectly(t *testing.T) { + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}} + tests := []struct { + name string + call func() error + }{ + {"duplicate", func() error { _, err := svc.DuplicateGroup(context.Background(), 1, "admin:1", "key"); return err }}, + {"recover duplicate", func() error { + _, err := svc.RecoverDuplicateGroup(context.Background(), 1, "admin:1", "key") + return err + }}, + {"list composite routes", func() error { _, err := svc.ListCompositeRoutes(context.Background(), 1); return err }}, + {"create composite route", func() error { + _, err := svc.CreateCompositeRoute(context.Background(), 1, CompositeRouteInput{}) + return err + }}, + {"update composite route", func() error { + _, err := svc.UpdateCompositeRoute(context.Background(), 1, 2, CompositeRouteInput{}) + return err + }}, + {"delete composite route", func() error { return svc.DeleteCompositeRoute(context.Background(), 1, 2) }}, + {"preview composite route", func() error { + _, err := svc.PreviewCompositeRoute(context.Background(), 1, CompositeRoutePreviewRequest{}) + return err + }}, + {"get multipliers", func() error { _, err := svc.GetGroupRateMultipliers(context.Background(), 1); return err }}, + {"clear multipliers", func() error { return svc.ClearGroupRateMultipliers(context.Background(), 1) }}, + {"set multipliers", func() error { return svc.BatchSetGroupRateMultipliers(context.Background(), 1, nil) }}, + {"clear rpm overrides", func() error { return svc.ClearGroupRPMOverrides(context.Background(), 1) }}, + {"set rpm overrides", func() error { return svc.BatchSetGroupRPMOverrides(context.Background(), 1, nil) }}, + {"sort", func() error { return svc.UpdateGroupSortOrders(context.Background(), nil) }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.call() + require.Error(t, err) + require.Equal(t, "SIMPLE_MODE_OPERATION_UNSUPPORTED", infraerrors.Reason(err)) + }) + } +} + +func TestAdminServiceSimpleModeRejectsCompositeCreateAndConversionDirectly(t *testing.T) { + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}} + _, err := svc.CreateGroup(context.Background(), &CreateGroupInput{Platform: PlatformComposite, RateMultiplier: 1}) + require.Error(t, err) + + repo := &groupRepoStubForAdmin{getByID: &Group{ID: 1, Platform: PlatformAnthropic}} + svc.groupRepo = repo + _, err = svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{Platform: PlatformComposite}) + require.Error(t, err) + require.Nil(t, repo.updated) +} + +func TestAdminServiceSimpleModeNormalizesAllUnsupportedCreateFieldsDirectly(t *testing.T) { + one := 1.0 + fallbackID := int64(44) + input := &CreateGroupInput{ + Name: "simple", Description: "allowed", Platform: PlatformAnthropic, + RateMultiplier: 9, IsExclusive: true, SubscriptionType: SubscriptionTypeSubscription, + DailyLimitUSD: &one, LongContextPricingEnabled: true, + ModelPricing: []ChannelModelPricing{{Models: []string{"claude"}}}, + PeakRateEnabled: true, PeakStart: "00:00", PeakEnd: "01:00", PeakRateMultiplier: &one, + ImageRateIndependent: true, ImageRateMultiplier: &one, VideoRateIndependent: true, VideoRateMultiplier: &one, + ImagePrice1K: &one, VideoPrice720P: &one, WebSearchPricePerCall: &one, SearchPricePer1k: &one, + AudioRealtimePricePerMin: &one, ClaudeCodeOnly: true, FallbackGroupID: &fallbackID, + ModelRouting: map[string][]int64{"claude": {1}}, ModelRoutingEnabled: true, + AllowMessagesDispatch: true, AllowLive: true, ForceOpenAIFast: true, RequireOAuthOnly: true, + RPMLimit: 99, MaxReasoningEffort: "high", ProfitControlEnabled: true, ProfitMinMargin: &one, + CopyAccountsFromGroupIDs: []int64{2}, + } + repo := &groupRepoStubForAdmin{} + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo} + + created, err := svc.CreateGroup(context.Background(), input) + require.NoError(t, err) + require.Same(t, repo.created, created) + require.Equal(t, CreateGroupInput{ + Name: "simple", Description: "allowed", Platform: PlatformAnthropic, + RateMultiplier: 1, SubscriptionType: SubscriptionTypeStandard, + }, *input) + require.Equal(t, 1.0, created.RateMultiplier) + require.Equal(t, SubscriptionTypeStandard, created.SubscriptionType) + require.False(t, created.IsExclusive) + require.Nil(t, created.FallbackGroupID) + require.Empty(t, created.ModelPricing) + require.Zero(t, created.RPMLimit) +} + +func TestAdminServiceSimpleModeNormalizesAllUnsupportedUpdateFieldsDirectly(t *testing.T) { + one := 1.0 + truth := true + status := "inactive" + description := "allowed" + fallbackID := int64(44) + pricing := []ChannelModelPricing{{Models: []string{"claude"}}} + input := &UpdateGroupInput{ + Name: "renamed", Description: &description, Platform: PlatformOpenAI, Status: status, + RateMultiplier: &one, IsExclusive: &truth, SubscriptionType: SubscriptionTypeSubscription, + DailyLimitUSD: &one, LongContextPricingEnabled: &truth, ModelPricing: &pricing, + PeakRateEnabled: &truth, PeakRateMultiplier: &one, ImageRateIndependent: &truth, + ImageRateMultiplier: &one, VideoRateIndependent: &truth, VideoRateMultiplier: &one, + ImagePrice1K: &one, VideoPrice720P: &one, WebSearchPricePerCall: &one, SearchPricePer1k: &one, + AudioRealtimePricePerMin: &one, ClaudeCodeOnly: &truth, FallbackGroupID: &fallbackID, + ModelRouting: map[string][]int64{"claude": {1}}, ModelRoutingEnabled: &truth, + AllowMessagesDispatch: &truth, AllowLive: &truth, ForceOpenAIFast: &truth, RequireOAuthOnly: &truth, + RPMLimit: new(int), MaxReasoningEffort: ptrString("high"), ProfitControlEnabled: &truth, + ProfitMinMargin: &one, CopyAccountsFromGroupIDs: []int64{2}, + } + existing := &Group{ID: 1, Name: "old", Description: "old description", Platform: PlatformAnthropic, Status: StatusActive, RateMultiplier: 3, RPMLimit: 8, FallbackGroupID: &fallbackID} + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo} + + updated, err := svc.UpdateGroup(context.Background(), 1, input) + require.NoError(t, err) + require.Equal(t, UpdateGroupInput{Name: "renamed", Description: &description}, *input) + require.Equal(t, "renamed", updated.Name) + require.Equal(t, description, updated.Description) + require.Equal(t, PlatformAnthropic, updated.Platform) + require.Equal(t, StatusActive, updated.Status) + require.Equal(t, 3.0, updated.RateMultiplier) + require.Equal(t, 8, updated.RPMLimit) + require.Equal(t, &fallbackID, updated.FallbackGroupID) +} + +func TestAdminServiceSimpleModeListUsesRepositoryFilteredTotal(t *testing.T) { + repo := &groupRepoStubForAdmin{ + listWithFiltersGroups: []Group{{ID: 2, Platform: PlatformAnthropic}}, + listWithFiltersResult: &pagination.PaginationResult{Total: 11, Page: 2, PageSize: 1}, + } + svc := &adminServiceImpl{cfg: &config.Config{RunMode: config.RunModeSimple}, groupRepo: repo} + groups, total, err := svc.ListGroups(context.Background(), 2, 1, "", "", "", nil, "id", "asc") + require.NoError(t, err) + require.Len(t, groups, 1) + require.EqualValues(t, 11, total) +} + func (s *groupRepoStubForAdmin) ListActive(_ context.Context) ([]Group, error) { panic("unexpected ListActive call") } @@ -960,7 +1137,7 @@ func TestAdminService_UpdateGroup_ClearsReasoningPolicyForUnsupportedPlatform(t repo := &groupRepoStubForAdmin{getByID: existing} svc := &adminServiceImpl{groupRepo: repo} - _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{Platform: PlatformAnthropic}) + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{Platform: PlatformGemini}) require.NoError(t, err) require.Empty(t, repo.updated.MaxReasoningEffort) @@ -1901,3 +2078,208 @@ func TestAdminService_PreviewCompositeRouteUsesExplicitRoutes(t *testing.T) { require.NotNil(t, decision.Route) require.Equal(t, int64(11), decision.Route.ID) } + +// accountRepoStubForGroupCodexManifest 支撑固定账号 manifest 配置校验测试。 +type accountRepoStubForGroupCodexManifest struct { + AccountRepository + accounts []Account +} + +func (s *accountRepoStubForGroupCodexManifest) ListByGroup(_ context.Context, _ int64) ([]Account, error) { + return append([]Account(nil), s.accounts...), nil +} + +func openAIGroupCodexManifestAccounts(ids ...int64) []Account { + accounts := make([]Account, 0, len(ids)) + for _, id := range ids { + accounts = append(accounts, Account{ + ID: id, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, + }) + } + return accounts +} + +func TestAdminService_UpdateGroup_CodexModelsManifestConfigValidation(t *testing.T) { + t.Run("enabled with empty list rejected", func(t *testing.T) { + existing := &Group{ID: 1, Name: "g", Platform: PlatformOpenAI, Status: StatusActive} + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{ + groupRepo: repo, + accountRepo: &accountRepoStubForGroupCodexManifest{}, + } + + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ + CodexModelsManifestConfig: &GroupCodexModelsManifestConfig{Enabled: true}, + }) + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err)) + require.Equal(t, "INVALID_CODEX_MODELS_MANIFEST_CONFIG", infraerrors.Reason(err)) + require.Nil(t, repo.updated) + }) + + t.Run("non-member or non-openai account rejected", func(t *testing.T) { + existing := &Group{ID: 1, Name: "g", Platform: PlatformOpenAI, Status: StatusActive} + repo := &groupRepoStubForAdmin{getByID: existing} + anthropicMember := Account{ID: 30, Platform: PlatformAnthropic, Type: AccountTypeOAuth, Status: StatusActive} + inactiveMember := Account{ID: 40, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusDisabled} + svc := &adminServiceImpl{ + groupRepo: repo, + accountRepo: &accountRepoStubForGroupCodexManifest{accounts: append(openAIGroupCodexManifestAccounts(10, 20), anthropicMember, inactiveMember)}, + } + + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ + CodexModelsManifestConfig: &GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{10, 30, 40, 99}, + }, + }) + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err)) + require.Equal(t, "INVALID_CODEX_MODELS_MANIFEST_CONFIG", infraerrors.Reason(err)) + require.Contains(t, err.Error(), "30") + require.Contains(t, err.Error(), "40") + require.Contains(t, err.Error(), "99") + require.Nil(t, repo.updated) + }) + + t.Run("more than ten accounts rejected", func(t *testing.T) { + existing := &Group{ID: 1, Name: "g", Platform: PlatformOpenAI, Status: StatusActive} + repo := &groupRepoStubForAdmin{getByID: existing} + ids := make([]int64, 0, 11) + for id := int64(1); id <= 11; id++ { + ids = append(ids, id) + } + svc := &adminServiceImpl{ + groupRepo: repo, + accountRepo: &accountRepoStubForGroupCodexManifest{accounts: openAIGroupCodexManifestAccounts(ids...)}, + } + + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ + CodexModelsManifestConfig: &GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: ids}, + }) + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err)) + require.Equal(t, "INVALID_CODEX_MODELS_MANIFEST_CONFIG", infraerrors.Reason(err)) + require.Nil(t, repo.updated) + }) + + t.Run("duplicate ids deduplicated preserving order", func(t *testing.T) { + existing := &Group{ID: 1, Name: "g", Platform: PlatformOpenAI, Status: StatusActive} + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{ + groupRepo: repo, + accountRepo: &accountRepoStubForGroupCodexManifest{accounts: openAIGroupCodexManifestAccounts(1, 2, 3)}, + } + + group, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ + CodexModelsManifestConfig: &GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{3, 1, 3, 2, 1}, + }, + }) + + require.NoError(t, err) + require.Equal(t, []int64{3, 1, 2}, repo.updated.CodexModelsManifestConfig.AccountIDs) + require.True(t, group.CodexModelsManifestConfig.Enabled) + }) + + t.Run("valid config accepted", func(t *testing.T) { + existing := &Group{ID: 1, Name: "g", Platform: PlatformOpenAI, Status: StatusActive} + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{ + groupRepo: repo, + accountRepo: &accountRepoStubForGroupCodexManifest{accounts: openAIGroupCodexManifestAccounts(10, 20)}, + } + + group, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ + CodexModelsManifestConfig: &GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{10, 20}, + FallbackToScheduler: true, + }, + }) + + require.NoError(t, err) + require.True(t, group.CodexModelsManifestConfig.Enabled) + require.Equal(t, []int64{10, 20}, repo.updated.CodexModelsManifestConfig.AccountIDs) + require.True(t, repo.updated.CodexModelsManifestConfig.FallbackToScheduler) + }) +} + +func TestAdminService_UpdateGroup_CodexModelsManifestConfigZeroedForNonOpenAIPlatform(t *testing.T) { + existing := &Group{ID: 1, Name: "g", Platform: PlatformOpenAI, Status: StatusActive} + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{ + groupRepo: repo, + accountRepo: &accountRepoStubForGroupCodexManifest{}, + } + + group, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{ + Platform: PlatformAnthropic, + CodexModelsManifestConfig: &GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{1, 2}, + FallbackToScheduler: true, + }, + }) + + require.NoError(t, err) + require.Equal(t, GroupCodexModelsManifestConfig{}, repo.updated.CodexModelsManifestConfig) + require.Equal(t, GroupCodexModelsManifestConfig{}, group.CodexModelsManifestConfig) +} + +func TestAdminService_UpdateGroup_CodexModelsManifestConfigUntouchedWhenOmitted(t *testing.T) { + existing := &Group{ + ID: 1, Name: "g", Platform: PlatformOpenAI, Status: StatusActive, + CodexModelsManifestConfig: GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{10}, + }, + } + repo := &groupRepoStubForAdmin{getByID: existing} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.UpdateGroup(context.Background(), existing.ID, &UpdateGroupInput{Name: "renamed"}) + + require.NoError(t, err) + require.Equal(t, existing.CodexModelsManifestConfig, repo.updated.CodexModelsManifestConfig) +} + +func TestAdminService_CreateGroup_CodexModelsManifestConfigEnabledRejected(t *testing.T) { + repo := &groupRepoStubForAdmin{createID: 61} + svc := &adminServiceImpl{groupRepo: repo} + + _, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "codex-pinned", Platform: PlatformOpenAI, RateMultiplier: 1, + CodexModelsManifestConfig: GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{1}, + }, + }) + + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, infraerrors.Code(err)) + require.Equal(t, "INVALID_CODEX_MODELS_MANIFEST_CONFIG", infraerrors.Reason(err)) + require.Nil(t, repo.created) +} + +func TestAdminService_CreateGroup_CodexModelsManifestConfigDisabledAccepted(t *testing.T) { + repo := &groupRepoStubForAdmin{createID: 62} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{ + Name: "codex-pinned-off", Platform: PlatformOpenAI, RateMultiplier: 1, + CodexModelsManifestConfig: GroupCodexModelsManifestConfig{ + Enabled: false, + AccountIDs: []int64{1, 1, 2}, + }, + }) + + require.NoError(t, err) + require.Equal(t, []int64{1, 2}, group.CodexModelsManifestConfig.AccountIDs) + require.False(t, repo.created.CodexModelsManifestConfig.Enabled) +} diff --git a/backend/internal/service/announcement_service.go b/backend/internal/service/announcement_service.go index 124790419b..8f78182441 100644 --- a/backend/internal/service/announcement_service.go +++ b/backend/internal/service/announcement_service.go @@ -73,6 +73,10 @@ func (s *AnnouncementService) Create(ctx context.Context, input *CreateAnnouncem return nil, ErrAnnouncementNilInput } + if !isJSONTimeInRange(input.StartsAt) || !isJSONTimeInRange(input.EndsAt) { + return nil, ErrAnnouncementInvalidSchedule + } + title := strings.TrimSpace(input.Title) content := strings.TrimSpace(input.Content) if title == "" || len(title) > 200 { @@ -134,6 +138,11 @@ func (s *AnnouncementService) Update(ctx context.Context, id int64, input *Updat return nil, ErrAnnouncementNilInput } + if (input.StartsAt != nil && !isJSONTimeInRange(*input.StartsAt)) || + (input.EndsAt != nil && !isJSONTimeInRange(*input.EndsAt)) { + return nil, ErrAnnouncementInvalidSchedule + } + a, err := s.announcementRepo.GetByID(ctx, id) if err != nil { return nil, err diff --git a/backend/internal/service/anthropic_apikey_auth.go b/backend/internal/service/anthropic_apikey_auth.go index 044d5be96c..8bd8c4ba8f 100644 --- a/backend/internal/service/anthropic_apikey_auth.go +++ b/backend/internal/service/anthropic_apikey_auth.go @@ -33,7 +33,26 @@ func (a *Account) GetAnthropicAPIKeyAuthScheme() string { } } -func setAnthropicAPIKeyAuthHeader(header http.Header, account *Account, token string) { +// isOllamaCloudAnthropicAuthBaseURL 判定本次实际选用的 Anthropic 上游 base_url +// 是否指向 Ollama Cloud。与真实出站 URL 组装同源:先 TrimSpace + TrimRight "/" +// (urlvalidator 与 nativeAnthropicTargetURL 同款归一化,'https://ollama.com/' +// 与 '.../v1' 均合理),再复用既有严格 host helper isOllamaCloudBaseURL(精确 +// host、https、无 query/fragment、路径仅空或 /v1),不按 substring 匹配。 +func isOllamaCloudAnthropicAuthBaseURL(baseURL string) bool { + return isOllamaCloudBaseURL(strings.TrimRight(strings.TrimSpace(baseURL), "/")) +} + +// setAnthropicAPIKeyAuthHeader 写入上游认证头。除 extra 覆写外,Ollama Cloud +// 官方 Anthropic 兼容端点只认 Authorization: Bearer:判定依据是本次实际选用的 +// 上游 base_url(与出站 URL 组装同源,而非 account.Platform——Ollama Cloud key +// 可挂在多个平台分组下),即使 extra 缺失或显式 x_api_key 也强制 Bearer; +// 其它上游保持历史 extra/default 行为。baseURL 为该请求实际选用的 Anthropic +// 上游 base(GetBaseURL / GetAnthropicProtocolBaseURL 等),默认官方端点时传空。 +func setAnthropicAPIKeyAuthHeader(header http.Header, account *Account, token, baseURL string) { + if account.Type == AccountTypeAPIKey && isOllamaCloudAnthropicAuthBaseURL(baseURL) { + header.Set("Authorization", "Bearer "+token) + return + } if account.GetAnthropicAPIKeyAuthScheme() == AnthropicAPIKeyAuthSchemeAuthorizationBearer { header.Set("Authorization", "Bearer "+token) return diff --git a/backend/internal/service/anthropic_apikey_auth_ollama_test.go b/backend/internal/service/anthropic_apikey_auth_ollama_test.go new file mode 100644 index 0000000000..6870d7e299 --- /dev/null +++ b/backend/internal/service/anthropic_apikey_auth_ollama_test.go @@ -0,0 +1,335 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// newOllamaCloudAnthropicAuthAccount 构造挂在 Anthropic 平台分组下的 Ollama Cloud +// API Key 账号(base_url 指向官方 ollama.com Anthropic 兼容端点)。 +func newOllamaCloudAnthropicAuthAccount(baseURL string, extra map[string]any) *Account { + if extra == nil { + extra = map[string]any{} + } + return &Account{ + ID: 901, + Name: "ollama-cloud-anthropic-auth", + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "ollama-cloud-key", + "base_url": baseURL, + }, + Extra: extra, + Status: StatusActive, + Schedulable: true, + } +} + +// TestSetAnthropicAPIKeyAuthHeader_OllamaCloudForcesBearer:Ollama Cloud 官方 +// Anthropic 兼容端点只认 Authorization: Bearer——extra 缺失或显式 x_api_key 均 +// 强制 Bearer;判定与实际 base_url 同源并做与出站组装一致的尾斜杠归一化; +// 其它上游保持历史 extra/default 行为,恶意相似 host 不误判。 +func TestSetAnthropicAPIKeyAuthHeader_OllamaCloudForcesBearer(t *testing.T) { + tests := []struct { + name string + baseURL string + extra map[string]any + accountType string + wantAuth string // "" = 不应出现 Authorization 头 + wantXAPIKey string // "" = 不应出现 x-api-key 头 + }{ + { + name: "ollama 缺 extra 默认 Bearer", + baseURL: "https://ollama.com", + wantAuth: "Bearer ollama-cloud-key", + }, + { + name: "ollama base 尾斜杠仍 Bearer", + baseURL: "https://ollama.com/", + wantAuth: "Bearer ollama-cloud-key", + }, + { + name: "ollama /v1 路径 Bearer", + baseURL: "https://ollama.com/v1", + wantAuth: "Bearer ollama-cloud-key", + }, + { + name: "ollama /v1 尾斜杠 Bearer", + baseURL: "https://www.ollama.com/v1/", + wantAuth: "Bearer ollama-cloud-key", + }, + { + name: "host 大小写不敏感 Bearer", + baseURL: "https://OLLAMA.COM", + wantAuth: "Bearer ollama-cloud-key", + }, + { + name: "ollama 显式 x_api_key 也强制 Bearer", + baseURL: "https://ollama.com", + extra: map[string]any{anthropicAPIKeyAuthSchemeExtraKey: AnthropicAPIKeyAuthSchemeXAPIKey}, + wantAuth: "Bearer ollama-cloud-key", + }, + { + name: "非 Ollama 默认 x-api-key", + baseURL: "https://api.anthropic.com", + wantXAPIKey: "ollama-cloud-key", + }, + { + name: "默认官方端点(空 base_url)x-api-key", + baseURL: "", + wantXAPIKey: "ollama-cloud-key", + }, + { + name: "非 Ollama 显式 authorization_bearer 保持 Bearer", + baseURL: "https://api.anthropic.com", + extra: map[string]any{anthropicAPIKeyAuthSchemeExtraKey: AnthropicAPIKeyAuthSchemeAuthorizationBearer}, + wantAuth: "Bearer ollama-cloud-key", + }, + { + name: "恶意相似 host 子串不命中", + baseURL: "https://evil.com/ollama.com", + wantXAPIKey: "ollama-cloud-key", + }, + { + name: "恶意相似 host 后缀不命中", + baseURL: "https://ollama.com.evil.com", + wantXAPIKey: "ollama-cloud-key", + }, + { + name: "http 明文不命中", + baseURL: "http://ollama.com", + wantXAPIKey: "ollama-cloud-key", + }, + { + name: "非标准端口不命中", + baseURL: "https://ollama.com:8443", + wantXAPIKey: "ollama-cloud-key", + }, + { + name: "带 path 的 messages 地址不命中(判定用 base 而非完整 URL)", + baseURL: "https://ollama.com/v1/messages", + wantXAPIKey: "ollama-cloud-key", + }, + { + name: "非 APIKey 账号类型不强制", + baseURL: "https://ollama.com", + accountType: AccountTypeOAuth, + wantXAPIKey: "ollama-cloud-key", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + accountType := AccountTypeAPIKey + if tt.accountType != "" { + accountType = tt.accountType + } + account := newOllamaCloudAnthropicAuthAccount(tt.baseURL, tt.extra) + account.Type = accountType + + header := http.Header{} + setAnthropicAPIKeyAuthHeader(header, account, "ollama-cloud-key", tt.baseURL) + require.Equal(t, tt.wantAuth, header.Get("Authorization"), "Authorization 头不符") + require.Equal(t, tt.wantXAPIKey, header.Get("x-api-key"), "x-api-key 头不符") + }) + } +} + +// TestSetAnthropicAPIKeyAuthHeader_CNAdaptiveBaseURLResolution:adaptive 账号经 +// GetCNProtocolBaseURL(anthropic) / GetAnthropicProtocolBaseURL 选出的分协议地址 +// 才是判定依据,Chat Completions 地址(GetOpenAIBaseURL 选到的 CC base)不参与。 +func TestSetAnthropicAPIKeyAuthHeader_CNAdaptiveBaseURL(t *testing.T) { + // adaptive:anthropic 分协议地址指向 ollama.com → Bearer;CC 地址同挂 ollama + // 不影响(判定只用 anthropic 分协议地址)。 + adaptiveOllamaAnthropic := &Account{ + ID: 902, + Name: "adaptive-ollama-anthropic", + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "kimi-key", + "api_protocol": APIProtocolAdaptive, + "api_base_urls": map[string]any{ + APIProtocolAnthropic: "https://ollama.com", + APIProtocolChatCompletions: "https://api.moonshot.cn", + }, + }, + } + anthropicBase := adaptiveOllamaAnthropic.GetCNProtocolBaseURL(APIProtocolAnthropic) + require.Equal(t, "https://ollama.com", anthropicBase) + header := http.Header{} + setAnthropicAPIKeyAuthHeader(header, adaptiveOllamaAnthropic, "kimi-key", anthropicBase) + require.Equal(t, "Bearer kimi-key", header.Get("Authorization")) + require.Empty(t, header.Get("x-api-key")) + + // 反向:CC 地址挂 ollama.com,anthropic 分协议地址非 ollama → 不误判, + // 保持 x-api-key。 + adaptiveOllamaCC := &Account{ + ID: 903, + Name: "adaptive-ollama-cc", + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "kimi-key", + "api_protocol": APIProtocolAdaptive, + "api_base_urls": map[string]any{ + APIProtocolAnthropic: "https://api.moonshot.cn/anthropic", + APIProtocolChatCompletions: "https://ollama.com/v1", + }, + }, + } + anthropicBase = adaptiveOllamaCC.GetCNProtocolBaseURL(APIProtocolAnthropic) + require.Equal(t, "https://api.moonshot.cn/anthropic", anthropicBase) + header = http.Header{} + setAnthropicAPIKeyAuthHeader(header, adaptiveOllamaCC, "kimi-key", anthropicBase) + require.Empty(t, header.Get("Authorization")) + require.Equal(t, "kimi-key", header.Get("x-api-key")) + + // anthropic 协议(非 adaptive):凭证 base_url 指向 ollama.com → Bearer。 + anthropicProtocolOllama := &Account{ + ID: 904, + Name: "anthropic-protocol-ollama", + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "kimi-key", + "api_protocol": APIProtocolAnthropic, + "base_url": "https://ollama.com", + }, + } + anthropicBase = anthropicProtocolOllama.GetAnthropicProtocolBaseURL() + require.Equal(t, "https://ollama.com", anthropicBase) + header = http.Header{} + setAnthropicAPIKeyAuthHeader(header, anthropicProtocolOllama, "kimi-key", anthropicBase) + require.Equal(t, "Bearer kimi-key", header.Get("Authorization")) + require.Empty(t, header.Get("x-api-key")) + + // 同协议但 base 为 kimi 官方 → 保持 x-api-key。 + anthropicProtocolKimi := &Account{ + ID: 905, + Name: "anthropic-protocol-kimi", + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "kimi-key", + "api_protocol": APIProtocolAnthropic, + "base_url": "https://api.moonshot.cn/anthropic", + }, + } + header = http.Header{} + setAnthropicAPIKeyAuthHeader(header, anthropicProtocolKimi, "kimi-key", anthropicProtocolKimi.GetAnthropicProtocolBaseURL()) + require.Empty(t, header.Get("Authorization")) + require.Equal(t, "kimi-key", header.Get("x-api-key")) +} + +// TestGatewayService_AnthropicPassthrough_OllamaCloudBearer:经 passthrough +// builder 真实构造 http.Request 验证最终 header——Ollama Cloud 强制 Bearer 且 +// 不泄漏客户端入站认证;非 Ollama 上游保持 x-api-key。 +func TestGatewayService_AnthropicPassthrough_OllamaCloudBearer(t *testing.T) { + gin.SetMode(gin.TestMode) + + buildReq := func(t *testing.T, baseURL string) *http.Request { + t.Helper() + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + // 入站认证残留:正确上游只发一种认证头,不得泄漏客户端凭证 + c.Request.Header.Set("Authorization", "Bearer inbound-token") + c.Request.Header.Set("X-Api-Key", "inbound-api-key") + + svc := &GatewayService{cfg: &config.Config{}} + account := newOllamaCloudAnthropicAuthAccount(baseURL, nil) + body := []byte(`{"model":"claude-3-7-sonnet-20250219","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`) + req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "ollama-cloud-key") + require.NoError(t, err) + return req + } + + ollamaReq := buildReq(t, "https://ollama.com/") + require.Equal(t, "Bearer ollama-cloud-key", ollamaReq.Header.Get("Authorization")) + require.Empty(t, ollamaReq.Header.Get("x-api-key"), "Ollama Cloud 上游不得再发 x-api-key") + require.NotContains(t, ollamaReq.Header.Get("Authorization"), "inbound-token") + require.Empty(t, ollamaReq.Header.Get("x-inbound"), "sanity") + + anthropicReq := buildReq(t, "https://api.anthropic.com") + require.Empty(t, anthropicReq.Header.Get("Authorization")) + require.Equal(t, "ollama-cloud-key", anthropicReq.Header.Get("x-api-key")) + require.NotContains(t, anthropicReq.Header.Get("x-api-key"), "inbound-api-key") +} + +// TestGatewayService_BuildUpstreamRequest_OllamaCloudBearer:经 Anthropic 原生 +// GetBaseURL builder 真实构造 http.Request 验证最终 header。 +func TestGatewayService_BuildUpstreamRequest_OllamaCloudBearer(t *testing.T) { + gin.SetMode(gin.TestMode) + + buildReq := func(t *testing.T, baseURL string) *http.Request { + t.Helper() + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + c.Request.Header.Set("Authorization", "Bearer inbound-token") + c.Request.Header.Set("X-Api-Key", "inbound-api-key") + + svc := &GatewayService{cfg: &config.Config{}} + account := newOllamaCloudAnthropicAuthAccount(baseURL, nil) + body := []byte(`{"model":"claude-3-7-sonnet-20250219","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`) + req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "ollama-cloud-key", "apikey", "claude-3-7-sonnet-20250219", false, false) + require.NoError(t, err) + return req + } + + ollamaReq := buildReq(t, "https://ollama.com") + require.Equal(t, "https://ollama.com/v1/messages?beta=true", ollamaReq.URL.String()) + require.Equal(t, "Bearer ollama-cloud-key", ollamaReq.Header.Get("Authorization")) + require.Empty(t, ollamaReq.Header.Get("x-api-key")) + + anthropicReq := buildReq(t, "https://api.anthropic.com") + require.Empty(t, anthropicReq.Header.Get("Authorization")) + require.Equal(t, "ollama-cloud-key", anthropicReq.Header.Get("x-api-key")) +} + +// TestOpenAIGatewayService_NativeAnthropicBridge_OllamaCloudBearer:经 native +// Anthropic 桥接 builder(CC/Responses → /v1/messages)真实构造 http.Request +// 验证最终 header。 +func TestOpenAIGatewayService_NativeAnthropicBridge_OllamaCloudBearer(t *testing.T) { + gin.SetMode(gin.TestMode) + + svc := &OpenAIGatewayService{cfg: &config.Config{}} + account := &Account{ + ID: 906, + Name: "anthropic-protocol-ollama-bridge", + Platform: PlatformKimi, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "ollama-cloud-key", + "api_protocol": APIProtocolAnthropic, + "base_url": "https://ollama.com", + }, + } + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages", targetURL) + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + body := []byte(`{"model":"kimi-k2-thinking","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`) + req, _, err := svc.buildNativeAnthropicUpstreamRequest(context.Background(), c, account, body, "ollama-cloud-key", targetURL) + require.NoError(t, err) + require.Equal(t, "Bearer ollama-cloud-key", req.Header.Get("Authorization")) + require.Empty(t, req.Header.Get("x-api-key")) + + // 非 Ollama 上游:保持 x-api-key,无 Authorization。 + account.Credentials["base_url"] = "https://api.moonshot.cn/anthropic" + targetURL, err = svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + req, _, err = svc.buildNativeAnthropicUpstreamRequest(context.Background(), c, account, body, "ollama-cloud-key", targetURL) + require.NoError(t, err) + require.Empty(t, req.Header.Get("Authorization")) + require.Equal(t, "ollama-cloud-key", req.Header.Get("x-api-key")) +} diff --git a/backend/internal/service/antigravity_gateway_claude.go b/backend/internal/service/antigravity_gateway_claude.go index 9e3dc0f7dd..39b1c982a4 100644 --- a/backend/internal/service/antigravity_gateway_claude.go +++ b/backend/internal/service/antigravity_gateway_claude.go @@ -464,6 +464,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, UpstreamModel: billingModel, diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index 5c6524489e..5e406656c7 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -364,6 +364,7 @@ func (s *AntigravityGatewayService) consumeAntigravityCompatResponse( return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *streamResult.usage, Model: call.request.originalModel, UpstreamModel: call.billingModel, diff --git a/backend/internal/service/antigravity_gateway_gemini.go b/backend/internal/service/antigravity_gateway_gemini.go index 2093911100..35824b51a7 100644 --- a/backend/internal/service/antigravity_gateway_gemini.go +++ b/backend/internal/service/antigravity_gateway_gemini.go @@ -448,6 +448,7 @@ handleSuccess: return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, UpstreamModel: forwardedModel, diff --git a/backend/internal/service/antigravity_gateway_upstream.go b/backend/internal/service/antigravity_gateway_upstream.go index 7254745698..e6b9a73fa8 100644 --- a/backend/internal/service/antigravity_gateway_upstream.go +++ b/backend/internal/service/antigravity_gateway_upstream.go @@ -150,6 +150,7 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin. Duration: duration, FirstTokenMs: firstTokenMs, ClientDisconnect: clientDisconnect, + UpstreamHeaders: resp.Header, Usage: ClaudeUsage{ InputTokens: usage.InputTokens, OutputTokens: usage.OutputTokens, diff --git a/backend/internal/service/antigravity_oauth_service.go b/backend/internal/service/antigravity_oauth_service.go index 3a4600db0e..5d4390d9cf 100644 --- a/backend/internal/service/antigravity_oauth_service.go +++ b/backend/internal/service/antigravity_oauth_service.go @@ -90,7 +90,7 @@ type AntigravityTokenInfo struct { Email string `json:"email,omitempty"` ProjectID string `json:"project_id,omitempty"` ProjectIDMissing bool `json:"-"` - PlanType string `json:"-"` + PlanType string `json:"plan_type,omitempty"` PrivacyMode string `json:"-"` } diff --git a/backend/internal/service/antigravity_oauth_service_test.go b/backend/internal/service/antigravity_oauth_service_test.go index 1d2d823557..c89b3f0a44 100644 --- a/backend/internal/service/antigravity_oauth_service_test.go +++ b/backend/internal/service/antigravity_oauth_service_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "testing" ) @@ -80,3 +81,26 @@ func TestResolveDefaultTierID(t *testing.T) { }) } } + +// The OAuth handler serializes this DTO before the browser creates or reauthorizes an account. +func TestAntigravityTokenInfoPreservesPlanTypeJSON(t *testing.T) { + for _, plan := range []string{"pro", "ultra", "free", ""} { + t.Run(plan, func(t *testing.T) { + data, err := json.Marshal(AntigravityTokenInfo{PlanType: plan}) + if err != nil { + t.Fatal(err) + } + var response map[string]any + if err := json.Unmarshal(data, &response); err != nil { + t.Fatal(err) + } + if plan == "" { + if _, exists := response["plan_type"]; exists { + t.Fatal("unknown plan must remain omitted") + } + } else if response["plan_type"] != plan { + t.Fatalf("OAuth response plan_type = %v, want %q", response["plan_type"], plan) + } + }) + } +} diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 59d2774cd7..474c0d85f1 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -107,12 +107,15 @@ type APIKeyAuthGroupSnapshot struct { FreeOpenAIFast bool `json:"free_openai_fast"` DefaultMappedModel string `json:"default_mapped_model,omitempty"` MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"` - ModelsListConfig GroupModelsListConfig `json:"models_list_config,omitempty"` + ModelAllowlist GroupModelAllowlist `json:"model_allowlist,omitempty"` + // CodexModelsManifestConfig 与 ModelAllowlist 一样在认证快照分组里透传, + // Codex /models handler 直接读认证分组对象。 + CodexModelsManifestConfig GroupCodexModelsManifestConfig `json:"codex_models_manifest_config,omitempty"` // RPMLimit 分组级每分钟请求数上限(0 = 不限制);用于 billing_cache_service.checkRPM 级联判断。 RPMLimit int `json:"rpm_limit"` - // MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。 + // MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。 MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"` // MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。 MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"` diff --git a/backend/internal/service/api_key_auth_cache_codex_manifest_test.go b/backend/internal/service/api_key_auth_cache_codex_manifest_test.go new file mode 100644 index 0000000000..d7970b8fac --- /dev/null +++ b/backend/internal/service/api_key_auth_cache_codex_manifest_test.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// 固定账号 manifest 配置必须在认证快照 JSON 往返后完整保留, +// 否则开关在缓存路径上会静默失效(投影对账由集成测试兜底,此处覆盖快照序列化)。 +func TestAPIKeyAuthSnapshotGroupCodexModelsManifestRoundtrip(t *testing.T) { + groupID := int64(60) + apiKey := &APIKey{ + ID: 92, UserID: 46, GroupID: &groupID, Key: "sk-codex-manifest-roundtrip", Status: StatusActive, + User: &User{ID: 46, Status: StatusActive}, + Group: &Group{ + ID: groupID, Name: "codex-manifest-roundtrip", Platform: PlatformOpenAI, Status: StatusActive, + Hydrated: true, + CodexModelsManifestConfig: GroupCodexModelsManifestConfig{ + Enabled: true, + AccountIDs: []int64{7, 8}, + FallbackToScheduler: true, + }, + }, + } + svc := &APIKeyService{} + + payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: svc.snapshotFromAPIKey(context.Background(), apiKey)}) + require.NoError(t, err) + var cached APIKeyAuthCacheEntry + require.NoError(t, json.Unmarshal(payload, &cached)) + + materialized, used, err := svc.applyAuthCacheEntry(apiKey.Key, &cached) + require.NoError(t, err) + require.True(t, used) + require.NotNil(t, materialized.Group) + require.True(t, materialized.Group.CodexModelsManifestConfig.Enabled) + require.Equal(t, []int64{7, 8}, materialized.Group.CodexModelsManifestConfig.AccountIDs) + require.True(t, materialized.Group.CodexModelsManifestConfig.FallbackToScheduler) + require.Equal(t, apiKeyAuthSnapshotVersion, cached.Snapshot.Version) +} diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 656a3ba6e7..836edbb2b5 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -14,7 +14,7 @@ import ( "github.com/dgraph-io/ristretto" ) -const apiKeyAuthSnapshotVersion = 22 // v22: group free_openai_fast field +const apiKeyAuthSnapshotVersion = 24 // v24: group model_allowlist field (renamed from models_list_config, enforcing semantics) type apiKeyAuthCacheConfig struct { l1Size int @@ -422,7 +422,8 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) FreeOpenAIFast: apiKey.Group.FreeOpenAIFast, DefaultMappedModel: apiKey.Group.DefaultMappedModel, MessagesDispatchModelConfig: apiKey.Group.MessagesDispatchModelConfig, - ModelsListConfig: apiKey.Group.ModelsListConfig, + ModelAllowlist: apiKey.Group.ModelAllowlist, + CodexModelsManifestConfig: apiKey.Group.CodexModelsManifestConfig, RPMLimit: apiKey.Group.RPMLimit, MaxReasoningEffort: apiKey.Group.MaxReasoningEffort, MaxReasoningEffortOverLimit: apiKey.Group.MaxReasoningEffortOverLimit, @@ -523,7 +524,8 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho FreeOpenAIFast: snapshot.Group.FreeOpenAIFast, DefaultMappedModel: snapshot.Group.DefaultMappedModel, MessagesDispatchModelConfig: snapshot.Group.MessagesDispatchModelConfig, - ModelsListConfig: snapshot.Group.ModelsListConfig, + ModelAllowlist: snapshot.Group.ModelAllowlist, + CodexModelsManifestConfig: snapshot.Group.CodexModelsManifestConfig, RPMLimit: snapshot.Group.RPMLimit, MaxReasoningEffort: snapshot.Group.MaxReasoningEffort, MaxReasoningEffortOverLimit: snapshot.Group.MaxReasoningEffortOverLimit, diff --git a/backend/internal/service/api_key_auth_cache_openai_fast_test.go b/backend/internal/service/api_key_auth_cache_openai_fast_test.go index 07e906e4e7..2c39d5bca9 100644 --- a/backend/internal/service/api_key_auth_cache_openai_fast_test.go +++ b/backend/internal/service/api_key_auth_cache_openai_fast_test.go @@ -32,5 +32,5 @@ func TestAPIKeyAuthSnapshotGroupForceOpenAIFastRoundtrip(t *testing.T) { require.True(t, materialized.Group.Hydrated) require.True(t, materialized.Group.ForceOpenAIFast) require.True(t, materialized.Group.FreeOpenAIFast) - require.Equal(t, 22, cached.Snapshot.Version) + require.Equal(t, apiKeyAuthSnapshotVersion, cached.Snapshot.Version) } diff --git a/backend/internal/service/api_key_auth_cache_profit_test.go b/backend/internal/service/api_key_auth_cache_profit_test.go index 51347e0eab..6ce7ecfce6 100644 --- a/backend/internal/service/api_key_auth_cache_profit_test.go +++ b/backend/internal/service/api_key_auth_cache_profit_test.go @@ -53,7 +53,7 @@ func TestAPIKeyAuthSnapshotProfitControlRoundtrip(t *testing.T) { snapshot := svc.snapshotFromAPIKey(context.Background(), apiKey) require.NotNil(t, snapshot) require.Equal(t, apiKeyAuthSnapshotVersion, snapshot.Version) - require.Equal(t, 22, snapshot.Version, "v22 起认证快照携带分组免费 Fast 开关") + require.Equal(t, apiKeyAuthSnapshotVersion, snapshot.Version, "认证快照版本必须与当前常量一致") // 模拟 L2 缓存的完整 JSON 往返(与 apiKeyCache.SetAuthCache/GetAuthCache 同构)。 payload, err := json.Marshal(&APIKeyAuthCacheEntry{Snapshot: snapshot}) diff --git a/backend/internal/service/api_key_auth_cache_version_test.go b/backend/internal/service/api_key_auth_cache_version_test.go index 20d778971d..a90cb31fc1 100644 --- a/backend/internal/service/api_key_auth_cache_version_test.go +++ b/backend/internal/service/api_key_auth_cache_version_test.go @@ -2,7 +2,7 @@ package service import "testing" -func TestAPIKeyService_RejectsV10AuthSnapshotWithoutModelsListConfig(t *testing.T) { +func TestAPIKeyService_RejectsV10AuthSnapshotWithoutModelAllowlist(t *testing.T) { groupID := int64(9) svc := &APIKeyService{} @@ -35,7 +35,7 @@ func TestAPIKeyService_RejectsV10AuthSnapshotWithoutModelsListConfig(t *testing. t.Fatalf("expected stale snapshot to be ignored without error, got %v", err) } if ok { - t.Fatalf("expected v10 auth snapshot to be rejected after models_list_config was added") + t.Fatalf("expected v10 auth snapshot to be rejected after model_allowlist was added") } if apiKey != nil { t.Fatalf("expected no API key from stale snapshot, got %#v", apiKey) diff --git a/backend/internal/service/api_key_group_visibility_test.go b/backend/internal/service/api_key_group_visibility_test.go new file mode 100644 index 0000000000..77efcdd1e8 --- /dev/null +++ b/backend/internal/service/api_key_group_visibility_test.go @@ -0,0 +1,100 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type visibilityUserRepo struct { + UserRepository + user *User + err error +} + +func (r *visibilityUserRepo) GetByID(context.Context, int64) (*User, error) { return r.user, r.err } + +type visibilitySubRepo struct { + UserSubscriptionRepository + subscriptions []UserSubscription + err error + calls int +} + +func (r *visibilitySubRepo) ListActiveByUserID(_ context.Context, userID int64) ([]UserSubscription, error) { + r.calls++ + // Match the repository's active-status and expiry predicates. + active := make([]UserSubscription, 0) + for _, sub := range r.subscriptions { + if sub.UserID == userID && sub.IsActive() { + active = append(active, sub) + } + } + return active, r.err +} + +type visibilityGroupRepo struct { + GroupRepository + groups []Group +} + +func (r *visibilityGroupRepo) ListActive(context.Context) ([]Group, error) { return r.groups, nil } + +func TestGetUserGroupVisibilityIncludesActiveSubscriptions(t *testing.T) { + for _, restricted := range []bool{false, true} { + t.Run(map[bool]string{false: "unrestricted", true: "restricted"}[restricted], func(t *testing.T) { + now := time.Now() + subs := &visibilitySubRepo{subscriptions: []UserSubscription{ + {UserID: 1, GroupID: 42, Status: SubscriptionStatusActive, ExpiresAt: now.Add(time.Hour)}, + {UserID: 1, GroupID: 43, Status: SubscriptionStatusActive, ExpiresAt: now.Add(-time.Hour)}, + {UserID: 1, GroupID: 44, Status: "expired", ExpiresAt: now.Add(time.Hour)}, + {UserID: 2, GroupID: 45, Status: SubscriptionStatusActive, ExpiresAt: now.Add(time.Hour)}, + }} + svc := &APIKeyService{ + userRepo: &visibilityUserRepo{user: &User{ID: 1, AllowedGroups: []int64{7}, RestrictPublicGroups: restricted}}, + userSubRepo: subs, + groupRepo: &visibilityGroupRepo{groups: []Group{{ID: 42, IsExclusive: true, SubscriptionType: "subscription"}}}, + } + available, err := svc.GetAvailableGroups(context.Background(), 1) + require.NoError(t, err) + require.Len(t, available, 1) + visible, restrict, err := svc.GetUserGroupVisibility(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, restricted, restrict) + require.Equal(t, map[int64]struct{}{7: {}, 42: {}}, visible) + require.Contains(t, visible, available[0].ID, "a subscribed group that can be bound must be visible") + }) + } +} + +func TestGetUserGroupVisibilityEmptyAndErrors(t *testing.T) { + failure := errors.New("repository unavailable") + for _, tc := range []struct { + name string + userErr, subErr error + }{ + {name: "empty"}, {name: "user failure", userErr: failure}, {name: "subscription failure", subErr: failure}, + } { + t.Run(tc.name, func(t *testing.T) { + subs := &visibilitySubRepo{err: tc.subErr} + svc := &APIKeyService{userRepo: &visibilityUserRepo{user: &User{ID: 1}, err: tc.userErr}, userSubRepo: subs} + got, _, err := svc.GetUserGroupVisibility(context.Background(), 1) + if tc.userErr != nil || tc.subErr != nil { + require.ErrorIs(t, err, failure) + require.Nil(t, got, "repository failures must not become anonymous visibility") + } else { + require.NoError(t, err) + require.NotNil(t, got, "an empty logged-in user must not become anonymous") + require.Empty(t, got) + } + if tc.userErr != nil { + require.Zero(t, subs.calls) + } + }) + } +} diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index f90ce0b000..5288f4ff8b 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -1071,11 +1071,11 @@ func (s *APIKeyService) SearchAPIKeys(ctx context.Context, userID int64, keyword return keys, nil } -// GetUserGroupVisibility 返回 user_allowed_groups 授权给该用户的分组 ID 集合, +// GetUserGroupVisibility 返回 user_allowed_groups 授权及有效订阅的分组 ID 集合, // 以及该用户是否开启了公开分组限制。开启时公开分组的可见性也要落在该集合内。 // -// 与 GetAvailableGroups 的区别:这里是「橱窗」语义(模型广场用),不检查订阅有效性, -// 也不关心分组是否活跃——仅回答"哪些专属分组对该用户可见"。返回值恒非 nil。 +// 与 GetAvailableGroups 的区别:这里保留普通授权分组的「橱窗」语义,不检查 +// 分组是否活跃;有效订阅也授予对应分组的可见性。返回值恒非 nil。 func (s *APIKeyService) GetUserGroupVisibility(ctx context.Context, userID int64) (map[int64]struct{}, bool, error) { user, err := s.userRepo.GetByID(ctx, userID) if err != nil { @@ -1085,6 +1085,13 @@ func (s *APIKeyService) GetUserGroupVisibility(ctx context.Context, userID int64 for _, id := range user.AllowedGroups { allowed[id] = struct{}{} } + subscriptions, err := s.userSubRepo.ListActiveByUserID(ctx, userID) + if err != nil { + return nil, false, fmt.Errorf("list active subscriptions: %w", err) + } + for _, sub := range subscriptions { + allowed[sub.GroupID] = struct{}{} + } return allowed, user.RestrictPublicGroups, nil } diff --git a/backend/internal/service/billing_cache_service_user_platform_quota_test.go b/backend/internal/service/billing_cache_service_user_platform_quota_test.go index a82c0e00d7..8dfdafc4e9 100644 --- a/backend/internal/service/billing_cache_service_user_platform_quota_test.go +++ b/backend/internal/service/billing_cache_service_user_platform_quota_test.go @@ -769,9 +769,9 @@ func TestHasUserPlatformQuotaLimit(t *testing.T) { daily := 5.0 tests := []struct { - name string - setup func() *BillingCacheService - want bool + name string + setup func() *BillingCacheService + want bool }{ { name: "has_limit", diff --git a/backend/internal/service/billing_context_schedule.go b/backend/internal/service/billing_context_schedule.go index 7773022504..d1f994707c 100644 --- a/backend/internal/service/billing_context_schedule.go +++ b/backend/internal/service/billing_context_schedule.go @@ -19,13 +19,14 @@ const ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request" // ContextPricingTier (MinTokens, MaxTokens] 区间内的有效 per-token 单价(USD)。 // nil 表示该项无价/不计费;MaxTokens 为 nil 表示无上限。 type ContextPricingTier struct { - MinTokens int - MaxTokens *int - Label string - Input *float64 - Output *float64 - CacheWrite *float64 - CacheRead *float64 + MinTokens int + MaxTokens *int + Label string + Input *float64 + Output *float64 + CacheWrite *float64 + CacheWrite1h *float64 + CacheRead *float64 } // TimePricingPeriod 分时倍率时段:配置时区当天 [StartTime, EndTime) 内整单费用乘 Multiplier。 @@ -289,6 +290,10 @@ func probeContextTier(seg contextSegment, resolved *ResolvedPricing, probe func( return tier, err } // 输出价只随上下文所在档变化:固定上下文 c,对输出 token 数做差商(固定部分相减抵消)。 + tier.CacheWrite1h, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{CacheCreationTokens: n, CacheCreation1hTokens: n} }, c, delta, probe) + if err != nil { + return tier, err + } tier.Output, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{InputTokens: c, OutputTokens: n} }, 0, contextProbeDelta, probe) if err != nil { return tier, err @@ -298,6 +303,7 @@ func probeContextTier(seg contextSegment, resolved *ResolvedPricing, probe func( tier.Input = contextPricePtr(tier.Input, explicit.input) tier.Output = contextPricePtr(tier.Output, explicit.output) tier.CacheWrite = contextPricePtr(tier.CacheWrite, explicit.cacheWrite) + tier.CacheWrite1h = contextPricePtr(tier.CacheWrite1h, explicit.cacheWrite1h) tier.CacheRead = contextPricePtr(tier.CacheRead, explicit.cacheRead) return tier, nil } @@ -341,7 +347,7 @@ func roundContextPrice(v float64) float64 { } type explicitContextFields struct { - input, output, cacheWrite, cacheRead bool + input, output, cacheWrite, cacheWrite1h, cacheRead bool } // explicitContextPricingFields 判断各项是否被分组卡/渠道定价(含命中区间)显式配置。 @@ -355,11 +361,13 @@ func explicitContextPricingFields(resolved *ResolvedPricing, contextTokens int) out.input = cp.InputPrice != nil out.output = cp.OutputPrice != nil out.cacheWrite = cp.CacheWritePrice != nil + out.cacheWrite1h = cp.CacheWrite1hPrice != nil || cp.CacheWritePrice != nil out.cacheRead = cp.CacheReadPrice != nil if iv := FindMatchingInterval(resolved.Intervals, contextTokens); iv != nil { out.input = out.input || iv.InputPrice != nil out.output = out.output || iv.OutputPrice != nil out.cacheWrite = out.cacheWrite || iv.CacheWritePrice != nil + out.cacheWrite1h = out.cacheWrite1h || iv.CacheWrite1hPrice != nil || iv.CacheWritePrice != nil out.cacheRead = out.cacheRead || iv.CacheReadPrice != nil } return out @@ -393,7 +401,7 @@ func mergeEqualContextTiers(tiers []ContextPricingTier) []ContextPricingTier { func sameContextPrices(a, b ContextPricingTier) bool { return samePricePtr(a.Input, b.Input) && samePricePtr(a.Output, b.Output) && - samePricePtr(a.CacheWrite, b.CacheWrite) && samePricePtr(a.CacheRead, b.CacheRead) + samePricePtr(a.CacheWrite, b.CacheWrite) && samePricePtr(a.CacheWrite1h, b.CacheWrite1h) && samePricePtr(a.CacheRead, b.CacheRead) } func samePricePtr(a, b *float64) bool { diff --git a/backend/internal/service/billing_context_schedule_test.go b/backend/internal/service/billing_context_schedule_test.go index dfe43f1265..fa2c4bd8ff 100644 --- a/backend/internal/service/billing_context_schedule_test.go +++ b/backend/internal/service/billing_context_schedule_test.go @@ -385,6 +385,24 @@ func TestResolveContextPricingSchedule_NilResolver(t *testing.T) { require.Nil(t, sched) } +func TestPlazaSchedulePreservesCacheWrite1h(t *testing.T) { + cp := sonnetChannel(PricingInterval{MinTokens: 272000, CacheWriteMultiplier: testPtrFloat64(2)}) + cp[0].CacheWritePrice = testPtrFloat64(12.5e-6) + cp[0].CacheWrite1hPrice = testPtrFloat64(20e-6) + group := enabledGroup(PlatformAnthropic) + bs, resolver := newScheduleTestEnv(t, scheduleScenario{channel: cp, groupPlatform: PlatformAnthropic}) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{Model: "claude-sonnet-4", Group: group, Platform: PlatformAnthropic}) + require.NoError(t, err) + display := plazaPricingFromSchedule(&cp[0], sched) + requirePrice(t, testPtrFloat64(20e-6), display.CacheWrite1hPrice, "base 1h") + require.Len(t, display.Intervals, 2) + requirePrice(t, testPtrFloat64(25e-6), display.Intervals[1].CacheWritePrice, "tier 5m") + requirePrice(t, testPtrFloat64(40e-6), display.Intervals[1].CacheWrite1hPrice, "tier 1h") + a := ContextPricingTier{CacheWrite1h: testPtrFloat64(1)} + b := ContextPricingTier{CacheWrite1h: testPtrFloat64(2)} + require.Len(t, mergeEqualContextTiers([]ContextPricingTier{a, b}), 2) +} + // --- 对账:阶梯表推算的费用必须等于真实计费函数 --- type tokenKind int diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 9d9fdff498..78f423b27e 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -105,6 +105,7 @@ type ModelPricing struct { CacheReadPricePerTokenPriority float64 // priority service tier 下缓存读取每token价格 (USD) FastMultiplier *float64 // 渠道显式 Fast/priority 倍率;nil 时沿用模型目录行为 FlexMultiplier *float64 // 渠道显式 Flex 倍率;nil 时沿用默认行为 + MaxReasoningEffortMultiplier *float64 // max 推理等级的额度/计费倍率;nil 时沿用模型默认行为 CacheCreation5mPrice float64 // 5分钟缓存创建每token价格 (USD) CacheCreation1hPrice float64 // 1小时缓存创建每token价格 (USD) SupportsCacheBreakdown bool // 是否支持详细的缓存分类 @@ -137,7 +138,7 @@ func usePriorityServiceTierPricing(serviceTier string, pricing *ModelPricing) bo func serviceTierCostMultiplier(serviceTier string) float64 { switch normalizeBillingServiceTier(serviceTier) { - case "priority", "fast": + case "priority", "fast", OpenAIFastTierUltrafast: return 2.0 case "flex": return 0.5 @@ -214,6 +215,42 @@ func applyCostBreakdownMultiplier(cost *CostBreakdown, multiplier float64) { cost.ActualCost *= multiplier } +const claudeFable51MaxReasoningEffortMultiplier = 3.0 + +func isClaudeFable51Model(model string) bool { + model = strings.ToLower(strings.TrimSpace(model)) + for _, marker := range []string{"fable-5-1", "fable-5.1", "fable5.1", "fable51"} { + if at := strings.Index(model, marker); at >= 0 { + after := at + len(marker) + if after == len(model) || model[after] < '0' || model[after] > '9' { + return true + } + } + } + return false +} + +func defaultMaxReasoningEffortMultiplier(model string) *float64 { + if !isClaudeFable51Model(model) { + return nil + } + multiplier := claudeFable51MaxReasoningEffortMultiplier + return &multiplier +} + +func maxReasoningEffortBillingMultiplier(model, effort string, pricing *ModelPricing) float64 { + if NormalizeMaxReasoningEffort(effort) != "max" { + return 1 + } + if pricing != nil && pricing.MaxReasoningEffortMultiplier != nil && *pricing.MaxReasoningEffortMultiplier > 0 { + return *pricing.MaxReasoningEffortMultiplier + } + if multiplier := defaultMaxReasoningEffortMultiplier(model); multiplier != nil { + return *multiplier + } + return 1 +} + func resolvedChannelTimeMultiplier(resolved *ResolvedPricing, at time.Time) float64 { if resolved == nil || resolved.Source != PricingSourceChannel || resolved.channelPricing == nil { return 1 @@ -455,6 +492,20 @@ func (s *BillingService) initFallbackPricing() { SupportsCacheBreakdown: false, } + s.fallbackPrices["gpt-6-astra"] = &ModelPricing{ + InputPricePerToken: 10e-6, + InputPricePerTokenPriority: 20e-6, + OutputPricePerToken: 50e-6, + OutputPricePerTokenPriority: 100e-6, + CacheCreationPricePerToken: 12.5e-6, + CacheCreationPricePerTokenPriority: 25e-6, + CacheReadPricePerToken: 1e-6, + CacheReadPricePerTokenPriority: 2e-6, + LongContextInputThreshold: 272_000, + LongContextInputMultiplier: 2, + LongContextOutputMultiplier: 1.5, + } + // OpenAI GPT-5.6 官方价格(USD/token)。缓存写入为输入价的 1.25 倍。 s.fallbackPrices["gpt-5.6-sol"] = &ModelPricing{ InputPricePerToken: 5e-6, @@ -559,7 +610,20 @@ func (s *BillingService) initFallbackPricing() { // Source: https://docs.z.ai/guides/overview/pricing (USD per 1M tokens) // 注意:CacheReadPricePerToken 即"缓存命中"价格,CacheCreationPricePerToken 留空(智谱未公开写入价,按 0 处理)。 // GLM-4.6 与 GLM-4.5 在 z.ai 国际版上定价一致;GLM-4.5 国内按 ¥0.8/¥2,汇率换算后约 $0.112/$0.28,与国际版 $0.6/$2.2 不同,本分支采用国际版 USD 口径与现有 Claude/GPT 一致。 - // GLM-5.2 与 GLM-5.1 在 z.ai 上同价。 + // GLM-5.3 / GLM-5.2 与 GLM-5.1 在 z.ai 上同价。 + // GLM-5.3-Flash 列表价 $0.15/$0.50(2026-09-09 前五折促销,此处按列表价,与其它模型口径一致)。 + s.fallbackPrices["glm-5.3-flash"] = &ModelPricing{ + InputPricePerToken: 0.15e-6, // $0.15 per MTok + OutputPricePerToken: 0.5e-6, // $0.50 per MTok + CacheReadPricePerToken: 0.03e-6, + SupportsCacheBreakdown: false, + } + s.fallbackPrices["glm-5.3"] = &ModelPricing{ + InputPricePerToken: 1.4e-6, // $1.40 per MTok + OutputPricePerToken: 4.4e-6, // $4.40 per MTok + CacheReadPricePerToken: 0.26e-6, + SupportsCacheBreakdown: false, + } s.fallbackPrices["glm-5.2"] = &ModelPricing{ InputPricePerToken: 1.4e-6, // $1.40 per MTok OutputPricePerToken: 4.4e-6, // $4.40 per MTok @@ -827,8 +891,7 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { modelLower := strings.ToLower(model) // 按模型系列匹配 - if strings.Contains(modelLower, "fable-5-1") || strings.Contains(modelLower, "fable-5.1") || - strings.Contains(modelLower, "fable5.1") || strings.Contains(modelLower, "fable51") { + if isClaudeFable51Model(modelLower) { return s.fallbackPrices["claude-fable-5-1"] } if strings.Contains(modelLower, "fable-5") || strings.Contains(modelLower, "fable5") { @@ -904,9 +967,16 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { // 匹配策略:长 key 优先(具体模型 → 系列 / 厂商),未知型号不回退以避免误计价。 // 与 DeepSeek 一样采用"白名单"语义:未在本表命中的国产模型 alias 一律不返回兜底价。 - // 智谱 GLM(z.ai 公开 SKU:glm-5.2 / glm-5.1 / glm-5 / glm-5-turbo / glm-4.7 / glm-4.6 / glm-4.5 等) + // 智谱 GLM(z.ai 公开 SKU:glm-5.3 / glm-5.3-flash / glm-5.2 / glm-5.1 / glm-5 / glm-5-turbo / glm-4.7 / glm-4.6 / glm-4.5 等) // 匹配顺序:先判别最高 tier,再依次降级。 - // 注意:带小数点的型号必须排在裸 "glm-5" 之前,否则会被 strings.Contains 抢走。 + // 注意:带小数点的型号必须排在裸 "glm-5" 之前,否则会被 strings.Contains 抢走; + // glm-5.3-flash 必须排在 glm-5.3 之前(前者包含后者子串)。 + if strings.Contains(modelLower, "glm-5.3-flash") || strings.Contains(modelLower, "glm-5.3flash") { + return s.fallbackPrices["glm-5.3-flash"] + } + if strings.Contains(modelLower, "glm-5.3") { + return s.fallbackPrices["glm-5.3"] + } if strings.Contains(modelLower, "glm-5.2") { return s.fallbackPrices["glm-5.2"] } @@ -1006,6 +1076,8 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { // OpenAI(GPT-5 / Codex 族):仅匹配已知型号,避免未知 OpenAI 型号误计价。 if normalized := normalizeKnownOpenAICodexModel(modelLower); normalized != "" { switch normalized { + case "gpt-6-astra": + return s.fallbackPrices["gpt-6-astra"] case "gpt-5.6-sol": return s.fallbackPrices["gpt-5.6-sol"] case "gpt-5.6-terra": @@ -1199,6 +1271,9 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing applyChannelTokenPriceOverrides(pricing, channelPricing) pricing.FastMultiplier = channelPricing.FastMultiplier pricing.FlexMultiplier = channelPricing.FlexMultiplier + if channelPricing.MaxReasoningEffortMultiplier != nil { + pricing.MaxReasoningEffortMultiplier = channelPricing.MaxReasoningEffortMultiplier + } if channelPricing.ImageOutputPrice != nil { pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice } else { @@ -1271,6 +1346,7 @@ type CostInput struct { RateMultiplier float64 PricingAt time.Time // 渠道分时定价使用的计费时刻 ServiceTier string // "priority","flex","" 等 + ReasoningEffort string // 最终转发的推理等级;max 可触发模型/渠道倍率 Resolver *ModelPricingResolver // 定价解析器 Resolved *ResolvedPricing // 可选:预解析的定价结果(避免重复 Resolve 调用) LongContextBillingEnabled *bool @@ -1285,7 +1361,7 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, if input.LongContextBillingEnabled != nil { applyLongContextBilling = *input.LongContextBillingEnabled } - return s.calculateCostInternalWithPolicy( + breakdown, err := s.calculateCostInternalWithPolicy( input.Model, input.Tokens, input.RateMultiplier, @@ -1293,6 +1369,10 @@ func (s *BillingService) CalculateCostUnified(input CostInput) (*CostBreakdown, nil, applyLongContextBilling, ) + if err == nil { + applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(input.Model, input.ReasoningEffort, nil)) + } + return breakdown, err } // 优先使用预解析结果,避免重复 Resolve 调用 @@ -1376,6 +1456,7 @@ func (s *BillingService) calculateTokenCost(resolved *ResolvedPricing, input Cos breakdown := s.computeTokenBreakdown(pricing, input.Tokens, input.RateMultiplier, input.ServiceTier, applyLongCtx) applyCostBreakdownMultiplier(breakdown, resolvedChannelTimeMultiplier(resolved, input.PricingAt)) + applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(input.Model, input.ReasoningEffort, pricing)) return breakdown, nil } @@ -1657,13 +1738,17 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing } normalized := normalizeKnownOpenAICodexModel(model) isGPT56 := isOpenAIGPT56Model(normalized) + needsMaxReasoningEffortMultiplier := isClaudeFable51Model(model) && pricing.MaxReasoningEffortMultiplier == nil needsCacheCreationPolicy := isGPT56 && !pricing.CacheCreationPriceExplicit && (pricing.CacheCreationPricePerToken <= 0 || (pricing.InputPricePerTokenPriority > 0 && pricing.CacheCreationPricePerTokenPriority <= 0)) fastRatio := openAIModelFastPricingRatio(normalized) - if !needsCacheCreationPolicy && fastRatio <= 0 { + if !needsCacheCreationPolicy && fastRatio <= 0 && !needsMaxReasoningEffortMultiplier { return pricing } cloned := *pricing + if needsMaxReasoningEffortMultiplier { + cloned.MaxReasoningEffortMultiplier = defaultMaxReasoningEffortMultiplier(model) + } if isGPT56 && !cloned.CacheCreationPriceExplicit { if cloned.CacheCreationPricePerToken <= 0 { cloned.CacheCreationPricePerToken = cloned.InputPricePerToken * 1.25 @@ -1678,16 +1763,19 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing return &cloned } -// openAIModelFastPricingRatio 返回业务口径下 OpenAI GPT-5.x 模型 Fast/priority -// 的标准价倍率:gpt-5.6 系列与 gpt-5.4 为 2x,gpt-5.5 为 2.5x。未定义 Fast +// openAIModelFastPricingRatio 返回业务口径下 OpenAI GPT 模型 Fast/priority +// 的标准价倍率:gpt-5.6 / gpt-6-astra / gpt-5.4 为 2x,gpt-5.5 为 2.5x。未定义 Fast // 档的模型(如 gpt-5.5-pro、gpt-5.4-mini/nano)返回 0。 func openAIModelFastPricingRatio(normalized string) float64 { switch normalized { - case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna": + case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra": return 2.0 case "gpt-5.5": return 2.5 default: + if isOpenAIGPT6AstraModel(normalized) { + return 2.0 + } return 0 } } diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 956fe73d33..811419e861 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -549,6 +549,20 @@ func TestGetFallbackPricing_FamilyMatching(t *testing.T) { }, // ---- 智谱 GLM(z.ai USD 口径)---- + { + name: "glm 5.3 flagship", + model: "glm-5.3", + expectedInput: 1.4e-6, + expectedOutput: floatPtr(4.4e-6), + expectedCacheRead: floatPtr(0.26e-6), + }, + { + name: "glm 5.3 flash", + model: "glm-5.3-flash", + expectedInput: 0.15e-6, + expectedOutput: floatPtr(0.5e-6), + expectedCacheRead: floatPtr(0.03e-6), + }, { name: "glm 5.2 flagship", model: "glm-5.2", @@ -639,7 +653,21 @@ func TestGetFallbackPricing_FamilyMatching(t *testing.T) { expectedInput: 0.1e-6, expectedOutput: floatPtr(0.1e-6), }, - // 关键:5.1 / 5.2 必须先于 5 匹配(避免被 glm-5 抢走) + // 关键:5.1 / 5.2 / 5.3 必须先于 5 匹配(避免被 glm-5 抢走) + { + name: "glm 5.3-flash vs glm 5.3 ordering (verbatim 5.3-flash)", + model: "glm-5.3-flash", + expectedInput: 0.15e-6, // = glm-5.3-flash 价格(不是 glm-5.3 的 1.4e-6,更不是 glm-5 的 1e-6) + expectedOutput: floatPtr(0.5e-6), + expectedCacheRead: floatPtr(0.03e-6), + }, + { + name: "glm 5.3 vs glm 5 ordering (verbatim 5.3)", + model: "glm-5.3", + expectedInput: 1.4e-6, // = glm-5.3 价格(不是 glm-5 的 1e-6) + expectedOutput: floatPtr(4.4e-6), + expectedCacheRead: floatPtr(0.26e-6), + }, { name: "glm 5.1 vs glm 5 ordering (verbatim 5.1)", model: "glm-5.1", @@ -1431,6 +1459,7 @@ func TestCalculateCost_LargeTokenCount(t *testing.T) { func TestServiceTierCostMultiplier(t *testing.T) { require.InDelta(t, 2.0, serviceTierCostMultiplier("priority"), 1e-12) require.InDelta(t, 2.0, serviceTierCostMultiplier(" Priority "), 1e-12) + require.InDelta(t, 2.0, serviceTierCostMultiplier("ultrafast"), 1e-12) require.InDelta(t, 0.5, serviceTierCostMultiplier("flex"), 1e-12) require.InDelta(t, 1.0, serviceTierCostMultiplier(""), 1e-12) require.InDelta(t, 1.0, serviceTierCostMultiplier("default"), 1e-12) @@ -1770,6 +1799,8 @@ func TestGetModelPricing_Fable51FallbackPricing(t *testing.T) { require.InDelta(t, 12.5e-6, pricing.CacheCreation5mPrice, 1e-12) require.InDelta(t, 20e-6, pricing.CacheCreation1hPrice, 1e-12) require.InDelta(t, 0.25e-6, pricing.CacheReadPricePerToken, 1e-12) + require.NotNil(t, pricing.MaxReasoningEffortMultiplier) + require.Equal(t, 3.0, *pricing.MaxReasoningEffortMultiplier) } func TestGetModelPricingWithChannel_CacheReadPriceAffectsPriority(t *testing.T) { diff --git a/backend/internal/service/billing_token_cost_request.go b/backend/internal/service/billing_token_cost_request.go index 6a59be16a5..0794864dd0 100644 --- a/backend/internal/service/billing_token_cost_request.go +++ b/backend/internal/service/billing_token_cost_request.go @@ -7,14 +7,15 @@ import ( // TokenCostRequest 通用网关 token 计费请求。 type TokenCostRequest struct { - Ctx context.Context - Model string - Group *Group - Tokens UsageTokens - RateMultiplier float64 - PricingAt time.Time - ServiceTier string - Resolver *ModelPricingResolver + Ctx context.Context + Model string + Group *Group + Tokens UsageTokens + RateMultiplier float64 + PricingAt time.Time + ServiceTier string + ReasoningEffort string + Resolver *ModelPricingResolver // Resolved 为调用方预先解析的定价(Resolver.Resolve 的结果),nil 表示未解析。 Resolved *ResolvedPricing } @@ -33,21 +34,25 @@ func (s *BillingService) CalculateTokenCostForRequest(req TokenCostRequest) (*Co if req.Resolver != nil && req.Group != nil { return s.CalculateCostUnified(s.tokenCostInput(req, resolved)) } + if req.ReasoningEffort != "" { + return s.CalculateCostUnified(s.tokenCostInput(req, resolved)) + } return s.CalculateCost(req.Model, req.Tokens, req.RateMultiplier) } func (s *BillingService) tokenCostInput(req TokenCostRequest, resolved *ResolvedPricing) CostInput { input := CostInput{ - Ctx: req.Ctx, - Model: req.Model, - Group: req.Group, - Tokens: req.Tokens, - RequestCount: 1, - RateMultiplier: req.RateMultiplier, - PricingAt: req.PricingAt, - ServiceTier: req.ServiceTier, - Resolver: req.Resolver, - Resolved: resolved, + Ctx: req.Ctx, + Model: req.Model, + Group: req.Group, + Tokens: req.Tokens, + RequestCount: 1, + RateMultiplier: req.RateMultiplier, + PricingAt: req.PricingAt, + ServiceTier: req.ServiceTier, + ReasoningEffort: req.ReasoningEffort, + Resolver: req.Resolver, + Resolved: resolved, } if req.Group != nil { gid := req.Group.ID diff --git a/backend/internal/service/billing_token_cost_request_test.go b/backend/internal/service/billing_token_cost_request_test.go index 9d282de061..23fae53557 100644 --- a/backend/internal/service/billing_token_cost_request_test.go +++ b/backend/internal/service/billing_token_cost_request_test.go @@ -23,7 +23,7 @@ func newTokenCostTestEnv(t *testing.T, groupPlatform string, pricing []ChannelMo return map[int64]string{100: groupPlatform}, nil }, } - cs := NewChannelService(repo, nil, nil, nil) + cs := NewChannelService(repo, nil, nil, nil, nil) bs := NewBillingService(&config.Config{}, catalog) return bs, NewModelPricingResolver(cs, bs) } @@ -216,3 +216,41 @@ func TestCalculateTokenCostForRequest_NoResolverFallsBackToCatalog(t *testing.T) require.NoError(t, err) require.Equal(t, want, got) } + +func TestCalculateTokenCostForRequest_Fable51MaxEffortUsesDefaultMultiplier(t *testing.T) { + bs := NewBillingService(&config.Config{}, nil) + tokens := UsageTokens{InputTokens: 1000, OutputTokens: 10} + standard, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Model: "claude-fable-5-1", Tokens: tokens, RateMultiplier: 1, ReasoningEffort: "xhigh", + }) + require.NoError(t, err) + max, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Model: "claude-fable-5-1", Tokens: tokens, RateMultiplier: 1, ReasoningEffort: "max", + }) + require.NoError(t, err) + require.InDelta(t, standard.TotalCost*3, max.TotalCost, 1e-12) + require.InDelta(t, standard.ActualCost*3, max.ActualCost, 1e-12) + require.InDelta(t, standard.InputCost*3, max.InputCost, 1e-12) + require.InDelta(t, standard.OutputCost*3, max.OutputCost, 1e-12) +} + +func TestCalculateTokenCostForRequest_ChannelOverridesFable51MaxEffortMultiplier(t *testing.T) { + configured := 1.5 + bs, resolver := newTokenCostTestEnv(t, PlatformAnthropic, []ChannelModelPricing{{ + Platform: PlatformAnthropic, Models: []string{"claude-fable-5-1"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(10e-6), OutputPrice: testPtrFloat64(50e-6), + MaxReasoningEffortMultiplier: &configured, + }}, nil) + group := &Group{ID: 100, Platform: PlatformAnthropic} + gid := group.ID + resolved := resolver.Resolve(context.Background(), PricingInput{Model: "claude-fable-5-1", GroupID: &gid, Group: group}) + + got, err := bs.CalculateTokenCostForRequest(TokenCostRequest{ + Ctx: context.Background(), Model: "claude-fable-5-1", Group: group, + Tokens: UsageTokens{InputTokens: 1000}, RateMultiplier: 1, ReasoningEffort: "max", + Resolver: resolver, Resolved: resolved, + }) + require.NoError(t, err) + require.InDelta(t, 1000*10e-6*configured, got.TotalCost, 1e-12) + require.InDelta(t, got.TotalCost, got.ActualCost, 1e-12) +} diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index df3b5f892a..f9fea3bcc9 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -87,25 +87,26 @@ type AccountStatsPricingRule struct { // ChannelModelPricing 渠道模型定价条目 type ChannelModelPricing struct { - ID int64 `json:"id,omitempty"` - ChannelID int64 `json:"channel_id,omitempty"` - Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...) - Models []string `json:"models"` - BillingMode BillingMode `json:"billing_mode"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - FastMultiplier *float64 `json:"fast_multiplier"` - FlexMultiplier *float64 `json:"flex_multiplier"` - ImageInputPrice *float64 `json:"image_input_price"` - ImageOutputPrice *float64 `json:"image_output_price"` - PerRequestPrice *float64 `json:"per_request_price"` - Intervals []PricingInterval `json:"intervals"` - TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + ID int64 `json:"id,omitempty"` + ChannelID int64 `json:"channel_id,omitempty"` + Platform string `json:"platform"` // 所属平台(anthropic/openai/gemini/...) + Models []string `json:"models"` + BillingMode BillingMode `json:"billing_mode"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + FastMultiplier *float64 `json:"fast_multiplier"` + FlexMultiplier *float64 `json:"flex_multiplier"` + MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"` + ImageInputPrice *float64 `json:"image_input_price"` + ImageOutputPrice *float64 `json:"image_output_price"` + PerRequestPrice *float64 `json:"per_request_price"` + Intervals []PricingInterval `json:"intervals"` + TimePricing *ChannelTimePricing `json:"time_pricing,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` } // ChannelTimePricing 渠道模型定价的分时倍率配置。 diff --git a/backend/internal/service/channel_available.go b/backend/internal/service/channel_available.go index 08b02eb423..46eb15d6bb 100644 --- a/backend/internal/service/channel_available.go +++ b/backend/internal/service/channel_available.go @@ -117,20 +117,16 @@ func (s *ChannelService) ListAvailable(ctx context.Context) ([]AvailableChannel, // 1. Pricing == nil(渠道完全没声明该模型的定价条目) // 2. Pricing 非 nil 但所有价格字段为空(admin UI 建了条目但没填价格) // -// 当 pricingService 为 nil(测试场景),跳过回落。可用渠道与模型广场共用。 +// 当 pricingService 为 nil(测试场景),跳过价格回落,但仍补充内置模型倍率。 +// 可用渠道与模型广场共用。 func fillGlobalPricingFallback(pricingService *PricingService, models []SupportedModel) { - if pricingService == nil { - return - } for i := range models { - if !pricingNeedsFallback(models[i].Pricing) { - continue + if pricingService != nil && pricingNeedsFallback(models[i].Pricing) { + if lp := pricingService.GetModelPricing(models[i].Name); lp != nil { + models[i].Pricing = synthesizePricingFromLiteLLM(lp, models[i].Pricing) + } } - lp := pricingService.GetModelPricing(models[i].Name) - if lp == nil { - continue - } - models[i].Pricing = synthesizePricingFromLiteLLM(lp, models[i].Pricing) + models[i].Pricing = withDefaultMaxReasoningEffortMultiplier(models[i].Pricing, models[i].Name) } } @@ -180,24 +176,33 @@ func synthesizePricingFromLiteLLM(lp *LiteLLMModelPricing, existing *ChannelMode if mode == BillingModeImage || mode == BillingModePerRequest { return &ChannelModelPricing{ - BillingMode: mode, - PerRequestPrice: nonZeroPtr(lp.OutputCostPerImage), - ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), - InputPrice: nonZeroPtr(lp.InputCostPerToken), - OutputPrice: nonZeroPtr(lp.OutputCostPerToken), + BillingMode: mode, + PerRequestPrice: nonZeroPtr(lp.OutputCostPerImage), + ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), + InputPrice: nonZeroPtr(lp.InputCostPerToken), + OutputPrice: nonZeroPtr(lp.OutputCostPerToken), + MaxReasoningEffortMultiplier: maxReasoningEffortMultiplierFromPricing(existing), } } return &ChannelModelPricing{ - BillingMode: mode, - InputPrice: nonZeroPtr(lp.InputCostPerToken), - OutputPrice: nonZeroPtr(lp.OutputCostPerToken), - CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost), - CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr), - CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost), - ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), + BillingMode: mode, + InputPrice: nonZeroPtr(lp.InputCostPerToken), + OutputPrice: nonZeroPtr(lp.OutputCostPerToken), + CacheWritePrice: nonZeroPtr(lp.CacheCreationInputTokenCost), + CacheWrite1hPrice: nonZeroPtr(lp.CacheCreationInputTokenCostAbove1hr), + CacheReadPrice: nonZeroPtr(lp.CacheReadInputTokenCost), + ImageOutputPrice: nonZeroPtr(lp.OutputCostPerImageToken), + MaxReasoningEffortMultiplier: maxReasoningEffortMultiplierFromPricing(existing), } } +func maxReasoningEffortMultiplierFromPricing(pricing *ChannelModelPricing) *float64 { + if pricing == nil { + return nil + } + return pricing.MaxReasoningEffortMultiplier +} + func nonZeroPtr(v float64) *float64 { if v == 0 { return nil diff --git a/backend/internal/service/channel_available_test.go b/backend/internal/service/channel_available_test.go index 2b7b23f7e5..d382c5fc2d 100644 --- a/backend/internal/service/channel_available_test.go +++ b/backend/internal/service/channel_available_test.go @@ -75,7 +75,7 @@ func newAvailableChannelService(channels []Channel, groupRepo GroupRepository) * repo := &mockChannelRepository{ listAllFn: func(ctx context.Context) ([]Channel, error) { return channels, nil }, } - return NewChannelService(repo, groupRepo, nil, nil) + return NewChannelService(repo, groupRepo, nil, nil, nil) } func TestListAvailable_EmptyActiveGroups_NoGroupsAttached(t *testing.T) { @@ -134,7 +134,7 @@ func TestListAvailable_ListAllErrorPropagates(t *testing.T) { listAllFn: func(ctx context.Context) ([]Channel, error) { return nil, sentinel }, } groupRepo := &stubGroupRepoForAvailable{} - svc := NewChannelService(repo, groupRepo, nil, nil) + svc := NewChannelService(repo, groupRepo, nil, nil, nil) out, err := svc.ListAvailable(context.Background()) require.Nil(t, out) require.ErrorIs(t, err, sentinel) diff --git a/backend/internal/service/channel_monitor_checker.go b/backend/internal/service/channel_monitor_checker.go index 71177db0bd..404f17e5b8 100644 --- a/backend/internal/service/channel_monitor_checker.go +++ b/backend/internal/service/channel_monitor_checker.go @@ -175,6 +175,7 @@ var providerAdapters = map[string]providerAdapter{ MonitorProviderKimi: providerKimiChatAdapter, MonitorProviderZhipu: providerZhipuChatAdapter, MonitorProviderDeepseek: providerDeepseekChatAdapter, + MonitorProviderMiniMax: providerMiniMaxChatAdapter, MonitorProviderAnthropic: { buildPath: func(string) string { return providerAnthropicPath }, buildBody: func(model, prompt string) ([]byte, error) { @@ -198,7 +199,7 @@ var providerAdapters = map[string]providerAdapter{ buildBody: func(_, prompt string) ([]byte, error) { return json.Marshal(map[string]any{ "contents": []map[string]any{ - {"parts": []map[string]any{{"text": prompt}}}, + {"role": "user", "parts": []map[string]any{{"text": prompt}}}, }, "generationConfig": map[string]any{"maxOutputTokens": monitorChallengeMaxTokens}, }) @@ -226,6 +227,9 @@ var providerZhipuChatAdapter = newOpenAICompatibleChatAdapter(providerZhipuPath) //nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。 var providerDeepseekChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath) +//nolint:gochecknoglobals // 适配器表是只读静态数据,初始化后不变更。 +var providerMiniMaxChatAdapter = newOpenAICompatibleChatAdapter(providerOpenAIPath) + func newOpenAICompatibleChatAdapter(path string) providerAdapter { return providerAdapter{ buildPath: func(string) string { return path }, @@ -458,6 +462,7 @@ var bodyMergeKeyDenyList = map[string]map[string]bool{ MonitorProviderKimi: {"model": true, "messages": true, "stream": true}, MonitorProviderZhipu: {"model": true, "messages": true, "stream": true}, MonitorProviderDeepseek: {"model": true, "messages": true, "stream": true}, + MonitorProviderMiniMax: {"model": true, "messages": true, "stream": true}, } func checkAPIMode(opts *CheckOptions) string { @@ -479,7 +484,7 @@ func bodyMergeDenyKey(provider, apiMode string) string { func isOpenAICompatibleChatProvider(provider string) bool { switch provider { case MonitorProviderOpenAI, MonitorProviderGrok, - MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek: + MonitorProviderKimi, MonitorProviderZhipu, MonitorProviderDeepseek, MonitorProviderMiniMax: return true default: return false diff --git a/backend/internal/service/channel_monitor_checker_body_test.go b/backend/internal/service/channel_monitor_checker_body_test.go index 2357646337..922eb86732 100644 --- a/backend/internal/service/channel_monitor_checker_body_test.go +++ b/backend/internal/service/channel_monitor_checker_body_test.go @@ -498,3 +498,26 @@ func TestValidateChallenge_AnthropicTextAfterThinking(t *testing.T) { t.Fatalf("validateChallenge(%q, %q) = false, want true", respText, "2") } } + +func TestGeminiMonitorBodyIncludesExplicitUserRole(t *testing.T) { + adapter := providerAdapters[MonitorProviderGemini] + body, err := adapter.buildBody("gemini-3.6-flash", "Reply with only 7.") + if err != nil { + t.Fatalf("buildBody() error = %v", err) + } + + var payload struct { + Contents []struct { + Role string `json:"role"` + } `json:"contents"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(payload.Contents) != 1 { + t.Fatalf("contents length = %d, want 1", len(payload.Contents)) + } + if payload.Contents[0].Role != "user" { + t.Fatalf("contents[0].role = %q, want user", payload.Contents[0].Role) + } +} diff --git a/backend/internal/service/channel_monitor_const.go b/backend/internal/service/channel_monitor_const.go index 6a2b9b92e8..155d6082f8 100644 --- a/backend/internal/service/channel_monitor_const.go +++ b/backend/internal/service/channel_monitor_const.go @@ -69,6 +69,7 @@ const ( MonitorProviderKimi = "kimi" MonitorProviderZhipu = "zhipu" MonitorProviderDeepseek = "deepseek" + MonitorProviderMiniMax = "minimax" // MonitorCheckMode 检测模式(channel_monitors.check_mode)。 // probe - LLM 探活(默认,原有行为) @@ -151,7 +152,7 @@ var ( "CHANNEL_MONITOR_NOT_FOUND", "channel monitor not found", ) ErrChannelMonitorInvalidProvider = infraerrors.BadRequest( - "CHANNEL_MONITOR_INVALID_PROVIDER", "provider must be one of openai/anthropic/gemini/grok/antigravity/kimi/zhipu/deepseek", + "CHANNEL_MONITOR_INVALID_PROVIDER", "provider must be one of openai/anthropic/gemini/grok/antigravity/kimi/zhipu/deepseek/minimax", ) ErrChannelMonitorInvalidCheckMode = infraerrors.BadRequest( "CHANNEL_MONITOR_INVALID_CHECK_MODE", "check_mode must be one of probe/quota/quota_probe; antigravity only supports quota", @@ -163,7 +164,7 @@ var ( "CHANNEL_MONITOR_PROVIDER_INCOMPATIBLE", "monitor provider must match the linked account platform", ) ErrChannelMonitorAccountNotSupportable = infraerrors.BadRequest( - "CHANNEL_MONITOR_ACCOUNT_NOT_SUPPORTABLE", "linked account cannot serve as a quota data source (cn coding plan must be kimi/zhipu, cn payg must be kimi/deepseek, openai requires an oauth account, anthropic requires oauth or setup-token)", + "CHANNEL_MONITOR_ACCOUNT_NOT_SUPPORTABLE", "linked account cannot serve as a quota data source (cn coding plan must be kimi/zhipu/minimax, cn payg must be kimi/deepseek, openai requires an oauth account, anthropic requires oauth or setup-token)", ) ErrChannelMonitorInvalidAPIMode = infraerrors.BadRequest( "CHANNEL_MONITOR_INVALID_API_MODE", "api_mode must be chat_completions or responses; responses is only supported for openai", diff --git a/backend/internal/service/channel_monitor_quota_fetcher.go b/backend/internal/service/channel_monitor_quota_fetcher.go index f8157ddd87..4272ea640d 100644 --- a/backend/internal/service/channel_monitor_quota_fetcher.go +++ b/backend/internal/service/channel_monitor_quota_fetcher.go @@ -216,7 +216,7 @@ func (f *ChannelMonitorQuotaFetcher) fetchUncached(ctx context.Context, accountI // (GetUsageForAccount / QueryUsageForAccount / QueryBalanceForAccount), // 下游服务不再各自 GetByID(每次含 proxies/groups 联查)。 switch account.Platform { - case domain.PlatformKimi, domain.PlatformZhipu, domain.PlatformDeepseek: + case domain.PlatformKimi, domain.PlatformZhipu, domain.PlatformDeepseek, domain.PlatformMiniMax: if account.IsCodingPlan() { return f.fetchCNQuota(ctx, account, now) } diff --git a/backend/internal/service/channel_monitor_quota_fetcher_test.go b/backend/internal/service/channel_monitor_quota_fetcher_test.go index 822009a17e..b03b9fe33d 100644 --- a/backend/internal/service/channel_monitor_quota_fetcher_test.go +++ b/backend/internal/service/channel_monitor_quota_fetcher_test.go @@ -173,6 +173,30 @@ func TestQuotaFetcher_CodingPlanAccountUsesCNQuota(t *testing.T) { require.Equal(t, 0, cnBalance.calls) } +func TestQuotaFetcher_MiniMaxCodingPlanUsesCNQuota(t *testing.T) { + fetcher, usage, cnQuota, cnBalance, accounts := newQuotaFetcherTestSetup(t) + accounts.accounts[19] = &Account{ + ID: 19, + Platform: domain.PlatformMiniMax, + Credentials: map[string]any{"account_mode": AccountModeCoding}, + } + cnQuota.result = &CNProviderQuotaProbeResult{ + Success: true, + CredentialValid: true, + Tiers: []CNQuotaTier{ + {Window: "5h", UsedPercent: 12}, + }, + } + + snapshot := fetcher.Fetch(context.Background(), 19) + + require.True(t, snapshot.Success) + require.Equal(t, "cn_quota", snapshot.Source) + require.Equal(t, 1, cnQuota.calls) + require.Equal(t, 0, cnBalance.calls) + require.Equal(t, 0, usage.getCalls()) +} + func TestQuotaFetcher_PayGAccountUsesCNBalance(t *testing.T) { fetcher, _, _, cnBalance, accounts := newQuotaFetcherTestSetup(t) accounts.accounts[11] = &Account{ diff --git a/backend/internal/service/channel_monitor_quota_mode_test.go b/backend/internal/service/channel_monitor_quota_mode_test.go index 7309d2cf2a..b4ca4dae29 100644 --- a/backend/internal/service/channel_monitor_quota_mode_test.go +++ b/backend/internal/service/channel_monitor_quota_mode_test.go @@ -458,11 +458,26 @@ func TestMonitorAccountQuotaCapability_Matrix(t *testing.T) { name: "zhipu coding default endpoint ok", account: &Account{ID: 4, Platform: domain.PlatformZhipu, Credentials: map[string]any{"account_mode": AccountModeCoding}}, }, + { + name: "minimax coding default endpoint ok", + account: &Account{ID: 14, Platform: domain.PlatformMiniMax, Credentials: map[string]any{"account_mode": AccountModeCoding}}, + }, + { + name: "custom-domain minimax coding unsupported", + account: &Account{ID: 16, Platform: domain.PlatformMiniMax, Type: AccountTypeAPIKey, + Credentials: map[string]any{"account_mode": AccountModeCoding, "base_url": "https://relay.example.com/v1"}}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, { name: "zhipu payg has no balance endpoint", account: &Account{ID: 5, Platform: domain.PlatformZhipu}, wantErr: ErrChannelMonitorAccountNotSupportable, }, + { + name: "minimax payg has no balance endpoint", + account: &Account{ID: 15, Platform: domain.PlatformMiniMax}, + wantErr: ErrChannelMonitorAccountNotSupportable, + }, { name: "kimi payg ok", account: &Account{ID: 6, Platform: domain.PlatformKimi}, diff --git a/backend/internal/service/channel_monitor_v2.go b/backend/internal/service/channel_monitor_v2.go index 1afc20293c..cb2251de7a 100644 --- a/backend/internal/service/channel_monitor_v2.go +++ b/backend/internal/service/channel_monitor_v2.go @@ -405,6 +405,18 @@ func (s *ChannelMonitorV2Service) hideThroughputForViewer(ctx context.Context, a return s.settings.GetChannelMonitorRuntime(ctx).HideThroughput } +func (s *ChannelMonitorV2Service) hideUserRankingForViewer(ctx context.Context, admin bool) bool { + if admin { + return false + } + // Missing reader keeps the current ranking tab visible. Unlike throughput, + // ranking is already public and must stay on until an operator turns it off. + if s == nil || s.settings == nil { + return false + } + return s.settings.GetChannelMonitorRuntime(ctx).HideUserRanking +} + func (s *ChannelMonitorV2Service) GetConfig(ctx context.Context) (*ChannelMonitorV2Config, error) { return s.repo.GetConfig(ctx) } @@ -652,6 +664,9 @@ func (s *ChannelMonitorV2Service) Users(ctx context.Context, filter ChannelMonit if err != nil { return nil, err } + if s.hideUserRankingForViewer(ctx, admin) { + return &ChannelMonitorV2List[ChannelMonitorV2UserRow]{Items: []ChannelMonitorV2UserRow{}}, nil + } result, err := s.repo.GetUsers(ctx, filter, *cfg, admin) if err != nil { return nil, err diff --git a/backend/internal/service/channel_monitor_v2_test.go b/backend/internal/service/channel_monitor_v2_test.go index 9172b333df..d26f43eef2 100644 --- a/backend/internal/service/channel_monitor_v2_test.go +++ b/backend/internal/service/channel_monitor_v2_test.go @@ -252,6 +252,20 @@ func TestChannelMonitorV2HealthBlendsErrorTTFTAndCache(t *testing.T) { require.Nil(t, health.Score) } +func TestChannelMonitorV2HealthLeavesMissingTTFTUnknown(t *testing.T) { + health := ChannelMonitorV2HealthFor(ChannelMonitorV2Metric{ + RequestCount: 200, + ErrorRate: 0, + CacheRate: 0, + CacheRateDenominator: 200, + TTFT: ChannelMonitorV2Latency{SampleCount: 0}, + }) + require.Equal(t, "unknown", health.TTFT) + require.Nil(t, health.TTFTScore) + require.NotEqual(t, "critical", health.Overall) + require.Equal(t, "healthy", health.Overall) +} + func TestChannelMonitorV2DefaultHealthThresholdsAreTolerant(t *testing.T) { p50 := int64(2500) health := ChannelMonitorV2HealthFor(ChannelMonitorV2Metric{ @@ -287,6 +301,32 @@ func TestErrorRateTTFTAndCacheScoreHelpers(t *testing.T) { require.Equal(t, "healthy", cacheRateBand(0.50, 0.20, 0.05)) } +type channelMonitorV2RuntimeStub struct { + rt ChannelMonitorRuntime +} + +func (s channelMonitorV2RuntimeStub) GetChannelMonitorRuntime(context.Context) ChannelMonitorRuntime { + return s.rt +} + +func TestChannelMonitorV2UsersHiddenWhenSettingEnabled(t *testing.T) { + selfID, otherID := int64(7), int64(9) + repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, users: &ChannelMonitorV2List[ChannelMonitorV2UserRow]{Items: []ChannelMonitorV2UserRow{ + {UserID: &otherID, Email: "other@example.com", Username: "other"}, + {UserID: &selfID, Email: "self@example.com", Username: "self"}, + }}} + svc := NewChannelMonitorV2Service(repo) + svc.SetRuntimeReader(channelMonitorV2RuntimeStub{rt: ChannelMonitorRuntime{HideUserRanking: true}}) + + hidden, err := svc.Users(context.Background(), ChannelMonitorV2Filter{}, selfID, false) + require.NoError(t, err) + require.Empty(t, hidden.Items) + + admin, err := svc.Users(context.Background(), ChannelMonitorV2Filter{}, selfID, true) + require.NoError(t, err) + require.Len(t, admin.Items, 2) +} + func TestChannelMonitorV2UsersRemovesOtherUserIdentity(t *testing.T) { selfID, otherID := int64(7), int64(9) repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, users: &ChannelMonitorV2List[ChannelMonitorV2UserRow]{Items: []ChannelMonitorV2UserRow{ diff --git a/backend/internal/service/channel_monitor_validate.go b/backend/internal/service/channel_monitor_validate.go index 6b3b9fd81f..1ca6dcb701 100644 --- a/backend/internal/service/channel_monitor_validate.go +++ b/backend/internal/service/channel_monitor_validate.go @@ -211,22 +211,22 @@ func normalizeMonitorPrimaryModel(provider, checkMode, model string) string { // monitorAccountQuotaCapability 校验关联账号能否充当配额数据源,与 // fetchUncached 的路由一一对应(coding→CN 额度端点 / payg→CN 余额端点 / // 其余→AccountUsageService)。在创建/更新期拦截注定运行期永久 error 的组合: -// - kimi/zhipu/deepseek coding:GetCodingPlanProvider 须识别为 kimi/zhipu -// (deepseek coding、自定义域名 kimi coding 无法路由额度端点) -// - kimi/zhipu/deepseek payg:仅 kimi/deepseek 有公开余额端点(zhipu payg 无) +// - kimi/zhipu/deepseek/minimax coding:GetCodingPlanProvider 须识别官方域名 +// (deepseek coding、自定义中转、minimax payg 无法路由额度端点) +// - kimi/zhipu/deepseek/minimax payg:仅 kimi/deepseek 有公开余额端点 // - anthropic:OAuth / Setup Token(API-Key 型无 usage 通道,永久 error) // - openai:OAuth(API-Key 型无 usage 通道) // - gemini/grok/antigravity:本地统计/值通道降级,不会永久 error,放行 func monitorAccountQuotaCapability(account *Account) error { switch account.Platform { - case PlatformKimi, PlatformZhipu, PlatformDeepseek: + case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: if account.IsCodingPlan() { - if p := account.GetCodingPlanProvider(); p != PlatformKimi && p != PlatformZhipu { + if p := account.GetCodingPlanProvider(); p != PlatformKimi && p != PlatformZhipu && p != PlatformMiniMax { return ErrChannelMonitorAccountNotSupportable } return nil } - if account.Platform == PlatformZhipu { + if account.Platform == PlatformZhipu || account.Platform == PlatformMiniMax { return ErrChannelMonitorAccountNotSupportable } return nil diff --git a/backend/internal/service/channel_pricing_multipliers_test.go b/backend/internal/service/channel_pricing_multipliers_test.go index 5b38fddec2..11aea750ff 100644 --- a/backend/internal/service/channel_pricing_multipliers_test.go +++ b/backend/internal/service/channel_pricing_multipliers_test.go @@ -19,6 +19,8 @@ func TestConfiguredServiceTierMultiplier(t *testing.T) { {name: "priority alias", serviceTier: "priority", pricing: &ModelPricing{FastMultiplier: pricingMultiplier(2)}, want: 2}, {name: "flex configured", serviceTier: "flex", pricing: &ModelPricing{FlexMultiplier: pricingMultiplier(0.4)}, want: 0.4}, {name: "legacy fast default", serviceTier: "fast", pricing: &ModelPricing{}, want: 2}, + {name: "ultrafast default", serviceTier: "ultrafast", pricing: &ModelPricing{}, want: 2}, + {name: "ultrafast ignores fast multiplier", serviceTier: "ultrafast", pricing: &ModelPricing{FastMultiplier: pricingMultiplier(2.5)}, want: 2}, {name: "legacy flex default", serviceTier: "flex", pricing: &ModelPricing{}, want: 0.5}, } diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 688b46e7e9..60d116f8eb 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -147,14 +147,22 @@ const ( channelCacheTTL = 10 * time.Minute channelErrorTTL = 5 * time.Second // DB 错误时的短缓存 channelCacheDBTimeout = 10 * time.Second + channelCacheNotifyTTL = 3 * time.Second ) +// ChannelCachePubSub broadcasts channel cache invalidations between instances. +type ChannelCachePubSub interface { + NotifyUpdate(ctx context.Context) error + SubscribeUpdates(ctx context.Context, handler func()) +} + // ChannelService 渠道管理服务 type ChannelService struct { repo ChannelRepository groupRepo GroupRepository authCacheInvalidator APIKeyAuthCacheInvalidator pricingService *PricingService // 用于「可用渠道」展示时回落到全局定价;可为 nil(测试场景) + cachePubSub ChannelCachePubSub cache atomic.Value // *channelCache cacheSF singleflight.Group @@ -163,13 +171,15 @@ type ChannelService struct { // NewChannelService 创建渠道服务实例。 // pricingService 仅供 ListAvailable 在渠道未配置定价时回落到全局 LiteLLM 数据; // 计费热路径走独立的 ModelPricingResolver,与此参数无关。可传 nil。 -func NewChannelService(repo ChannelRepository, groupRepo GroupRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, pricingService *PricingService) *ChannelService { +func NewChannelService(repo ChannelRepository, groupRepo GroupRepository, authCacheInvalidator APIKeyAuthCacheInvalidator, pricingService *PricingService, cachePubSub ChannelCachePubSub) *ChannelService { s := &ChannelService{ repo: repo, groupRepo: groupRepo, authCacheInvalidator: authCacheInvalidator, pricingService: pricingService, + cachePubSub: cachePubSub, } + s.subscribeCacheUpdates(context.Background()) return s } @@ -357,7 +367,7 @@ func isPlatformPricingMatch(groupPlatform, pricingPlatform string) bool { // fallback used before a request target has been resolved. func matchingPlatforms(groupPlatform string) []string { if groupPlatform == PlatformComposite { - return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} + return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} } return []string{groupPlatform} } @@ -383,13 +393,41 @@ func (s *ChannelService) InvalidateCache() { } func (s *ChannelService) invalidateCache() { - s.cache.Store((*channelCache)(nil)) - s.cacheSF.Forget("channel_cache") + s.clearCache() // 主动重建缓存,确保 CRUD 后立即生效 if _, err := s.buildCache(context.Background()); err != nil { slog.Warn("failed to rebuild channel cache after invalidation", "error", err) } + + s.notifyCacheUpdate() +} + +// clearCache clears only the in-process snapshot. Keeping this separate from +// invalidateCache prevents notifications received from Redis from being +// published again in a loop. +func (s *ChannelService) clearCache() { + s.cache.Store((*channelCache)(nil)) + s.cacheSF.Forget("channel_cache") +} + +func (s *ChannelService) notifyCacheUpdate() { + if s.cachePubSub == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), channelCacheNotifyTTL) + defer cancel() + if err := s.cachePubSub.NotifyUpdate(ctx); err != nil { + slog.Warn("failed to publish channel cache invalidation", "error", err) + } +} + +func (s *ChannelService) subscribeCacheUpdates(ctx context.Context) { + if s.cachePubSub == nil { + return + } + s.cachePubSub.SubscribeUpdates(ctx, s.clearCache) } // matchWildcard 在通配符定价中查找匹配项(最先匹配到优先) diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index e443b80b59..2698da061c 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "net/http" + "sync" "testing" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" @@ -175,6 +176,27 @@ type mockChannelAuthCacheInvalidator struct { invalidatedUserIDs []int64 } +type mockChannelCachePubSub struct { + mu sync.Mutex + handlers []func() +} + +func (m *mockChannelCachePubSub) NotifyUpdate(context.Context) error { + m.mu.Lock() + handlers := append([]func(){}, m.handlers...) + m.mu.Unlock() + for _, handler := range handlers { + handler() + } + return nil +} + +func (m *mockChannelCachePubSub) SubscribeUpdates(_ context.Context, handler func()) { + m.mu.Lock() + m.handlers = append(m.handlers, handler) + m.mu.Unlock() +} + func (m *mockChannelAuthCacheInvalidator) InvalidateAuthCacheByKey(_ context.Context, key string) { m.invalidatedKeys = append(m.invalidatedKeys, key) } @@ -192,11 +214,11 @@ func (m *mockChannelAuthCacheInvalidator) InvalidateAuthCacheByGroupID(_ context // --------------------------------------------------------------------------- func newTestChannelService(repo *mockChannelRepository) *ChannelService { - return NewChannelService(repo, nil, nil, nil) + return NewChannelService(repo, nil, nil, nil, nil) } func newTestChannelServiceWithAuth(repo *mockChannelRepository, auth *mockChannelAuthCacheInvalidator) *ChannelService { - return NewChannelService(repo, nil, auth, nil) + return NewChannelService(repo, nil, auth, nil, nil) } // makeStandardRepo returns a repo that serves one active channel with anthropic pricing @@ -1384,6 +1406,42 @@ func TestInvalidateCache(t *testing.T) { require.Equal(t, 2, callCount) // rebuilt } +func TestInvalidateCachePublishesToOtherInstances(t *testing.T) { + cachePubSub := &mockChannelCachePubSub{} + publisher := NewChannelService(&mockChannelRepository{}, nil, nil, nil, cachePubSub) + updated := false + subscriberRepo := &mockChannelRepository{ + listAllFn: func(_ context.Context) ([]Channel, error) { + model := "old-model" + if updated { + model = "new-model" + } + return []Channel{{ + ID: 1, + Status: StatusActive, + GroupIDs: []int64{10}, + ModelPricing: []ChannelModelPricing{{ + ID: 100, + Platform: PlatformAnthropic, + Models: []string{model}, + }}, + }}, nil + }, + getGroupPlatformsFn: func(_ context.Context, _ []int64) (map[int64]string, error) { + return map[int64]string{10: PlatformAnthropic}, nil + }, + } + subscriber := NewChannelService(subscriberRepo, nil, nil, nil, cachePubSub) + + require.NotNil(t, subscriber.GetChannelModelPricing(context.Background(), 10, "old-model")) + require.Nil(t, subscriber.GetChannelModelPricing(context.Background(), 10, "new-model")) + + updated = true + publisher.invalidateCache() + + require.NotNil(t, subscriber.GetChannelModelPricing(context.Background(), 10, "new-model")) +} + // =========================================================================== // 5. CRUD Methods // =========================================================================== @@ -2069,7 +2127,7 @@ func TestMatchingPlatforms(t *testing.T) { {"anthropic returns itself", PlatformAnthropic, []string{PlatformAnthropic}}, {"gemini returns itself", PlatformGemini, []string{PlatformGemini}}, {"openai returns itself", PlatformOpenAI, []string{PlatformOpenAI}}, - {"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek}}, + {"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax}}, } for _, tt := range tests { diff --git a/backend/internal/service/claude_code_validator.go b/backend/internal/service/claude_code_validator.go index c37d444370..814ce35472 100644 --- a/backend/internal/service/claude_code_validator.go +++ b/backend/internal/service/claude_code_validator.go @@ -104,6 +104,12 @@ func (v *ClaudeCodeValidator) Validate(r *http.Request, body map[string]any) boo if isMaxTokensOneHaiku, ok := IsMaxTokensOneHaikuRequestFromContext(r.Context()); ok && isMaxTokensOneHaiku { return true // 绕过 system prompt 检查,UA 已在 Step 1 验证 } + // 探测请求并不总是打到 haiku:CLI 切换模型、刷新上下文用量时会向当前模型发 + // max_tokens=1 的轻量请求,同样不携带 system。UA 已过 Step 1,且 1 个输出 token + // 对滥用者没有价值,故按请求体放行,不再限定模型名。 + if isMaxTokensOneBody(body) { + return true + } // Step 4: messages 路径,进行严格验证 @@ -154,6 +160,24 @@ func isMessagesCountTokensPath(path string) bool { return strings.HasSuffix(path, "/messages/count_tokens") } +// isMaxTokensOneBody 判断请求体是否显式声明 max_tokens=1。 +// 兼容 JSON 反序列化出的 float64 与 ParsedRequest 复用时的 int。 +func isMaxTokensOneBody(body map[string]any) bool { + if body == nil { + return false + } + switch v := body["max_tokens"].(type) { + case float64: + return v == 1 + case int: + return v == 1 + case int64: + return v == 1 + default: + return false + } +} + // hasClaudeCodeSystemPrompt 检查请求是否包含 Claude Code 系统提示词 // 使用字符串相似度匹配(Dice coefficient) func (v *ClaudeCodeValidator) hasClaudeCodeSystemPrompt(body map[string]any) bool { diff --git a/backend/internal/service/claude_code_validator_test.go b/backend/internal/service/claude_code_validator_test.go index bf0e127e82..ffc9d52f46 100644 --- a/backend/internal/service/claude_code_validator_test.go +++ b/backend/internal/service/claude_code_validator_test.go @@ -45,9 +45,10 @@ func TestClaudeCodeValidator_MessagesWithoutProbeStillNeedStrictValidation(t *te req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil) req.Header.Set("User-Agent", "claude-cli/1.2.3 (darwin; arm64)") + // max_tokens=2 不是探测请求:没有 system prompt 仍走严格校验并被拒。 ok := validator.Validate(req, map[string]any{ "model": "claude-haiku-4-5", - "max_tokens": 1, + "max_tokens": 2, }) require.False(t, ok) } @@ -574,3 +575,26 @@ func TestSetGetClaudeCodeVersion(t *testing.T) { ctx = SetClaudeCodeVersion(ctx, "2.1.63") require.Equal(t, "2.1.63", GetClaudeCodeVersion(ctx)) } + +func TestClaudeCodeValidator_MaxTokensOneProbeIsNotLimitedToHaiku(t *testing.T) { + validator := NewClaudeCodeValidator() + + for _, model := range []string{"claude-sonnet-4-5", "claude-opus-4-1", "claude-haiku-4-5"} { + t.Run(model, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil) + req.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)") + // No context flag, no system prompt, no extra headers: the body alone marks the probe. + for _, mt := range []any{float64(1), 1} { + require.True(t, validator.Validate(req, map[string]any{"model": model, "max_tokens": mt}), "max_tokens=%v (%T)", mt, mt) + } + }) + } +} + +func TestClaudeCodeValidator_MaxTokensOneProbeStillRequiresClaudeCodeUA(t *testing.T) { + validator := NewClaudeCodeValidator() + req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/messages", nil) + req.Header.Set("User-Agent", "python-requests/2.32") + + require.False(t, validator.Validate(req, map[string]any{"model": "claude-sonnet-4-5", "max_tokens": 1})) +} diff --git a/backend/internal/service/cn_provider_balance_check_service.go b/backend/internal/service/cn_provider_balance_check_service.go index f880605d68..691f8fd33c 100644 --- a/backend/internal/service/cn_provider_balance_check_service.go +++ b/backend/internal/service/cn_provider_balance_check_service.go @@ -126,9 +126,9 @@ func (s *CNProviderBalanceCheckService) runOnce() { quotaTargets = append(quotaTargets, quotaTarget{id: account.ID, platform: account.Platform}) continue } - // payg 余额探测仅 kimi/deepseek(智谱无公开余额端点,payg 账号 - // 依赖响应式 402/429 处理)。 - if platform != PlatformZhipu && account.Schedulable { + // payg 余额探测仅 kimi/deepseek(智谱 / MiniMax 无公开余额端点, + // payg 账号依赖响应式 402/429 处理)。 + if platform != PlatformZhipu && platform != PlatformMiniMax && account.Schedulable { paygTargets = append(paygTargets, account) } } @@ -141,13 +141,15 @@ func (s *CNProviderBalanceCheckService) runOnce() { } collect(platform, accounts) } - // 智谱无余额端点,仅进额度探测。 + // 智谱 / MiniMax 无余额端点,仅进额度探测。 if s.quotaService != nil { - accounts, err := s.accountRepo.ListByPlatform(context.Background(), PlatformZhipu) - if err != nil { - log.Printf("[CNBalance] list %s accounts failed: %v", PlatformZhipu, err) - } else { - collect(PlatformZhipu, accounts) + for _, platform := range []string{PlatformZhipu, PlatformMiniMax} { + accounts, err := s.accountRepo.ListByPlatform(context.Background(), platform) + if err != nil { + log.Printf("[CNBalance] list %s accounts failed: %v", platform, err) + continue + } + collect(platform, accounts) } } diff --git a/backend/internal/service/cn_provider_balance_check_service_test.go b/backend/internal/service/cn_provider_balance_check_service_test.go index 59eba6d311..825e66ff63 100644 --- a/backend/internal/service/cn_provider_balance_check_service_test.go +++ b/backend/internal/service/cn_provider_balance_check_service_test.go @@ -49,10 +49,13 @@ func TestCNProviderBalanceCheckRunOnceProbesCodingPlanQuota(t *testing.T) { Credentials: map[string]any{"account_mode": "coding"}} zhipuCoding := Account{ID: 4, Platform: PlatformZhipu, Type: AccountTypeAPIKey, Status: StatusActive, Credentials: map[string]any{"account_mode": "coding"}} + minimaxCoding := Account{ID: 5, Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Status: StatusActive, + Credentials: map[string]any{"account_mode": "coding"}} repo := &fakeCNCheckRepo{byPlatform: map[string][]Account{ - PlatformKimi: {kimiActive, kimiPaused, kimiInactive}, - PlatformZhipu: {zhipuCoding}, + PlatformKimi: {kimiActive, kimiPaused, kimiInactive}, + PlatformZhipu: {zhipuCoding}, + PlatformMiniMax: {minimaxCoding}, }} prober := &fakeCNQuotaProber{} svc := &CNProviderBalanceCheckService{ @@ -63,7 +66,7 @@ func TestCNProviderBalanceCheckRunOnceProbesCodingPlanQuota(t *testing.T) { svc.runOnce() - require.ElementsMatch(t, []int64{1, 2, 4}, prober.probed) + require.ElementsMatch(t, []int64{1, 2, 4, 5}, prober.probed) } // runOnceZhipuQuota 在 quotaService 缺失时安全跳过(Start 门控不启动的老部署路径)。 diff --git a/backend/internal/service/cn_provider_foraccount_test.go b/backend/internal/service/cn_provider_foraccount_test.go index 664da505fb..17aa7b7a1e 100644 --- a/backend/internal/service/cn_provider_foraccount_test.go +++ b/backend/internal/service/cn_provider_foraccount_test.go @@ -46,6 +46,7 @@ func TestValidateCodingPlanAccount_Matrix(t *testing.T) { {name: "payg has no quota endpoint", account: paygAccount(PlatformKimi), wantReason: "CN_QUOTA_NOT_CODING_PLAN"}, {name: "kimi coding ok", account: codingAccount(PlatformKimi)}, {name: "zhipu coding ok", account: codingAccount(PlatformZhipu)}, + {name: "minimax coding ok", account: codingAccount(PlatformMiniMax)}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/backend/internal/service/cn_provider_quota_service.go b/backend/internal/service/cn_provider_quota_service.go index 74c1f91fd6..a78102c306 100644 --- a/backend/internal/service/cn_provider_quota_service.go +++ b/backend/internal/service/cn_provider_quota_service.go @@ -129,8 +129,8 @@ func (s *CNProviderQuotaService) QueryUsageForAccount(ctx context.Context, accou func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) { provider := account.GetCodingPlanProvider() - if provider != PlatformKimi && provider != PlatformZhipu { - return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a kimi/zhipu coding plan account") + if provider != PlatformKimi && provider != PlatformZhipu && provider != PlatformMiniMax { + return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a kimi/zhipu/minimax coding plan account") } apiKey := strings.TrimSpace(account.GetCNAPIKey()) @@ -158,6 +158,9 @@ func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, accou if zhipuOrg != "" { targetURL += "?type=2" } + case PlatformMiniMax: + targetURL = minimaxQuotaURL(baseURL) + authHeader = "Bearer " + apiKey } // 探测发起前过出站 URL 安全策略(与网关转发/Grok 探测同一套校验): @@ -177,7 +180,7 @@ func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, accou } req.Header.Set("Authorization", authHeader) req.Header.Set("Accept", "application/json") - if provider == PlatformZhipu { + if provider == PlatformZhipu || provider == PlatformMiniMax { req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept-Language", "en-US,en") if zhipuOrg != "" { @@ -234,6 +237,17 @@ func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, accou case PlatformZhipu: tiers = parseZhipuTokenTiers(gjson.GetBytes(bodyBytes, "data")) result.PlanLevel = strings.TrimSpace(gjson.GetBytes(bodyBytes, "data.level").String()) + case PlatformMiniMax: + if status := gjson.GetBytes(bodyBytes, "base_resp.status_code"); status.Exists() && status.Int() != 0 { + msg := strings.TrimSpace(gjson.GetBytes(bodyBytes, "base_resp.status_msg").String()) + if msg == "" { + msg = "unknown minimax quota error" + } + result.Error = fmt.Sprintf("API error (%d): %s", status.Int(), msg) + return result, nil + } + tiers = parseMiniMaxUsageTiers(bodyBytes) + result.PlanLevel = strings.TrimSpace(gjson.GetBytes(bodyBytes, "current_subscribe_title").String()) } result.Tiers = tiers result.Success = true @@ -305,6 +319,16 @@ func kimiQuotaURL(baseURL string) string { return base + "/v1/usages" } +// minimaxQuotaURL 根据推理域名选择 Token Plan / Coding Plan 额度主机。 +// 官方 FAQ 写 www.minimax.io / www.minimaxi.com,实际以 Bearer Key 打 api.*。 +// 国际站 api.minimax.io;国内站 api.minimaxi.com(含 api.minimax.com 与自定义回落)。 +func minimaxQuotaURL(baseURL string) string { + if strings.Contains(strings.ToLower(baseURL), "minimax.io") { + return "https://api.minimax.io/v1/api/openplatform/coding_plan/remains" + } + return "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains" +} + func zhipuQuotaHost(baseURL string) string { switch u := strings.ToLower(baseURL); { case strings.Contains(u, "bigmodel.cn"): @@ -372,6 +396,71 @@ func parseKimiUsageTiers(body []byte) []CNQuotaTier { return tiers } +// parseMiniMaxUsageTiers 解析 MiniMax Token Plan / Coding Plan remains 响应。 +// +// 只取 model_name == "general"(编程套餐),跳过 video。字段是剩余百分比, +// 展示已用 = 100 - remaining: +// - 5h:current_interval_remaining_percent + end_time +// - 周限额:仅 current_weekly_status == 1 时用 current_weekly_remaining_percent + weekly_end_time +func parseMiniMaxUsageTiers(body []byte) []CNQuotaTier { + remains := gjson.GetBytes(body, "model_remains") + if !remains.IsArray() { + return nil + } + var general gjson.Result + remains.ForEach(func(_, item gjson.Result) bool { + if strings.EqualFold(strings.TrimSpace(item.Get("model_name").String()), "general") { + general = item + return false + } + return true + }) + if !general.Exists() { + return nil + } + + var tiers []CNQuotaTier + if remaining, ok := cnParseF64(general.Get("current_interval_remaining_percent").Value()); ok { + used := 100 - remaining + if used < 0 { + used = 0 + } + tiers = append(tiers, CNQuotaTier{ + Window: "5h", + UsedPercent: used, + ResetAt: minimaxResetTime(general.Get("end_time")), + }) + } + if general.Get("current_weekly_status").Int() == 1 { + if remaining, ok := cnParseF64(general.Get("current_weekly_remaining_percent").Value()); ok { + used := 100 - remaining + if used < 0 { + used = 0 + } + tiers = append(tiers, CNQuotaTier{ + Window: "weekly", + UsedPercent: used, + ResetAt: minimaxResetTime(general.Get("weekly_end_time")), + }) + } + } + return tiers +} + +func minimaxResetTime(v gjson.Result) string { + if !v.Exists() { + return "" + } + ms := v.Int() + if ms <= 0 { + return cnNormalizeResetTime(v.Value()) + } + if ms < 1_000_000_000_000 { + return time.Unix(ms, 0).UTC().Format(time.RFC3339) + } + return time.UnixMilli(ms).UTC().Format(time.RFC3339) +} + // cnZhipuWindow 标识智谱 TOKENS_LIMIT 条目所属窗口。 type cnZhipuWindow int diff --git a/backend/internal/service/cn_providers_test.go b/backend/internal/service/cn_providers_test.go index 84598f3ba7..8f9e7b8996 100644 --- a/backend/internal/service/cn_providers_test.go +++ b/backend/internal/service/cn_providers_test.go @@ -229,6 +229,88 @@ func TestKimiQuotaURL(t *testing.T) { require.Equal(t, "https://api.kimi.com/coding/v1/usages", kimiQuotaURL("https://api.kimi.com/coding/v1/")) } +func TestMiniMaxQuotaURL(t *testing.T) { + t.Parallel() + require.Equal(t, "https://api.minimax.io/v1/api/openplatform/coding_plan/remains", + minimaxQuotaURL("https://api.minimax.io/v1")) + require.Equal(t, "https://api.minimax.io/v1/api/openplatform/coding_plan/remains", + minimaxQuotaURL("https://api.minimax.io/anthropic")) + require.Equal(t, "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains", + minimaxQuotaURL("https://api.minimaxi.com/v1")) + require.Equal(t, "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains", + minimaxQuotaURL("https://api.minimax.com/v1")) + require.Equal(t, "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains", + minimaxQuotaURL("https://custom.example.com")) +} + +func TestParseMiniMaxUsageTiers(t *testing.T) { + t.Parallel() + endMs := int64(1_700_000_000_000) + weeklyEndMs := endMs + 7*24*60*60*1000 + body := []byte(`{ + "base_resp": {"status_code": 0}, + "current_subscribe_title": "Max", + "model_remains": [ + {"model_name": "video", "current_interval_remaining_percent": 10}, + { + "model_name": "general", + "current_interval_remaining_percent": 25, + "end_time": 1700000000000, + "current_weekly_status": 1, + "current_weekly_remaining_percent": 40, + "weekly_end_time": 1700604800000 + } + ] + }`) + tiers := parseMiniMaxUsageTiers(body) + require.Len(t, tiers, 2) + require.Equal(t, "5h", tiers[0].Window) + require.InDelta(t, 75, tiers[0].UsedPercent, 1e-9) + require.Equal(t, time.UnixMilli(endMs).UTC().Format(time.RFC3339), tiers[0].ResetAt) + require.Equal(t, "weekly", tiers[1].Window) + require.InDelta(t, 60, tiers[1].UsedPercent, 1e-9) + require.Equal(t, time.UnixMilli(weeklyEndMs).UTC().Format(time.RFC3339), tiers[1].ResetAt) + + noWeekly := parseMiniMaxUsageTiers([]byte(`{ + "model_remains": [{ + "model_name": "general", + "current_interval_remaining_percent": 80, + "end_time": 1700000000000, + "current_weekly_status": 3, + "current_weekly_remaining_percent": 10 + }] + }`)) + require.Len(t, noWeekly, 1) + require.Equal(t, "5h", noWeekly[0].Window) + require.InDelta(t, 20, noWeekly[0].UsedPercent, 1e-9) + + require.Nil(t, parseMiniMaxUsageTiers([]byte(`{"model_remains":[{"model_name":"video","current_interval_remaining_percent":5}]}`))) +} + +func TestGetCodingPlanProvider_MiniMax(t *testing.T) { + t.Parallel() + coding := &Account{Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "account_mode": AccountModeCoding, + "base_url": "https://api.minimaxi.com/v1", + }} + require.Equal(t, PlatformMiniMax, coding.GetCodingPlanProvider()) + intl := &Account{Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "account_mode": AccountModeCoding, + "base_url": "https://api.minimax.io/anthropic", + }} + require.Equal(t, PlatformMiniMax, intl.GetCodingPlanProvider()) + // 未配 base_url 时走国内站默认域名,仍能识别官方额度端点。 + require.Equal(t, PlatformMiniMax, (&Account{Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "account_mode": AccountModeCoding, + }}).GetCodingPlanProvider()) + require.Empty(t, (&Account{Platform: PlatformMiniMax, Credentials: map[string]any{"account_mode": AccountModePayG}}).GetCodingPlanProvider()) + // 自定义中转不得把第三方 Key 发往官方额度端点。 + require.Empty(t, (&Account{Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "account_mode": AccountModeCoding, + "base_url": "https://relay.example.com/v1", + }}).GetCodingPlanProvider()) +} + // TestCNBalanceURL Kimi 固定端点;DeepSeek 基于 base_url 拼接。 func TestCNBalanceURL(t *testing.T) { t.Parallel() @@ -309,6 +391,28 @@ func TestEvaluateAccountSchedulingThreshold_KimiCodingPlan(t *testing.T) { require.True(t, reset.Equal(*decision.Until)) } +func TestEvaluateAccountSchedulingThreshold_MiniMaxCodingPlan(t *testing.T) { + t.Parallel() + now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC) + reset := now.Add(3 * time.Hour) + account := &Account{ + Platform: PlatformMiniMax, + Extra: map[string]any{ + "minimax_5h_used_percent": 90.0, + "minimax_5h_reset_at": reset.Format(time.RFC3339), + "minimax_weekly_used_percent": 30.0, + "minimax_weekly_reset_at": now.Add(7 * 24 * time.Hour).Format(time.RFC3339), + }, + } + decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformMiniMax: 80}, now) + require.True(t, decision.ShouldPause) + require.Equal(t, PlatformMiniMax, decision.Platform) + require.Equal(t, "5h", decision.Window) + require.InDelta(t, 90.0, decision.UsedPercent, 1e-9) + require.NotNil(t, decision.Until) + require.True(t, reset.Equal(*decision.Until)) +} + // TestEvaluateAccountSchedulingThreshold_CNWindowResetSkipped 窗口已重置(reset<=now) // 或用量低于阈值 → 不停调(candidateMatchesThreshold 要求 until.After(now))。 func TestEvaluateAccountSchedulingThreshold_CNWindowResetSkipped(t *testing.T) { @@ -451,6 +555,8 @@ func TestBuildUpstreamModelsRequest_CNProviders(t *testing.T) { {"zhipu default", PlatformZhipu, "", "https://open.bigmodel.cn/api/paas/v4/models"}, {"zhipu coding", PlatformZhipu, AccountModeCoding, "https://open.bigmodel.cn/api/coding/paas/v4/models"}, {"deepseek", PlatformDeepseek, "", "https://api.deepseek.com/v1/models"}, + {"minimax default", PlatformMiniMax, "", "https://api.minimaxi.com/v1/models"}, + {"minimax coding", PlatformMiniMax, AccountModeCoding, "https://api.minimaxi.com/v1/models"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -487,7 +593,9 @@ func TestGetAPIProtocol(t *testing.T) { require.Equal(t, APIProtocolAnthropic, mk(PlatformDeepseek, APIProtocolAnthropic).GetAPIProtocol()) require.Equal(t, APIProtocolResponses, mk(PlatformDeepseek, APIProtocolResponses).GetAPIProtocol()) require.Equal(t, APIProtocolResponses, mk(PlatformKimi, APIProtocolResponses).GetAPIProtocol()) + require.Equal(t, APIProtocolResponses, mk(PlatformMiniMax, APIProtocolResponses).GetAPIProtocol()) require.Equal(t, APIProtocolAdaptive, mk(PlatformKimi, APIProtocolAdaptive).GetAPIProtocol()) + require.Equal(t, APIProtocolAdaptive, mk(PlatformMiniMax, APIProtocolAdaptive).GetAPIProtocol()) require.Equal(t, APIProtocolAdaptive, mk(PlatformZhipu, APIProtocolAdaptive).GetAPIProtocol()) require.Equal(t, APIProtocolAdaptive, mk(PlatformDeepseek, APIProtocolAdaptive).GetAPIProtocol()) require.Equal(t, APIProtocolChatCompletions, mk(PlatformZhipu, APIProtocolResponses).GetAPIProtocol(), "zhipu 无 responses 端点") @@ -500,6 +608,7 @@ func TestSupportsNativeCNResponses(t *testing.T) { require.True(t, (&Account{Platform: PlatformDeepseek}).SupportsNativeCNResponses()) require.True(t, (&Account{Platform: PlatformKimi}).SupportsNativeCNResponses()) require.True(t, (&Account{Platform: PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}).SupportsNativeCNResponses()) + require.True(t, (&Account{Platform: PlatformMiniMax}).SupportsNativeCNResponses()) require.False(t, (&Account{Platform: PlatformZhipu}).SupportsNativeCNResponses()) require.False(t, (&Account{Platform: PlatformOpenAI}).SupportsNativeCNResponses()) } @@ -520,6 +629,8 @@ func TestAdaptiveProtocolBaseURLs(t *testing.T) { {"zhipu payg", PlatformZhipu, AccountModePayG, DefaultZhipuPayGBaseURL, DefaultZhipuAnthropicBaseURL, DefaultZhipuPayGBaseURL}, {"zhipu coding", PlatformZhipu, AccountModeCoding, DefaultZhipuCodingBaseURL, DefaultZhipuAnthropicBaseURL, DefaultZhipuCodingBaseURL}, {"deepseek", PlatformDeepseek, AccountModePayG, DefaultDeepseekBaseURL, DefaultDeepseekAnthropicBaseURL, DefaultDeepseekBaseURL}, + {"minimax payg", PlatformMiniMax, AccountModePayG, DefaultMiniMaxBaseURL, DefaultMiniMaxAnthropicBaseURL, DefaultMiniMaxBaseURL}, + {"minimax coding", PlatformMiniMax, AccountModeCoding, DefaultMiniMaxBaseURL, DefaultMiniMaxAnthropicBaseURL, DefaultMiniMaxBaseURL}, } for _, tc := range cases { @@ -577,6 +688,10 @@ func TestAnthropicProtocolBaseURL(t *testing.T) { Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_protocol": APIProtocolAnthropic}, }).GetAnthropicProtocolBaseURL()) + require.Equal(t, "https://api.minimaxi.com/anthropic", (&Account{ + Platform: PlatformMiniMax, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAnthropic}, + }).GetAnthropicProtocolBaseURL()) // 凭证 base_url 覆盖默认值 require.Equal(t, "https://custom.example.com/anthropic", (&Account{ diff --git a/backend/internal/service/composite_platform.go b/backend/internal/service/composite_platform.go index c7736f9582..fc44428039 100644 --- a/backend/internal/service/composite_platform.go +++ b/backend/internal/service/composite_platform.go @@ -112,6 +112,8 @@ func DetectModelPlatform(model string) (string, bool) { return PlatformZhipu, true case "deepseek": return PlatformDeepseek, true + case "minimax": + return PlatformMiniMax, true } if rest != "" { normalized = strings.TrimPrefix(rest, "models/") @@ -148,6 +150,11 @@ func DetectModelPlatform(model string) (string, bool) { return PlatformZhipu, true case strings.HasPrefix(normalized, "deepseek-"): return PlatformDeepseek, true + case strings.HasPrefix(normalized, "minimax-"), + strings.HasPrefix(normalized, "abab5"), + strings.HasPrefix(normalized, "abab6"), + strings.HasPrefix(normalized, "abab7"): + return PlatformMiniMax, true default: return "", false } @@ -195,7 +202,7 @@ func (s *GatewayService) resolveCompositeRouteDecision(ctx context.Context, grou func isConcreteRequestPlatform(platform string) bool { switch platform { case PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok, - PlatformKimi, PlatformZhipu, PlatformDeepseek: + PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: return true default: return false diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go index bff5928024..2ca27c5951 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -176,6 +176,11 @@ func TestDetectModelPlatform(t *testing.T) { {name: "moonshot prefix", model: "moonshot/moonshot-v1-32k", platform: PlatformKimi, ok: true}, {name: "zhipu", model: "glm-5.2", platform: PlatformZhipu, ok: true}, {name: "deepseek", model: "deepseek-v4-pro", platform: PlatformDeepseek, ok: true}, + {name: "minimax", model: "MiniMax-M3", platform: PlatformMiniMax, ok: true}, + {name: "minimax prefix", model: "minimax/MiniMax-M2.5", platform: PlatformMiniMax, ok: true}, + {name: "abab legacy", model: "abab6.5-chat", platform: PlatformMiniMax, ok: true}, + {name: "abab7 legacy", model: "abab7-chat-preview", platform: PlatformMiniMax, ok: true}, + {name: "abab unrelated namespace", model: "abab-other", ok: false}, {name: "unknown k3 alias", model: "k3-preview", ok: false}, {name: "unknown", model: "llama-4-maverick", ok: false}, } @@ -211,13 +216,13 @@ func TestCompositeGroupSchedulerHasAllCanonicalPlatformBuckets(t *testing.T) { platforms = append(platforms, platform) } require.ElementsMatch(t, - []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek}, + []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax}, platforms, ) } func TestCompositeConcretePlatformsIncludeCNProviders(t *testing.T) { - for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { require.True(t, isConcreteRequestPlatform(platform)) require.True(t, canCopyAccountsFromGroupPlatform(PlatformComposite, platform)) } diff --git a/backend/internal/service/content_moderation_runtime_cache_test.go b/backend/internal/service/content_moderation_runtime_cache_test.go index 2b34d1fa57..18fa2b66ea 100644 --- a/backend/internal/service/content_moderation_runtime_cache_test.go +++ b/backend/internal/service/content_moderation_runtime_cache_test.go @@ -276,10 +276,15 @@ func TestContentModerationRuntimeSnapshotRefreshFailureKeepsStaleConfig(t *testi decision, err = svc.Check(context.Background(), input) require.NoError(t, err) require.True(t, decision.Blocked) + // Windows 单调时钟步进约 0.5ms,两次相邻调用可能读到同一时刻,需反复调用才能观察到 TTL 过期。 require.Eventually(t, func() bool { + _, _ = svc.Check(context.Background(), input) _, calls := repo.calls() return calls >= 2 }, time.Second, time.Millisecond) + decision, err = svc.Check(context.Background(), input) + require.NoError(t, err) + require.True(t, decision.Blocked) } func TestContentModerationRuntimeSnapshotRefreshFailureBacksOff(t *testing.T) { diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 50de7a05d1..3e0cb28c5b 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -47,6 +47,7 @@ const ( PlatformKimi = domain.PlatformKimi PlatformZhipu = domain.PlatformZhipu PlatformDeepseek = domain.PlatformDeepseek + PlatformMiniMax = domain.PlatformMiniMax PlatformComposite = domain.PlatformComposite // PlatformKiro is retained for unsupported-platform threshold tests and legacy // account rows. Scheduling-threshold evaluation never pauses kiro accounts. @@ -75,6 +76,8 @@ const ( DefaultZhipuPayGBaseURL = "https://open.bigmodel.cn/api/paas/v4" DefaultZhipuCodingBaseURL = "https://open.bigmodel.cn/api/coding/paas/v4" DefaultDeepseekBaseURL = "https://api.deepseek.com" + // MiniMax 按量付费与 Coding/Token Plan 共用推理域名,靠 API Key 区分套餐。 + DefaultMiniMaxBaseURL = "https://api.minimaxi.com/v1" ) // 国产供应商 Anthropic 协议端点的默认 base_url(上游路径为 {base}/v1/messages)。 @@ -84,12 +87,13 @@ const ( DefaultKimiCodingAnthropicBaseURL = "https://api.kimi.com/coding" DefaultZhipuAnthropicBaseURL = "https://open.bigmodel.cn/api/anthropic" DefaultDeepseekAnthropicBaseURL = "https://api.deepseek.com/anthropic" + DefaultMiniMaxAnthropicBaseURL = "https://api.minimaxi.com/anthropic" ) -// IsCNProvider 报告 platform 是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)。 +// IsCNProvider 报告 platform 是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek/minimax)。 func IsCNProvider(platform string) bool { switch platform { - case PlatformKimi, PlatformZhipu, PlatformDeepseek: + case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: return true default: return false @@ -108,17 +112,19 @@ var AllowedQuotaPlatforms = []string{ PlatformKimi, PlatformZhipu, PlatformDeepseek, + PlatformMiniMax, } // AllowedSchedulingThresholdPlatforms 是允许设置账号自动停调阈值的平台列表。 -// openai/anthropic/grok 有原生用量窗口;kimi/zhipu 的 Coding Plan 同样暴露 5h/weekly -// 滚动窗口,纳入阈值评估。deepseek 为余额型,走余额检测而非阈值。 +// openai/anthropic/grok 有原生用量窗口;kimi/zhipu/minimax 的 Coding Plan 同样暴露 +// 5h/weekly 滚动窗口,纳入阈值评估。deepseek 为余额型,走余额检测而非阈值。 var AllowedSchedulingThresholdPlatforms = []string{ PlatformOpenAI, PlatformAnthropic, PlatformGrok, PlatformKimi, PlatformZhipu, + PlatformMiniMax, } // IsAllowedQuotaPlatform 报告 s 是否为合法的 quota platform 标识。 @@ -484,6 +490,10 @@ const ( // fail-closed (only the literal "true" enables it). Admin endpoints always // keep the full snapshots regardless of this flag. SettingKeyChannelMonitorShowQuota = "channel_monitor_show_quota" + // SettingKeyChannelMonitorHideUserRanking hides the user ranking tab and + // /users payload from non-admin channel-monitor v2 viewers. + // Default false (keep the current ranking tab). Admin endpoints always keep it. + SettingKeyChannelMonitorHideUserRanking = "channel_monitor_hide_user_ranking" // SettingKeyGrokDefaultTextModel is the fallback Grok text model for empty // request models and built-in Grok aliases (e.g. "grok" → this id). Default grok-4.5. diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index 10455f0581..d9393e5a0c 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -278,6 +278,7 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput( return &ForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: *usage, Model: input.OriginalModel, UpstreamModel: input.RequestModel, @@ -324,6 +325,10 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( body = sanitized } + // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的 + // base 取值同源(GetBaseURL),详见 helper 注释。 + body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) if err != nil { return nil, nil, err @@ -347,7 +352,9 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( req.Header.Del("x-api-key") req.Header.Del("x-goog-api-key") req.Header.Del("cookie") - setAnthropicAPIKeyAuthHeader(req.Header, account, token) + // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 + // targetURL 的 base 取值),其余保持 extra/default 行为。 + setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) if getHeaderRaw(req.Header, "content-type") == "" { setHeaderRaw(req.Header, "content-type", "application/json") diff --git a/backend/internal/service/gateway_bedrock.go b/backend/internal/service/gateway_bedrock.go index 5c46f25971..80e2afcfac 100644 --- a/backend/internal/service/gateway_bedrock.go +++ b/backend/internal/service/gateway_bedrock.go @@ -159,6 +159,7 @@ func (s *GatewayService) forwardBedrock( return &ForwardResult{ RequestID: resp.Header.Get("x-amzn-requestid"), + UpstreamHeaders: resp.Header, Usage: *usage, Model: reqModel, UpstreamModel: mappedModel, diff --git a/backend/internal/service/gateway_billing_header.go b/backend/internal/service/gateway_billing_header.go index 5dd0474780..dd1f16b2c7 100644 --- a/backend/internal/service/gateway_billing_header.go +++ b/backend/internal/service/gateway_billing_header.go @@ -5,16 +5,30 @@ import ( "regexp" "strings" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) -// ccVersionInBillingRe matches the semver part of cc_version (X.Y.Z), preserving -// the trailing message-derived suffix (e.g. ".c02") if present. +// ccVersionInBillingRe matches the semver part of cc_version (X.Y.Z). var ccVersionInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+`) +var ccVersionWithFingerprintInBillingRe = regexp.MustCompile(`cc_version=\d+\.\d+\.\d+\.[0-9a-fA-F]{3}\b`) + +// OAuth mimicry forces the built-in User-Agent after applying account fingerprints. +func effectiveBillingUserAgent(tokenType string, mimicClaudeCode bool, fingerprint *Fingerprint) string { + if tokenType == "oauth" && mimicClaudeCode { + return claude.DefaultHeaders["User-Agent"] + } + if fingerprint == nil { + return "" + } + return fingerprint.UserAgent +} + // syncBillingHeaderVersion rewrites cc_version in x-anthropic-billing-header // system text blocks to match the version extracted from userAgent. +// Recompute any recognized fingerprint suffix because its input includes the version. // Only touches system array blocks whose text starts with "x-anthropic-billing-header". func syncBillingHeaderVersion(body []byte, userAgent string) []byte { version := ExtractCLIVersion(userAgent) @@ -33,7 +47,9 @@ func syncBillingHeaderVersion(body []byte, userAgent string) []byte { text := item.Get("text") if text.Exists() && text.Type == gjson.String && strings.HasPrefix(text.String(), "x-anthropic-billing-header") { - newText := ccVersionInBillingRe.ReplaceAllString(text.String(), replacement) + fingerprintedReplacement := replacement + "." + computeClaudeCodeFingerprint(body, version) + newText := ccVersionWithFingerprintInBillingRe.ReplaceAllString(text.String(), fingerprintedReplacement) + newText = ccVersionInBillingRe.ReplaceAllString(newText, replacement) if newText != text.String() { if updated, err := sjson.SetBytes(body, fmt.Sprintf("system.%d.text", idx), newText); err == nil { body = updated diff --git a/backend/internal/service/gateway_billing_header_test.go b/backend/internal/service/gateway_billing_header_test.go index 0d5930996a..6de6b4bfec 100644 --- a/backend/internal/service/gateway_billing_header_test.go +++ b/backend/internal/service/gateway_billing_header_test.go @@ -1,9 +1,20 @@ package service import ( + "context" + "io" + "net/http" + "net/http/httptest" "testing" + "time" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) func TestSyncBillingHeaderVersion(t *testing.T) { @@ -15,10 +26,10 @@ func TestSyncBillingHeaderVersion(t *testing.T) { unchanged bool // expect body to remain the same }{ { - name: "replaces cc_version preserving message-derived suffix", + name: "replaces cc_version and recomputes message-derived suffix", body: `{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81.df2; cc_entrypoint=cli; cch=00000;"},{"type":"text","text":"You are Claude Code.","cache_control":{"type":"ephemeral"}}],"messages":[]}`, userAgent: "claude-cli/2.1.22 (external, cli)", - wantSub: "cc_version=2.1.22.df2", + wantSub: "cc_version=2.1.22." + computeClaudeCodeFingerprint([]byte(`{"messages":[]}`), "2.1.22"), }, { name: "no billing header in system", @@ -65,3 +76,78 @@ func TestSyncBillingHeaderVersion(t *testing.T) { }) } } + +func TestSyncBillingHeaderVersion_RecomputesSuffixAndIsIdempotent(t *testing.T) { + body := []byte(`{"system":[{"type":"text","text":"x-anthropic-billing-header: cc_version=2.1.81.df2; cc_entrypoint=cli;"}],"messages":[{"role":"user","content":"hello world"}]}`) + version := "2.1.22" + result := syncBillingHeaderVersion(body, "claude-cli/"+version) + require.Contains(t, gjson.GetBytes(result, "system.0.text").String(), + "cc_version="+version+"."+computeClaudeCodeFingerprint(body, version)+";") + require.Equal(t, string(result), string(syncBillingHeaderVersion(result, "claude-cli/"+version))) + require.JSONEq(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(result, "messages").Raw) +} + +func TestBuildOAuthRequest_BillingMatchesWireUserAgent(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, endpoint := range []string{"messages", "count_tokens"} { + for _, tc := range []struct { + name string + mimic bool + identity bool + disableFP bool + }{ + {name: "mimic_overrides_cached_version", mimic: true, identity: true}, + {name: "mimic_without_identity", mimic: true}, + {name: "mimic_with_fingerprint_disabled", mimic: true, identity: true, disableFP: true}, + {name: "passthrough_uses_cached_version", identity: true}, + } { + t.Run(endpoint+"/"+tc.name, func(t *testing.T) { + resetGatewayForwardingSettingsCacheForTest(t) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + body := []byte(`{"model":"claude-haiku-4-5","system":[{"type":"text","text":""}],"messages":[{"role":"user","content":"hello world"}]}`) + billing, err := buildBillingAttributionText(body, "2.1.81") + require.NoError(t, err) + body, err = sjson.SetBytes(body, "system.0.text", billing) + require.NoError(t, err) + + cfg := &config.Config{} + svc := &GatewayService{cfg: cfg} + cachedUA := "claude-cli/2.9.0 (external, cli)" + if tc.identity { + svc.identityService = NewIdentityService(&stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: cachedUA, ClientID: "test-client", UpdatedAt: time.Now().Unix(), + }}) + } + if tc.disableFP { + svc.settingService = NewSettingService(&gatewayTTLSettingRepo{data: map[string]string{ + SettingKeyEnableFingerprintUnification: "false", + }}, cfg) + } + account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeOAuth} + var req *http.Request + var wireBody []byte + if endpoint == "messages" { + req, wireBody, err = svc.buildUpstreamRequest(context.Background(), c, account, + body, "test-token", "oauth", "claude-haiku-4-5", false, tc.mimic) + } else { + req, wireBody, err = svc.buildCountTokensRequest(context.Background(), c, account, + body, "test-token", "oauth", "claude-haiku-4-5", tc.mimic) + } + require.NoError(t, err) + defer func() { require.NoError(t, req.Body.Close()) }() + wantUA := cachedUA + if tc.mimic { + wantUA = claude.DefaultHeaders["User-Agent"] + } + require.Equal(t, wantUA, getHeaderRaw(req.Header, "User-Agent")) + version := ExtractCLIVersion(wantUA) + require.Contains(t, gjson.GetBytes(wireBody, "system.0.text").String(), + "cc_version="+version+"."+computeClaudeCodeFingerprint(wireBody, version)+";") + actualBody, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.Equal(t, wireBody, actualBody) + }) + } + } +} diff --git a/backend/internal/service/gateway_channel_restriction_load_aware_test.go b/backend/internal/service/gateway_channel_restriction_load_aware_test.go new file mode 100644 index 0000000000..706140fe63 --- /dev/null +++ b/backend/internal/service/gateway_channel_restriction_load_aware_test.go @@ -0,0 +1,165 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + "github.com/stretchr/testify/require" +) + +// loadAwareRestrictionFixture 构造走负载感知路径(load_batch 开启 + 并发服务存在)的 +// GatewayService:分组 10 绑定 upstream 计费基准的渠道,定价列表只允许 claude-sonnet-4-6。 +// 账号 1 把 claude-fable-5-1 映射为自身(不在定价列表),账号 2 映射为 claude-sonnet-4-6(在列表)。 +type loadAwareRestrictionFixture struct { + svc *GatewayService + ctx context.Context + groupID int64 + cache *mockGatewayCacheForPlatform + concurrencyCache *mockConcurrencyCache +} + +func newLoadAwareRestrictionFixture(t *testing.T, restrict bool, sessionBindings map[string]int64, routing map[string][]int64) *loadAwareRestrictionFixture { + t.Helper() + + groupID := int64(10) + ch := Channel{ + ID: 1, + Status: StatusActive, + GroupIDs: []int64{groupID}, + RestrictModels: restrict, + BillingModelSource: BillingModelSourceUpstream, + ModelPricing: []ChannelModelPricing{ + {Platform: PlatformAnthropic, Models: []string{"claude-sonnet-4-6"}}, + }, + } + channelSvc := newTestChannelService(makeStandardRepo(ch, map[int64]string{groupID: PlatformAnthropic})) + + accountRepo := &mockAccountRepoForPlatform{ + accounts: []Account{ + { + ID: 1, Platform: PlatformAnthropic, Priority: 1, Status: StatusActive, Schedulable: true, Concurrency: 5, + Credentials: map[string]any{ + "model_mapping": map[string]any{"claude-fable-5-1": "claude-fable-5-1"}, + }, + }, + { + ID: 2, Platform: PlatformAnthropic, Priority: 2, Status: StatusActive, Schedulable: true, Concurrency: 5, + Credentials: map[string]any{ + "model_mapping": map[string]any{"claude-fable-5-1": "claude-sonnet-4-6"}, + }, + }, + }, + accountsByID: map[int64]*Account{}, + } + for i := range accountRepo.accounts { + accountRepo.accountsByID[accountRepo.accounts[i].ID] = &accountRepo.accounts[i] + } + + group := &Group{ + ID: groupID, + Platform: PlatformAnthropic, + Status: StatusActive, + Hydrated: true, + ModelRoutingEnabled: len(routing) > 0, + ModelRouting: routing, + } + groupRepo := &mockGroupRepoForGateway{groups: map[int64]*Group{groupID: group}} + + cfg := testConfig() + cfg.Gateway.Scheduling.LoadBatchEnabled = true + cache := &mockGatewayCacheForPlatform{sessionBindings: sessionBindings} + concurrencyCache := &mockConcurrencyCache{} + + svc := &GatewayService{ + accountRepo: accountRepo, + groupRepo: groupRepo, + channelService: channelSvc, + cache: cache, + cfg: cfg, + concurrencyService: NewConcurrencyService(concurrencyCache), + } + + return &loadAwareRestrictionFixture{ + svc: svc, + ctx: context.WithValue(context.Background(), ctxkey.Group, group), + groupID: groupID, + cache: cache, + concurrencyCache: concurrencyCache, + } +} + +func TestSelectAccountWithLoadAwareness_UpstreamRestrictionSkipsDisallowedAccount(t *testing.T) { + t.Parallel() + + f := newLoadAwareRestrictionFixture(t, true, nil, nil) + result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "", "claude-fable-5-1", nil, "", 0) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(2), result.Account.ID, "上游模型不在渠道定价列表的高优先级账号必须被跳过") + require.Equal(t, 1, f.concurrencyCache.loadBatchCalls, "应经过 Layer 2 负载感知选择") +} + +func TestSelectAccountWithLoadAwareness_UpstreamRestrictionRejectsWhenAllAccountsDisallowed(t *testing.T) { + t.Parallel() + + f := newLoadAwareRestrictionFixture(t, true, nil, nil) + // 账号 2 也改为映射到不在定价列表的模型 + f.svc.accountRepo.(*mockAccountRepoForPlatform).accounts[1].Credentials = map[string]any{ + "model_mapping": map[string]any{"claude-fable-5-1": "claude-fable-5-1"}, + } + + result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "", "claude-fable-5-1", nil, "", 0) + require.ErrorIs(t, err, ErrNoAvailableAccounts) + require.ErrorContains(t, err, "channel pricing restriction") + require.Nil(t, result) + require.Equal(t, 0, f.concurrencyCache.acquireAccountCalls, "没有合规候选时不应尝试占用任何账号槽位") +} + +func TestSelectAccountWithLoadAwareness_UpstreamRestrictionIgnoresStickyAccount(t *testing.T) { + t.Parallel() + + f := newLoadAwareRestrictionFixture(t, true, map[string]int64{"sticky": 1}, nil) + result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "sticky", "claude-fable-5-1", nil, "", 0) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(2), result.Account.ID, "粘性账号的上游模型不在定价列表时不得沿用粘性会话") + require.Equal(t, int64(2), f.cache.sessionBindings["sticky"], "粘性会话应重新绑定到合规账号") +} + +func TestSelectAccountWithLoadAwareness_UpstreamRestrictionFiltersRoutedAccounts(t *testing.T) { + t.Parallel() + + f := newLoadAwareRestrictionFixture(t, true, nil, map[string][]int64{"claude-fable-5-1": {1}}) + result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "", "claude-fable-5-1", nil, "", 0) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(2), result.Account.ID, "模型路由指向的账号被渠道限制时应回退到合规账号") +} + +func TestSelectAccountWithLoadAwareness_UpstreamRestrictionRoutedStickyAccountNotHonored(t *testing.T) { + t.Parallel() + + f := newLoadAwareRestrictionFixture(t, true, map[string]int64{"sticky": 1}, map[string][]int64{"claude-fable-5-1": {1, 2}}) + result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "sticky", "claude-fable-5-1", nil, "", 0) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(2), result.Account.ID, "路由列表内的粘性账号被渠道限制时不得沿用") +} + +func TestSelectAccountWithLoadAwareness_RestrictModelsDisabledKeepsPriorityOrder(t *testing.T) { + t.Parallel() + + f := newLoadAwareRestrictionFixture(t, false, nil, nil) + result, err := f.svc.SelectAccountWithLoadAwareness(f.ctx, &f.groupID, "", "claude-fable-5-1", nil, "", 0) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Account) + require.Equal(t, int64(1), result.Account.ID, "未开启限制时不应因定价列表过滤账号") +} diff --git a/backend/internal/service/gateway_claude_oauth_body.go b/backend/internal/service/gateway_claude_oauth_body.go index 79f9a7c438..e98b967e2a 100644 --- a/backend/internal/service/gateway_claude_oauth_body.go +++ b/backend/internal/service/gateway_claude_oauth_body.go @@ -770,14 +770,14 @@ func expandClaudeOAuthSystemPromptTextTemplate(body []byte, text string, expansi return "", nil } expansionPrompt = defaultClaudeOAuthExpansionPrompt(expansionPrompt) - billingText, err := buildBillingAttributionText(body, claude.CLICurrentVersion) + billingText, err := buildBillingAttributionText(body, claude.CLIVersion()) if err != nil { return "", err } - fp := computeClaudeCodeFingerprint(body, claude.CLICurrentVersion) + fp := computeClaudeCodeFingerprint(body, claude.CLIVersion()) replacer := strings.NewReplacer( "{billing_header}", billingText, - "{cc_version}", claude.CLICurrentVersion, + "{cc_version}", claude.CLIVersion(), "{fp}", fp, "{claude_code_system_prompt}", claudeCodeSystemPrompt, "{claude_code_expansion_prompt}", expansionPrompt, diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index 57ae438309..0e48f2cb91 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -114,6 +114,23 @@ func TestSanitizeAnthropicBodyForBetaTokens_EmptyBody(t *testing.T) { require.Empty(t, out) } +func TestSanitizeAnthropicBodyForBetaTokens_ThinkingBlockBindingKeptWhenBetaPresent(t *testing.T) { + body := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}},"messages":[]}`) + out, changed := sanitizeAnthropicBodyForBetaTokens(body, claude.BetaThinkingBindingControls) + require.False(t, changed) + require.Equal(t, "drop_block", + gjson.GetBytes(out, "thinking.block_binding.prefix_mismatch_behavior").String()) +} + +func TestSanitizeAnthropicBodyForBetaTokens_ThinkingBlockBindingStrippedWhenBetaMissing(t *testing.T) { + body := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}},"messages":[]}`) + out, changed := sanitizeAnthropicBodyForBetaTokens(body, claude.BetaContextManagement) + require.True(t, changed) + require.False(t, gjson.GetBytes(out, "thinking.block_binding").Exists()) + require.Equal(t, "adaptive", gjson.GetBytes(out, "thinking.type").String()) + require.Equal(t, "summarized", gjson.GetBytes(out, "thinking.display").String()) +} + // ★ 关键回归断言:能力维度 sanitize 解决了 "真 CC + haiku" 路径的过度删除问题。 // 真实 Claude Code CLI 2.1.87+ 客户端 header 含 context-management beta; // 即使 model 是 haiku,sanitize 也不应剥离功能字段。 @@ -145,6 +162,8 @@ func TestComputeFinalAnthropicBeta_OAuthMimic_NonHaiku_IncludesContextManagement "OAuth mimic non-haiku 必须注入完整 CC mimicry beta,含 context-management-2025-06-27") require.True(t, anthropicBetaTokensContains(final, claude.BetaOAuth)) require.True(t, anthropicBetaTokensContains(final, claude.BetaClaudeCode)) + require.True(t, anthropicBetaTokensContains(final, claude.BetaThinkingBindingControls), + "OAuth mimic 必须注入 thinking block binding 所需的 beta") } func TestComputeFinalAnthropicBeta_OAuthMimic_Haiku_IncludesFullClaudeCodeBetas(t *testing.T) { diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index b3c7286c3f..5ff4f323a8 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -417,7 +417,9 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( req.Header.Del("x-api-key") req.Header.Del("x-goog-api-key") req.Header.Del("cookie") - setAnthropicAPIKeyAuthHeader(req.Header, account, token) + // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 + // targetURL 的 base 取值),其余保持 extra/default 行为。 + setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) if req.Header.Get("content-type") == "" { req.Header.Set("content-type", "application/json") @@ -485,9 +487,13 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con } } - // 同步 billing header cc_version 与实际发送的 User-Agent 版本 - if ctFingerprint != nil && ctEnableFP { - body = syncBillingHeaderVersion(body, ctFingerprint.UserAgent) + // Disabled fingerprint unification does not disable forced mimicry headers. + var billingFingerprint *Fingerprint + if ctEnableFP { + billingFingerprint = ctFingerprint + } + if billingUA := effectiveBillingUserAgent(tokenType, mimicClaudeCode, billingFingerprint); billingUA != "" { + body = syncBillingHeaderVersion(body, billingUA) } // === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)=== @@ -518,7 +524,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con if tokenType == "oauth" { setHeaderRaw(req.Header, "authorization", "Bearer "+token) } else { - setAnthropicAPIKeyAuthHeader(req.Header, account, token) + // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 + // targetURL 的 base 取值),其余保持 extra/default 行为。 + setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) } // 白名单透传 headers(恢复真实 wire casing) diff --git a/backend/internal/service/gateway_forward.go b/backend/internal/service/gateway_forward.go index 7743b2d1c7..d6a454111c 100644 --- a/backend/internal/service/gateway_forward.go +++ b/backend/internal/service/gateway_forward.go @@ -880,6 +880,7 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A return &ForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, // 使用原始模型用于计费和日志 UpstreamModel: mappedModel, diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 6a84426642..32d38cf40d 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -339,6 +339,7 @@ func (s *GatewayService) handleCCBufferedFromAnthropic( return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, UpstreamModel: mappedModel, @@ -391,6 +392,7 @@ func (s *GatewayService) handleCCStreamingFromAnthropic( resultWithUsage := func() *ForwardResult { return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, UpstreamModel: mappedModel, diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index a72eb1a278..1b97a73961 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -468,6 +468,7 @@ func (s *GatewayService) handleResponsesBufferedStreamingResponse( return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, UpstreamModel: mappedModel, @@ -516,6 +517,7 @@ func (s *GatewayService) handleResponsesStreamingResponse( resultWithUsage := func() *ForwardResult { return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, UpstreamModel: mappedModel, diff --git a/backend/internal/service/gateway_messages_cache.go b/backend/internal/service/gateway_messages_cache.go index 8f7e6b5b7d..646f8b2177 100644 --- a/backend/internal/service/gateway_messages_cache.go +++ b/backend/internal/service/gateway_messages_cache.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "fmt" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" @@ -157,5 +158,8 @@ func injectCacheControlOnLastContentBlock(body []byte, idx int, msg *gjson.Resul // mustJSONString 把一个 Go string 序列化为合法 JSON string(含引号), // 用于 sjson.SetRawBytes 场景下手工拼 JSON。 func mustJSONString(s string) string { - return fmt.Sprintf("%q", s) + // Go string quoting can emit non-JSON escapes such as \x7f or \a. + // Marshaling a string cannot fail. + encoded, _ := json.Marshal(s) + return string(encoded) } diff --git a/backend/internal/service/gateway_messages_cache_json_test.go b/backend/internal/service/gateway_messages_cache_json_test.go new file mode 100644 index 0000000000..26759c4d72 --- /dev/null +++ b/backend/internal/service/gateway_messages_cache_json_test.go @@ -0,0 +1,59 @@ +//go:build unit + +package service + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestAddMessageCacheBreakpoints_JSONStringEscaping(t *testing.T) { + var controls strings.Builder + for r := rune(0); r <= 0x9f; r++ { + controls.WriteRune(r) + } + tests := []struct{ name, text string }{ + {"delete_character", "before\x7fafter"}, + {"bell_and_vertical_tab", "before\a\vafter"}, + {"nul_and_escape", "before\x00\x1bafter"}, + {"all_ascii_and_c1_controls", controls.String()}, + {"supplementary_nonprinting_rune", "before\U000e0067after"}, + {"unicode_and_json_metacharacters", "中文 😀 <>& \"quote\" \\slash\n\r\t\b\f"}, + {"literal_backslash_sequences", `literal \x7f \a \v \U000e0067`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Both the final message and the second-to-last user turn are promoted + // from strings to text blocks by the cache rewrite. + body, err := json.Marshal(map[string]any{ + "messages": []map[string]any{ + {"role": "user", "content": tt.text}, + {"role": "assistant", "content": "ack"}, + {"role": "user", "content": "next"}, + {"role": "assistant", "content": tt.text}, + }, + }) + require.NoError(t, err) + out := addMessageCacheBreakpoints(body) + // gjson accepts some malformed input, so validate with encoding/json + // before inspecting the rewritten content and cache metadata. + var decoded any + require.NoError(t, json.Unmarshal(out, &decoded)) + for _, path := range []string{"messages.0.content", "messages.3.content"} { + content := gjson.GetBytes(out, path) + require.True(t, content.IsArray()) + require.Len(t, content.Array(), 1) + block := content.Array()[0] + require.Equal(t, "text", block.Get("type").String()) + require.Equal(t, tt.text, block.Get("text").String()) + require.Equal(t, "ephemeral", block.Get("cache_control.type").String()) + require.Equal(t, "5m", block.Get("cache_control.ttl").String()) + } + require.JSONEq(t, string(out), string(addMessageCacheBreakpoints(out))) + }) + } +} diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index ec1bf901ad..6568b3f6b4 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -3515,3 +3515,74 @@ func TestGatewayService_ResolveGatewayGroup_DetectsFallbackCycle(t *testing.T) { require.Nil(t, gotID) require.Contains(t, err.Error(), "fallback group cycle") } + +func TestModelRoutingAppliesToPlatform(t *testing.T) { + for _, tc := range []struct { + name string + targetPlatform string + groupPlatform string + want bool + }{ + {"anthropic group", PlatformAnthropic, PlatformAnthropic, true}, + {"openai group", PlatformOpenAI, PlatformOpenAI, true}, + {"composite group resolved to anthropic", PlatformAnthropic, PlatformComposite, true}, + {"composite group resolved to openai", PlatformOpenAI, PlatformComposite, true}, + {"target platform outside the allowed set", PlatformGemini, PlatformGemini, false}, + {"composite group resolved outside the allowed set", PlatformGemini, PlatformComposite, false}, + {"group platform does not match target", PlatformOpenAI, PlatformAnthropic, false}, + {"empty target platform", "", PlatformOpenAI, false}, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, modelRoutingAppliesToPlatform(tc.targetPlatform, tc.groupPlatform)) + }) + } +} + +// Model routing used to be read only for requests resolved to Anthropic, so an +// OpenAI group's rules were stored and displayed but never honored. Routing an +// OpenAI request must pick the routed account instead of the group-wide winner. +func TestGatewayService_SelectAccountForModelWithPlatform_RoutedOpenAIGroup(t *testing.T) { + ctx := context.Background() + groupID := int64(13) + requestedModel := "gpt-6-astra" + + repo := &mockAccountRepoForPlatform{ + accounts: []Account{ + // Priority 1 would win any group-wide selection, but it is not routed. + {ID: 1, Platform: PlatformOpenAI, Priority: 1, Status: StatusActive, Schedulable: true}, + {ID: 2, Platform: PlatformOpenAI, Priority: 5, Status: StatusActive, Schedulable: true}, + }, + accountsByID: map[int64]*Account{}, + } + for i := range repo.accounts { + repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i] + } + + groupRepo := &mockGroupRepoForGateway{ + groups: map[int64]*Group{ + groupID: { + ID: groupID, + Name: "openai-route-group", + Platform: PlatformOpenAI, + Status: StatusActive, + Hydrated: true, + ModelRoutingEnabled: true, + ModelRouting: map[string][]int64{ + requestedModel: {2}, + }, + }, + }, + } + + svc := &GatewayService{ + accountRepo: repo, + cache: &mockGatewayCacheForPlatform{}, + cfg: testConfig(), + groupRepo: groupRepo, + } + + acc, err := svc.selectAccountForModelWithPlatform(ctx, &groupID, "", requestedModel, nil, PlatformOpenAI) + require.NoError(t, err) + require.NotNil(t, acc) + require.Equal(t, int64(2), acc.ID, "routed account must win over the higher-priority unrouted one") +} diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index c14bf9c27e..a0d1c0e180 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -194,6 +194,42 @@ func TestGatewayServiceRecordUsage_PreservesRequestedAndUpstreamModels(t *testin require.Equal(t, mappedModel, *usageRepo.lastLog.UpstreamModel) } +func TestGatewayServiceRecordUsage_GeminiFlashThinkingTierUsesCatalogPrice(t *testing.T) { + for _, baseModel := range []string{"gemini-3.7-flash", "gemini-3.8-flash"} { + t.Run(baseModel, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + svc.billingService = NewBillingService(svc.cfg, &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + baseModel: {InputCostPerToken: 0.75e-6, OutputCostPerToken: 3.75e-6, CacheReadInputTokenCost: 0.075e-6}, + }}) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + group := &Group{ID: 27, Platform: PlatformGemini, RateMultiplier: 0.15} + model := baseModel + "-medium" + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gemini_thinking_tier", + Model: model, + UpstreamModel: model, + Usage: ClaudeUsage{InputTokens: 8498, OutputTokens: 469, CacheReadInputTokens: 159248}, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501, GroupID: &group.ID, Group: group}, + User: &User{ID: 601}, + Account: &Account{ID: 701, Platform: PlatformGemini, Type: AccountTypeAPIKey}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, model, usageRepo.lastLog.Model) + require.InDelta(t, 0.02007585, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.0030113775, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, 0.0030113775, userRepo.lastAmount, 1e-12) + }) + } +} + func TestGatewayServiceRecordUsage_PreservesChannelMappedUpstreamModel(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} svc := newGatewayRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}) @@ -424,6 +460,69 @@ func TestGatewayServiceRecordUsage_UsesExplicitPricingAtForPeakRate(t *testing.T } } +func TestGatewayServiceRecordUsage_DeepSeekAccountStatsUsesRequestPricingAtAndUpstreamModel(t *testing.T) { + for _, model := range []struct { + name string + offPeakCost float64 + }{ + {"deepseek-v4-flash", 1000*2.2e-7 + 500*6.6e-7 + 1000*7e-9}, + {"deepseek-v4-pro", 1000*6.6e-7 + 500*1.98e-6 + 1000*2.2e-8}, + } { + for _, slot := range []struct { + name string + pricingAt time.Time + multiplier float64 + }{ + {"peak", time.Date(2026, time.August, 24, 2, 0, 0, 0, time.UTC), 2}, + {"off_peak", time.Date(2026, time.August, 24, 12, 0, 0, 0, time.UTC), 1}, + } { + t.Run(model.name+"/"+slot.name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + groupID := int64(905) + svc.channelService = newTestChannelServiceForStats(t, &Channel{ID: 1, Status: StatusActive}, groupID, PlatformDeepseek) + svc.resolver = NewModelPricingResolver(svc.channelService, svc.billingService) + alias := "customer-chat" + inputPrice, outputPrice, cachePrice := 1e-6, 2e-6, 1e-7 + group := &Group{ID: groupID, Platform: PlatformDeepseek, RateMultiplier: 0.8, + ModelPricing: []ChannelModelPricing{{ + Models: []string{alias}, BillingMode: BillingModeToken, + InputPrice: &inputPrice, OutputPrice: &outputPrice, CacheReadPrice: &cachePrice, + }}, + } + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_deepseek_account_stats_" + model.name + "_" + slot.name, + Model: alias, UpstreamModel: model.name, + Usage: ClaudeUsage{InputTokens: 1000, OutputTokens: 500, CacheReadInputTokens: 1000}, + }, + APIKey: &APIKey{ID: 805, GroupID: &groupID, Group: group}, + User: &User{ID: 605}, Account: &Account{ID: 705, Platform: PlatformDeepseek}, + PricingAt: slot.pricingAt, + ChannelUsageFields: ChannelUsageFields{OriginalModel: alias, BillingModelSource: BillingModelSourceRequested}, + }) + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + log := usageRepo.lastLog + require.Equal(t, alias, log.RequestedModel) + require.NotNil(t, log.UpstreamModel) + require.Equal(t, model.name, *log.UpstreamModel) + require.WithinDuration(t, time.Now(), log.CreatedAt, time.Minute) + require.False(t, log.CreatedAt.Equal(slot.pricingAt), "request pricing time must differ from record creation") + customerTotal := 1000*inputPrice + 500*outputPrice + 1000*cachePrice + require.InDelta(t, customerTotal, log.TotalCost, 1e-12) + require.InDelta(t, customerTotal*0.8, log.ActualCost, 1e-12) + require.Equal(t, 1, userRepo.deductCalls) + require.InDelta(t, customerTotal*0.8, userRepo.lastAmount, 1e-12) + require.NotNil(t, log.AccountStatsCost) + require.InDelta(t, model.offPeakCost*slot.multiplier, *log.AccountStatsCost, 1e-12, + "account cost must use the upstream model and historical PricingAt") + }) + } + } +} + func TestGatewayServiceRecordUsage_UsageLogWriteErrorDoesNotSkipBilling(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: MarkUsageLogCreateNotPersisted(context.Canceled)} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index f5e4a4d500..ab12f4ad22 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -912,6 +912,12 @@ const anthropicBetaContextManagementToken = "context-management-2025-06-27" // FullClaudeCodeMimicryBetas 覆盖客户端 beta(该列表不含 fallback beta), // 若不 strip,body 字段与 header 不对称 → 所有模型 400 // +// thinking.block_binding 场景: +// - Claude Fable 5.1 的会话前缀绑定控制受 +// `thinking-binding-controls-2026-08-01` beta 保护 +// - 缺 token 时上游拒收: +// "thinking.adaptive.block_binding: Extra inputs are not permitted" +// // 本函数按最终发送的 anthropic-beta header 决定是否保留 body 中的上述字段: // 缺对应 beta token → strip;客户端 header 已带对应 beta → 保留(不过度删除)。 // 这将限制完全建立在 "能力维度" 上,与 model 名 / token type / mimicry 子路径无关。 @@ -935,6 +941,13 @@ func sanitizeAnthropicBodyForBetaTokens(body []byte, anthropicBetaHeader string) body, changed = b, true } + // thinking.block_binding:需要 thinking-binding-controls beta。 + if b, deleted := stripAnthropicBodyFieldUnlessBeta( + body, "thinking.block_binding", anthropicBetaHeader, claude.BetaThinkingBindingControls, + ); deleted { + body, changed = b, true + } + // fallbacks:server-side refusal fallback,仅接受 server-side-fallback beta。 if b, deleted := stripAnthropicBodyFieldUnlessBeta( body, "fallbacks", anthropicBetaHeader, claude.BetaServerSideFallback, @@ -1388,6 +1401,9 @@ func NormalizeGLMOpenAIReasoningEffort(body []byte, mappedModel string) ([]byte, } mapped := normalizeGLMOpenAIReasoningEffort(raw) + if isGLM53Model(mappedModel) && mapped == "high" && normalizeEffortToken(raw) == "low" { + mapped = "low" + } if mapped == "" || mapped == raw { return body, false } @@ -1399,12 +1415,20 @@ func NormalizeGLMOpenAIReasoningEffort(body []byte, mappedModel string) ([]byte, return modified, true } -func normalizeGLMOpenAIReasoningEffort(raw string) string { +func normalizeEffortToken(raw string) string { value := strings.ToLower(strings.TrimSpace(raw)) + return strings.NewReplacer("-", "", "_", "", " ", "").Replace(value) +} + +func isGLM53Model(model string) bool { + return strings.EqualFold(strings.TrimSpace(model), "glm-5.3") +} + +func normalizeGLMOpenAIReasoningEffort(raw string) string { + value := normalizeEffortToken(raw) if value == "" { return "" } - value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value) switch value { case "low", "medium", "high": @@ -1416,6 +1440,42 @@ func normalizeGLMOpenAIReasoningEffort(raw string) string { } } +// NormalizeGLM53AnthropicThinking maps explicit client thinking effort onto the +// GLM-5.3 Anthropic-compatible scale. Requests without an effort or thinking +// preference are left unchanged so the upstream default remains in effect. +func NormalizeGLM53AnthropicThinking(body []byte, mappedModel string) ([]byte, bool) { + if !isGLM53Model(mappedModel) { + return body, false + } + + raw := gjson.GetBytes(body, "output_config.effort").String() + if strings.TrimSpace(raw) == "" { + raw = gjson.GetBytes(body, "thinking.type").String() + } + + var effort string + switch normalizeEffortToken(raw) { + case "disabled", "off", "none", "minimal", "low": + effort = "low" + case "enabled", "adaptive", "medium", "high": + effort = "high" + case "xhigh", "max", "ultra": + effort = "max" + default: + return body, false + } + + modified, err := sjson.SetBytes(body, "thinking.type", "enabled") + if err != nil { + return body, false + } + modified, err = sjson.SetBytes(modified, "output_config.effort", effort) + if err != nil { + return body, false + } + return modified, true +} + // ========================= // Thinking Budget Rectifier // ========================= diff --git a/backend/internal/service/gateway_request_test.go b/backend/internal/service/gateway_request_test.go index 6074c02991..27b71c31fe 100644 --- a/backend/internal/service/gateway_request_test.go +++ b/backend/internal/service/gateway_request_test.go @@ -1605,6 +1605,14 @@ func TestNormalizeGLMOpenAIReasoningEffort(t *testing.T) { wantPath: "reasoning_effort", wantValue: "high", }, + { + name: "glm 5.2 low maps to high", + model: "glm-5.2", + input: `{"model":"glm-5.2","reasoning_effort":"low","messages":[]}`, + wantApplied: true, + wantPath: "reasoning_effort", + wantValue: "high", + }, { name: "nested high case-normalizes", model: "glm-5.2", @@ -1613,6 +1621,13 @@ func TestNormalizeGLMOpenAIReasoningEffort(t *testing.T) { wantPath: "reasoning.effort", wantValue: "high", }, + { + name: "glm 5.3 native low unchanged", + model: "glm-5.3", + input: `{"model":"glm-5.3","reasoning_effort":"low","messages":[]}`, + wantApplied: false, + wantUnchanged: true, + }, { name: "native max unchanged", model: "glm-5.2", diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index 977a28a99d..f5dd160c79 100644 --- a/backend/internal/service/gateway_scheduling.go +++ b/backend/internal/service/gateway_scheduling.go @@ -216,7 +216,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro return nil, err } preferOAuth := platform == PlatformGemini - if s.debugModelRoutingEnabled() && platform == PlatformAnthropic && requestedModel != "" { + if s.debugModelRoutingEnabled() && requestedModel != "" && modelRoutingAppliesToTargetPlatform(platform) { logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] load-aware enabled: group_id=%v model=%s session=%s platform=%s", derefGroupID(groupID), requestedModel, shortSessionHash(sessionHash), platform) } @@ -243,10 +243,17 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro return excluded } - // 获取模型路由配置(anthropic 目标平台;composite 分组按目标平台判断) + // upstream 计费基准的渠道模型限制以账号映射后的上游模型为准,只能逐账号判定; + // 负载感知各层的候选过滤与粘性 gate 共用这一判定。 + needsUpstreamCheck := s.needsUpstreamChannelRestrictionCheck(ctx, groupID) + isChannelRestricted := func(account *Account) bool { + return needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel) + } + + // 获取模型路由配置(anthropic / openai 目标平台;composite 分组按目标平台判断) var routingAccountIDs []int64 - if group != nil && requestedModel != "" && platform == PlatformAnthropic && - (group.Platform == PlatformAnthropic || group.Platform == PlatformComposite) { + if group != nil && requestedModel != "" && + modelRoutingAppliesToPlatform(platform, group.Platform) { routingAccountIDs = group.GetRoutingAccountIDs(requestedModel) if s.debugModelRoutingEnabled() { logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] context group routing: group_id=%d model=%s enabled=%v rules=%d matched_ids=%v session=%s sticky_account=%d", @@ -270,7 +277,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if len(routingAccountIDs) > 0 && s.concurrencyService != nil { // 1. 过滤出路由列表中可调度的账号 var routingCandidates []*Account - var filteredExcluded, filteredMissing, filteredUnsched, filteredPlatform, filteredModelScope, filteredModelMapping, filteredWindowCost int + var filteredExcluded, filteredMissing, filteredUnsched, filteredPlatform, filteredModelScope, filteredModelMapping, filteredChannelRestricted, filteredWindowCost int var modelScopeSkippedIDs []int64 // 记录因模型限流被跳过的账号 ID for _, routingAccountID := range routingAccountIDs { if isExcluded(routingAccountID) { @@ -297,6 +304,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro filteredModelMapping++ continue } + if isChannelRestricted(account) { + filteredChannelRestricted++ + continue + } if !s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) { filteredModelScope++ modelScopeSkippedIDs = append(modelScopeSkippedIDs, account.ID) @@ -319,9 +330,9 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro } if s.debugModelRoutingEnabled() { - logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed candidates: group_id=%v model=%s routed=%d candidates=%d filtered(excluded=%d missing=%d unsched=%d platform=%d model_scope=%d model_mapping=%d window_cost=%d)", + logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] routed candidates: group_id=%v model=%s routed=%d candidates=%d filtered(excluded=%d missing=%d unsched=%d platform=%d model_scope=%d model_mapping=%d channel_restricted=%d window_cost=%d)", derefGroupID(groupID), requestedModel, len(routingAccountIDs), len(routingCandidates), - filteredExcluded, filteredMissing, filteredUnsched, filteredPlatform, filteredModelScope, filteredModelMapping, filteredWindowCost) + filteredExcluded, filteredMissing, filteredUnsched, filteredPlatform, filteredModelScope, filteredModelMapping, filteredChannelRestricted, filteredWindowCost) if len(modelScopeSkippedIDs) > 0 { logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] model_rate_limited accounts skipped: group_id=%v model=%s account_ids=%v", derefGroupID(groupID), requestedModel, modelScopeSkippedIDs) @@ -347,6 +358,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro s.isGatewayAccountProfitEligible(ctx, stickyAccount) && s.isAccountAllowedForPlatform(stickyAccount, platform, useMixed) && (requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, stickyAccount, requestedModel)) && + !isChannelRestricted(stickyAccount) && s.isAccountSchedulableForModelSelection(ctx, stickyAccount, requestedModel) && s.isAccountSchedulableForQuota(stickyAccount) && s.isAccountSchedulableForWindowCost(ctx, stickyAccount, true) @@ -531,6 +543,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro platformOK := s.isAccountAllowedForPlatform(account, platform, useMixed) profitOK := s.isGatewayAccountProfitEligible(ctx, account) modelSupported := requestedModel == "" || s.isModelSupportedByAccountWithContext(ctx, account, requestedModel) + channelOK := !isChannelRestricted(account) modelSchedulable := s.isAccountSchedulableForModelSelection(ctx, account, requestedModel) quotaOK := s.isAccountSchedulableForQuota(account) windowCostOK := s.isAccountSchedulableForWindowCost(ctx, account, true) @@ -545,13 +558,14 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro "platform_ok", platformOK, "profit_ok", profitOK, "model_supported", modelSupported, + "channel_ok", channelOK, "model_schedulable", modelSchedulable, "quota_ok", quotaOK, "window_cost_ok", windowCostOK, "rpm_ok", rpmOK, ) - if !clearSticky && platformOK && profitOK && modelSupported && modelSchedulable && quotaOK && windowCostOK && rpmOK && schedulable { + if !clearSticky && platformOK && profitOK && modelSupported && channelOK && modelSchedulable && quotaOK && windowCostOK && rpmOK && schedulable { result, err := s.tryAcquireAccountSlot(ctx, accountID, account.Concurrency) if err == nil && result.Acquired { // 会话数量限制检查 @@ -636,6 +650,7 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro "total_accounts", len(accounts), ) candidates := make([]*Account, 0, len(accounts)) + channelRestrictedCount := 0 for i := range accounts { acc := &accounts[i] if isExcluded(acc.ID) { @@ -656,6 +671,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro if requestedModel != "" && !s.isModelSupportedByAccountWithContext(ctx, acc, requestedModel) { continue } + if isChannelRestricted(acc) { + channelRestrictedCount++ + continue + } if !s.isAccountSchedulableForModelSelection(ctx, acc, requestedModel) { continue } @@ -675,6 +694,14 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro } if len(candidates) == 0 { + if channelRestrictedCount > 0 { + slog.Warn("channel pricing restriction blocked request", + "group_id", derefGroupID(groupID), + "model", requestedModel, + "restricted_accounts", channelRestrictedCount, + "total_accounts", len(accounts)) + return nil, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel) + } return nil, ErrNoAvailableAccounts } @@ -838,8 +865,35 @@ func (s *GatewayService) ResolveGroupByID(ctx context.Context, groupID int64) (* return s.resolveGroupByID(ctx, groupID) } +// modelRoutingAppliesToPlatform 判定模型路由规则是否适用于本次请求。 +// +// 路由规则的存储与查表(Group.ModelRouting / GetRoutingAccountIDs)本身与平台无关, +// 早期只有 Anthropic 目标平台会读取它。OpenAI 分组同样存在“同一个公开别名由不同账号 +// 映射到不同上游模型”的故障转移写法,需要显式路由来指定谁是主、谁是备,因此这里把 +// Anthropic 与 OpenAI 一并放行。 +// +// targetPlatform 是请求解析后的目标平台;groupPlatform 是分组自身的平台。composite +// 分组不限定自身平台,只要目标平台落在放行集合内即可复用其规则。 +func modelRoutingAppliesToPlatform(targetPlatform, groupPlatform string) bool { + if !modelRoutingAppliesToTargetPlatform(targetPlatform) { + return false + } + return groupPlatform == targetPlatform || groupPlatform == PlatformComposite +} + +// modelRoutingAppliesToTargetPlatform 是放行平台集合的唯一定义处:新增平台只改这里。 +// 在只知道目标平台、还没取到分组的位置(调试日志、取分组前的短路)单独判定用。 +func modelRoutingAppliesToTargetPlatform(targetPlatform string) bool { + switch targetPlatform { + case PlatformAnthropic, PlatformOpenAI: + return true + default: + return false + } +} + func (s *GatewayService) routingAccountIDsForRequest(ctx context.Context, groupID *int64, requestedModel string, platform string) []int64 { - if groupID == nil || requestedModel == "" || platform != PlatformAnthropic { + if groupID == nil || requestedModel == "" || !modelRoutingAppliesToTargetPlatform(platform) { return nil } group, err := s.resolveGroupByID(ctx, *groupID) @@ -849,11 +903,11 @@ func (s *GatewayService) routingAccountIDsForRequest(ctx context.Context, groupI } return nil } - // Model routing applies only to requests resolved to Anthropic. Composite - // groups may still use those rules once their model resolved to Anthropic. - if group.Platform != PlatformAnthropic && group.Platform != PlatformComposite { + // 路由规则适用于解析到 Anthropic 或 OpenAI 的请求;composite 分组在其模型解析到 + // 上述平台后同样可以复用这些规则。 + if !modelRoutingAppliesToPlatform(platform, group.Platform) { if s.debugModelRoutingEnabled() { - logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] skip: non-anthropic group platform: group_id=%d group_platform=%s model=%s", group.ID, group.Platform, requestedModel) + logger.LegacyPrintf("service.gateway", "[ModelRoutingDebug] skip: group platform not eligible: group_id=%d group_platform=%s target_platform=%s model=%s", group.ID, group.Platform, platform, requestedModel) } return nil } @@ -1435,6 +1489,28 @@ func (s *GatewayService) checkAndRegisterSession(ctx context.Context, account *A return allowed } +// ReleaseAccountSession 立即释放会话槽(不等待空闲超时) +// 供 handler 在请求最终失败(选号成功但转发失败/客户端中断)时调用: +// 上游从未真正服务该会话,若继续占槽,max_sessions 受限的账号会被失败请求的 +// session hash 卡满整个空闲窗口,后续新会话全部被拒。 +// 适用条件与 checkAndRegisterSession 对齐;不适用账号为 no-op,幂等可安全重复调用。 +func (s *GatewayService) ReleaseAccountSession(ctx context.Context, account *Account, sessionID string) { + if s == nil || s.sessionLimitCache == nil || account == nil || sessionID == "" { + return + } + if !account.IsAnthropicOAuthOrSetupToken() { + return + } + if account.GetMaxSessions() <= 0 { + return + } + if err := s.sessionLimitCache.UnregisterSession(ctx, account.ID, sessionID); err != nil { + slog.Debug("session_limit.release_failed", + "account_id", account.ID, + "error", err) + } +} + func (s *GatewayService) getSchedulableAccount(ctx context.Context, accountID int64) (*Account, error) { var ( account *Account diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 33652e733c..97e35fdb1b 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -610,8 +610,10 @@ type AudioUsage struct { type ForwardResult struct { RequestID string - Usage ClaudeUsage - Model string + // UpstreamHeaders 是直接上游的响应头,用于按账户配置解析上游请求标识。 + UpstreamHeaders http.Header + Usage ClaudeUsage + Model string // UpstreamModel is the actual upstream model after mapping. // Prefer empty when it is identical to Model; persistence normalizes equal values away as no-op mappings. UpstreamModel string @@ -1450,6 +1452,10 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, } sort.Strings(models) + if platform == PlatformOpenAI { + models = supplementUnmappedOpenAIModels(accounts, models) + } + if s.modelsListCache != nil { s.modelsListCache.Set(cacheKey, cloneStringSlice(models), s.modelsListCacheTTL) modelsListCacheStoreTotal.Add(1) diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index 0a8efbdd5e..4d8e74c162 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -84,9 +84,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } } - // 同步 billing header cc_version 与实际发送的 User-Agent 版本 - if fingerprint != nil { - body = syncBillingHeaderVersion(body, fingerprint.UserAgent) + // Mimicry may override the cached User-Agent later, even without a fingerprint. + if billingUA := effectiveBillingUserAgent(tokenType, mimicClaudeCode, fingerprint); billingUA != "" { + body = syncBillingHeaderVersion(body, billingUA) } // === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)=== @@ -117,6 +117,11 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex body = sanitized } + // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的 + // base 取值同源(GetBaseURL),仅实际上游为 ollama.com 且映射后出站模型 + // 为 DeepSeek 系时压到 cap,详见 helper 注释。 + body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body) + req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { return nil, nil, err @@ -126,7 +131,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex if tokenType == "oauth" { setHeaderRaw(req.Header, "authorization", "Bearer "+token) } else { - setAnthropicAPIKeyAuthHeader(req.Header, account, token) + // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 + // targetURL 的 base 取值),其余保持 extra/default 行为。 + setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) } // 白名单透传 headers diff --git a/backend/internal/service/gateway_upstream_response.go b/backend/internal/service/gateway_upstream_response.go index cd9b1bf8b8..5b1307b40e 100644 --- a/backend/internal/service/gateway_upstream_response.go +++ b/backend/internal/service/gateway_upstream_response.go @@ -285,6 +285,12 @@ func ExtractUpstreamErrorMessage(body []byte) string { return extractUpstreamErrorMessage(body) } +// SanitizeUpstreamErrorMessage redacts sensitive query parameter values before +// an upstream error message is recorded or returned to a client. +func SanitizeUpstreamErrorMessage(message string) string { + return sanitizeUpstreamErrorMessage(message) +} + func extractUpstreamErrorMessage(body []byte) string { // Claude 风格:{"type":"error","error":{"type":"...","message":"..."}} if m := gjson.GetBytes(body, "error.message").String(); strings.TrimSpace(m) != "" { @@ -679,6 +685,7 @@ func partialStreamUsageResult(c *gin.Context, resp *http.Response, streamResult } return &ForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: *streamResult.usage, Model: model, UpstreamModel: upstreamModel, diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 069758baae..1e1a0f8477 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -831,7 +831,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage CacheReadTokens: result.Usage.CacheReadInputTokens, ImageOutputTokens: result.Usage.ImageOutputTokens, }, - cost.TotalCost, + cost.TotalCost, pricingAt, ) } @@ -1092,15 +1092,16 @@ func (s *GatewayService) calculateTokenCost( } cost, err := s.billingService.CalculateTokenCostForRequest(TokenCostRequest{ - Ctx: ctx, - Model: billingModel, - Group: apiKey.Group, - Tokens: tokens, - RateMultiplier: multiplier, - PricingAt: pricingAt, - ServiceTier: optionalStringValue(result.ServiceTier), - Resolver: s.resolver, - Resolved: resolved, + Ctx: ctx, + Model: billingModel, + Group: apiKey.Group, + Tokens: tokens, + RateMultiplier: multiplier, + PricingAt: pricingAt, + ServiceTier: optionalStringValue(result.ServiceTier), + ReasoningEffort: optionalStringValue(result.ReasoningEffort), + Resolver: s.resolver, + Resolved: resolved, }) if err != nil { logger.LegacyPrintf("service.gateway", "Calculate cost failed: %v", err) @@ -1143,6 +1144,7 @@ func (s *GatewayService) buildRecordUsageLog( APIKeyID: apiKey.ID, AccountID: account.ID, RequestID: requestID, + UpstreamRequestID: usageUpstreamRequestIDPtr(account, result.UpstreamHeaders, false), Model: result.Model, RequestedModel: requestedModel, UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index bb464bbe7c..21b6e9af97 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -296,6 +296,7 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, UpstreamModel: mappedModel, diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 0f245b95fe..1396c93fdc 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -1115,6 +1115,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, UpstreamModel: mappedModel, @@ -1467,13 +1468,14 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. estimated := estimateGeminiCountTokens(body) c.JSON(http.StatusOK, map[string]any{"totalTokens": estimated}) return &ForwardResult{ - RequestID: requestID, - Usage: ClaudeUsage{}, - Model: originalModel, - UpstreamModel: mappedModel, - Stream: false, - Duration: time.Since(startTime), - FirstTokenMs: nil, + RequestID: requestID, + UpstreamHeaders: resp.Header, + Usage: ClaudeUsage{}, + Model: originalModel, + UpstreamModel: mappedModel, + Stream: false, + Duration: time.Since(startTime), + FirstTokenMs: nil, }, nil } @@ -1626,6 +1628,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. return &ForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: *usage, Model: originalModel, UpstreamModel: mappedModel, diff --git a/backend/internal/service/grok_audio.go b/backend/internal/service/grok_audio.go index 0cd138cad5..c726b339df 100644 --- a/backend/internal/service/grok_audio.go +++ b/backend/internal/service/grok_audio.go @@ -111,11 +111,12 @@ func (s *OpenAIGatewayService) ForwardGrokVoice(ctx context.Context, c *gin.Cont upstreamID := firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id")) return &OpenAIForwardResult{ // Forced durable money-event id so usage_billing_dedup cannot collapse under a reused client id. - RequestID: StableGrokAudioBillingRequestID(upstreamID), - Model: baseEndpoint, - UpstreamModel: baseEndpoint, - Duration: time.Since(started), - AudioUsage: audioUsage, + RequestID: StableGrokAudioBillingRequestID(upstreamID), + UpstreamHeaders: resp.Header, + Model: baseEndpoint, + UpstreamModel: baseEndpoint, + Duration: time.Since(started), + AudioUsage: audioUsage, }, nil } diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 9bc7bdc5ec..ff4187d987 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -739,6 +739,7 @@ func (s *OpenAIGatewayService) ForwardGrokMedia( } return &OpenAIForwardResult{ RequestID: requestIDHeader, + UpstreamHeaders: resp.Header, ResponseID: usage.ResponseID, Usage: usage.Usage, Model: resultModel, @@ -873,6 +874,7 @@ func (s *OpenAIGatewayService) forwardGrokMediaVideoContent( // (same path as status polling). Pending snapshot is merged in the handler. result := &OpenAIForwardResult{ RequestID: contentRequestID, + UpstreamHeaders: contentResp.Header, ResponseHeaders: contentResp.Header.Clone(), Duration: time.Since(startTime), } diff --git a/backend/internal/service/grok_oauth_service_test.go b/backend/internal/service/grok_oauth_service_test.go index d6fc4918fd..51875c2eb6 100644 --- a/backend/internal/service/grok_oauth_service_test.go +++ b/backend/internal/service/grok_oauth_service_test.go @@ -252,7 +252,7 @@ func TestGrokOAuthServiceRefreshAccountTokenIgnoresIDTokenTierWhenAccessTokenHas svc := NewGrokOAuthService(nil, &grokOAuthClientStub{ refreshResponse: &xai.TokenResponse{ AccessToken: "opaque-access-token", - IDToken: makeGrokOAuthJWT(map[string]any{"tier": 5}), + IDToken: makeGrokOAuthJWT(map[string]any{"tier": 5}), TokenType: "Bearer", ExpiresIn: 3600, }, diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index fc9996da3b..bb6804a823 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -12,7 +12,7 @@ import ( ) type OpenAIMessagesDispatchModelConfig = domain.OpenAIMessagesDispatchModelConfig -type GroupModelsListConfig = domain.GroupModelsListConfig +type GroupCodexModelsManifestConfig = domain.GroupCodexModelsManifestConfig type ReasoningEffortMapping = domain.ReasoningEffortMapping type Group struct { @@ -106,13 +106,16 @@ type Group struct { RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini) DefaultMappedModel string MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig - ModelsListConfig GroupModelsListConfig + ModelAllowlist GroupModelAllowlist + // CodexModelsManifestConfig 开启后,普通模型列表与 Codex manifest 优先使用 + // 固定账号列表拉取并合并,不经过调度器(仅 openai 平台)。 + CodexModelsManifestConfig GroupCodexModelsManifestConfig // RPMLimit 分组级每分钟请求数上限(0 = 不限制)。 // 一旦设置即接管该分组用户的限流(覆盖用户级 rpm_limit),可被 user-group rpm_override 进一步覆盖。 RPMLimit int - // MaxReasoningEffort limits the effective OpenAI/Codex reasoning effort. + // MaxReasoningEffort limits the effective Anthropic/OpenAI reasoning effort. // Empty means unlimited; supported values are minimal/low/medium/high/xhigh/max. MaxReasoningEffort string // MaxReasoningEffortOverLimit is the access control when an explicit effort @@ -138,6 +141,12 @@ type Group struct { RateLimitedAccountCount int64 } +// IsGroupBindableInSimpleMode is the shared policy for groups that may be +// surfaced and bound to accounts while running in simple mode. +func IsGroupBindableInSimpleMode(group *Group) bool { + return group != nil && group.Platform != PlatformComposite +} + func (g *Group) IsActive() bool { return g.Status == StatusActive } diff --git a/backend/internal/service/group_codex_models_manifest.go b/backend/internal/service/group_codex_models_manifest.go new file mode 100644 index 0000000000..65a12a3cec --- /dev/null +++ b/backend/internal/service/group_codex_models_manifest.go @@ -0,0 +1,86 @@ +package service + +import ( + "context" + "fmt" + "net/http" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// maxCodexModelsManifestAccounts is the upper bound of pinned accounts per group. +const maxCodexModelsManifestAccounts = 10 + +// normalizeCodexModelsManifestConfig normalizes a group's pinned-accounts Codex +// manifest config before persistence: +// - Non-OpenAI platforms always persist a disabled zero config (spec: silently +// normalize instead of rejecting). +// - Account IDs are de-duplicated preserving first-seen order; invalid IDs (<= 0) +// are dropped. The list is kept even when disabled so toggling enabled back +// on does not lose the previous selection. +func normalizeCodexModelsManifestConfig(platform string, cfg GroupCodexModelsManifestConfig) GroupCodexModelsManifestConfig { + if platform != PlatformOpenAI { + return GroupCodexModelsManifestConfig{} + } + out := GroupCodexModelsManifestConfig{ + Enabled: cfg.Enabled, + FallbackToScheduler: cfg.FallbackToScheduler, + } + if len(cfg.AccountIDs) == 0 { + return out + } + seen := make(map[int64]struct{}, len(cfg.AccountIDs)) + out.AccountIDs = make([]int64, 0, len(cfg.AccountIDs)) + for _, id := range cfg.AccountIDs { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out.AccountIDs = append(out.AccountIDs, id) + } + if len(out.AccountIDs) == 0 { + out.AccountIDs = nil + } + return out +} + +// validateCodexModelsManifestConfig enforces the admin contract for an enabled +// pinned-accounts config: at least one account, at most +// maxCodexModelsManifestAccounts, and every account must be an active OpenAI +// member of the group. Validation failures return 400 with reason +// INVALID_CODEX_MODELS_MANIFEST_CONFIG and never reach persistence. +func (s *adminServiceImpl) validateCodexModelsManifestConfig(ctx context.Context, groupID int64, cfg GroupCodexModelsManifestConfig) error { + if !cfg.Enabled { + return nil + } + if len(cfg.AccountIDs) == 0 { + return infraerrors.New(http.StatusBadRequest, "INVALID_CODEX_MODELS_MANIFEST_CONFIG", "codex models manifest config requires at least one account") + } + if len(cfg.AccountIDs) > maxCodexModelsManifestAccounts { + return infraerrors.Newf(http.StatusBadRequest, "INVALID_CODEX_MODELS_MANIFEST_CONFIG", "codex models manifest config allows at most %d accounts, got %d", maxCodexModelsManifestAccounts, len(cfg.AccountIDs)) + } + accounts, err := s.accountRepo.ListByGroup(ctx, groupID) + if err != nil { + return fmt.Errorf("load group accounts for codex models manifest config: %w", err) + } + members := make(map[int64]struct{}, len(accounts)) + for i := range accounts { + acc := &accounts[i] + if acc.IsActive() && acc.Platform == PlatformOpenAI { + members[acc.ID] = struct{}{} + } + } + var invalid []int64 + for _, id := range cfg.AccountIDs { + if _, ok := members[id]; !ok { + invalid = append(invalid, id) + } + } + if len(invalid) > 0 { + return infraerrors.Newf(http.StatusBadRequest, "INVALID_CODEX_MODELS_MANIFEST_CONFIG", "codex models manifest config contains accounts not in this group or not active openai accounts: %v", invalid) + } + return nil +} diff --git a/backend/internal/service/group_model_allowlist.go b/backend/internal/service/group_model_allowlist.go new file mode 100644 index 0000000000..6ef6b41a85 --- /dev/null +++ b/backend/internal/service/group_model_allowlist.go @@ -0,0 +1,229 @@ +package service + +import ( + "net/http" + "slices" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/domain" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" +) + +// GroupModelAllowlist 是 service 层的分组模型白名单(与 domain.GroupModelAllowlist +// 字段一致,ent 持久化用 domain 类型,边界处显式转换)。 +type GroupModelAllowlist struct { + Enabled bool `json:"enabled"` + Models []string `json:"models,omitempty"` +} + +// DomainGroupModelAllowlist 把 service 白名单转换为 ent 持久化使用的 domain 类型。 +func DomainGroupModelAllowlist(cfg GroupModelAllowlist) domain.GroupModelAllowlist { + return domain.GroupModelAllowlist{Enabled: cfg.Enabled, Models: cfg.Models} +} + +// GroupModelAllowlistFromDomain 把 ent 读出的 domain 白名单转换为 service 类型。 +func GroupModelAllowlistFromDomain(cfg domain.GroupModelAllowlist) GroupModelAllowlist { + return GroupModelAllowlist{Enabled: cfg.Enabled, Models: cfg.Models} +} + +// supplementUnmappedOpenAIModels ensures a partial mapping catalog does not +// hide models from unmapped OpenAI accounts. An empty catalog is left unchanged +// so callers retain their existing discovery fallback. +func supplementUnmappedOpenAIModels(accounts []Account, models []string) []string { + if len(models) == 0 { + return models + } + for i := range accounts { + account := &accounts[i] + if account.Platform == PlatformOpenAI && len(account.GetModelMapping()) == 0 { + return dedupeAndSortModelIDs(slices.Concat(models, openai.DefaultModelIDs())) + } + } + return models +} + +// normalizeGroupModelAllowlist 归一化管理端提交的分组模型白名单: +// 条目 TrimSpace、按小写去重保序;`*` 只允许出现在条目末尾; +// enabled=true 且列表为空视为配置错误,返回 400 而不是运行时静默放行/拒绝。 +func normalizeGroupModelAllowlist(cfg GroupModelAllowlist) (GroupModelAllowlist, error) { + out := GroupModelAllowlist{Enabled: cfg.Enabled} + if len(cfg.Models) == 0 { + if out.Enabled { + return out, infraerrors.New(http.StatusBadRequest, "INVALID_MODEL_ALLOWLIST", "model allowlist cannot be enabled with an empty model list") + } + return out, nil + } + + seen := make(map[string]struct{}, len(cfg.Models)) + out.Models = make([]string, 0, len(cfg.Models)) + for _, model := range cfg.Models { + model = strings.TrimSpace(model) + if model == "" { + continue + } + if strings.Contains(strings.TrimSuffix(model, "*"), "*") { + return out, infraerrors.New(http.StatusBadRequest, "INVALID_MODEL_ALLOWLIST", `wildcard "*" is only allowed at the end of an allowlist entry`) + } + key := strings.ToLower(model) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out.Models = append(out.Models, model) + } + if len(out.Models) == 0 { + if out.Enabled { + return out, infraerrors.New(http.StatusBadRequest, "INVALID_MODEL_ALLOWLIST", "model allowlist cannot be enabled with an empty model list") + } + out.Models = nil + } + return out, nil +} + +// ModelAllowlistEnabled 报告该分组是否启用了模型白名单。 +// 开启后所有携带模型的网关请求与模型列表接口都受白名单约束。 +func (g *Group) ModelAllowlistEnabled() bool { + return g != nil && g.ModelAllowlist.Enabled +} + +// Allows 判断客户端请求的模型是否命中白名单。 +// 准入只看客户端书写的模型名,与账号映射、渠道映射、合成路由改写无关; +// 候选形式覆盖代码中已有的模型名等价规则(Gemini models/ 前缀、 +// Antigravity/Claude -thinking 宽容规则、OpenAI 推理后缀),不做模糊匹配。 +func (a GroupModelAllowlist) Allows(model string) bool { + if !a.Enabled { + return true + } + model = strings.TrimSpace(model) + if model == "" { + return true + } + candidates := groupModelAllowlistCandidates(model) + for _, entry := range a.Models { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + entry = strings.ToLower(entry) + if strings.HasSuffix(entry, "*") { + prefix := strings.TrimSuffix(entry, "*") + for _, candidate := range candidates { + if strings.HasPrefix(candidate, prefix) { + return true + } + } + continue + } + for _, candidate := range candidates { + if candidate == entry { + return true + } + } + } + return false +} + +// groupModelAllowlistCandidates 返回客户端模型名在白名单匹配中的候选形式(均已小写)。 +func groupModelAllowlistCandidates(model string) []string { + candidates := make([]string, 0, 4) + add := func(value string) { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return + } + for _, existing := range candidates { + if existing == value { + return + } + } + candidates = append(candidates, value) + } + + add(model) + add(strings.TrimPrefix(model, "models/")) + add(claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking"))) + add(NormalizeOpenAICompatRequestedModel(model)) + return candidates +} + +// FilterForListing 按白名单条目顺序生成模型列表输出。 +// 精确条目沿用既有规则:只有出现在 source(账号映射键 ∪ 平台默认列表)的模式 +// 集合中才输出;通配条目展开为 source 中所有匹配项并保持 source 顺序;全局去重。 +func (a GroupModelAllowlist) FilterForListing(source []string) []string { + if !a.Enabled { + return source + } + if len(a.Models) == 0 { + // 开启但为空的配置在管理端已被拒绝;对遗留脏数据保持“看到什么 = 能调什么”, + // 列表与准入同时返回空。 + return nil + } + + patterns := make([]string, 0, len(source)) + for _, pattern := range source { + pattern = strings.TrimSpace(pattern) + if pattern != "" { + patterns = append(patterns, pattern) + } + } + if len(patterns) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(a.Models)+len(patterns)) + filtered := make([]string, 0, len(patterns)) + add := func(model string) { + key := strings.ToLower(model) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + filtered = append(filtered, model) + } + + for _, entry := range a.Models { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + if strings.HasSuffix(entry, "*") { + // 空 前缀(裸 `*` 条目)匹配全部来源,与 Allows 的全放行语义一致。 + prefix := strings.ToLower(strings.TrimSuffix(entry, "*")) + for _, pattern := range patterns { + if strings.HasPrefix(strings.ToLower(pattern), prefix) { + add(pattern) + } + } + continue + } + if allowlistSourcePatternAllowsModel(patterns, entry) { + add(entry) + } + } + return filtered +} + +// allowlistSourcePatternAllowsModel 沿用 filterModelsByCustomList 时代的匹配规则: +// 精确相等、source 通配模式的前缀匹配,以及 Claude 归一化(-thinking 后缀) +// 后的精确匹配。比较与 Allows 一律大小写不敏感,保证「准入允许 ⇒ 列表可见」。 +func allowlistSourcePatternAllowsModel(patterns []string, model string) bool { + for _, pattern := range patterns { + if strings.EqualFold(pattern, model) { + return true + } + if strings.HasSuffix(pattern, "*") && strings.HasPrefix(strings.ToLower(model), strings.ToLower(strings.TrimSuffix(pattern, "*"))) { + return true + } + } + normalizedClaudeModel := claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking")) + if !strings.EqualFold(normalizedClaudeModel, model) { + for _, pattern := range patterns { + if strings.EqualFold(pattern, normalizedClaudeModel) { + return true + } + } + } + return false +} diff --git a/backend/internal/service/group_model_allowlist_test.go b/backend/internal/service/group_model_allowlist_test.go new file mode 100644 index 0000000000..0a8988112d --- /dev/null +++ b/backend/internal/service/group_model_allowlist_test.go @@ -0,0 +1,254 @@ +package service + +import ( + "strings" + "testing" +) + +func TestNormalizeGroupModelAllowlist(t *testing.T) { + tests := []struct { + name string + in GroupModelAllowlist + want GroupModelAllowlist + wantErr string + }{ + { + name: "trims and dedupes case-insensitively preserving order", + in: GroupModelAllowlist{ + Enabled: true, + Models: []string{" gpt-5.4 ", "GPT-5.4", "claude-sonnet-4.5", ""}, + }, + want: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.4", "claude-sonnet-4.5"}}, + }, + { + name: "disabled with empty list is fine", + in: GroupModelAllowlist{Enabled: false}, + want: GroupModelAllowlist{Enabled: false}, + }, + { + name: "enabled with empty list is rejected", + in: GroupModelAllowlist{Enabled: true}, + wantErr: "INVALID_MODEL_ALLOWLIST", + }, + { + name: "enabled with only blank entries is rejected", + in: GroupModelAllowlist{Enabled: true, Models: []string{" ", ""}}, + wantErr: "INVALID_MODEL_ALLOWLIST", + }, + { + name: "trailing wildcard is accepted", + in: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.5-*"}}, + want: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.5-*"}}, + }, + { + name: "wildcard in the middle is rejected", + in: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4"}}, + wantErr: "INVALID_MODEL_ALLOWLIST", + }, + { + name: "bare wildcard is accepted as allow-all", + in: GroupModelAllowlist{Enabled: true, Models: []string{"*"}}, + want: GroupModelAllowlist{Enabled: true, Models: []string{"*"}}, + wantErr: "", + }, + { + name: "disabled config with invalid wildcard still rejected", + in: GroupModelAllowlist{Enabled: false, Models: []string{"foo-*bar"}}, + wantErr: "INVALID_MODEL_ALLOWLIST", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeGroupModelAllowlist(tt.in) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Enabled != tt.want.Enabled { + t.Fatalf("enabled mismatch: got %v want %v", got.Enabled, tt.want.Enabled) + } + if len(got.Models) != len(tt.want.Models) { + t.Fatalf("models mismatch: got %#v want %#v", got.Models, tt.want.Models) + } + for i := range tt.want.Models { + if got.Models[i] != tt.want.Models[i] { + t.Fatalf("models[%d] mismatch: got %q want %q", i, got.Models[i], tt.want.Models[i]) + } + } + }) + } +} + +func TestGroupModelAllowlistAllows(t *testing.T) { + allowlist := GroupModelAllowlist{ + Enabled: true, + Models: []string{"claude-sonnet-4.5", "gemini-2.5-pro", "gpt-5.5", "grok-*"}, + } + + tests := []struct { + name string + model string + want bool + }{ + {name: "exact match", model: "claude-sonnet-4.5", want: true}, + {name: "case-insensitive entry match", model: "Claude-Sonnet-4.5", want: true}, + {name: "not listed", model: "claude-opus-4.6", want: false}, + {name: "thinking suffix tolerated via claude normalization", model: "claude-sonnet-4.5-thinking", want: true}, + {name: "gemini models/ prefix stripped", model: "models/gemini-2.5-pro", want: true}, + {name: "gemini models/ prefix not in list", model: "models/gemini-2.5-flash", want: false}, + {name: "openai reasoning suffix normalizes to base model", model: "gpt-5.5-codex-high", want: true}, + {name: "openai reasoning suffix on unlisted model", model: "gpt-4.1-low", want: false}, + {name: "trailing wildcard prefix match", model: "grok-4.6", want: true}, + {name: "trailing wildcard requires prefix", model: "grok", want: false}, + {name: "empty model passes (handler decides required-ness)", model: "", want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := allowlist.Allows(tt.model); got != tt.want { + t.Fatalf("Allows(%q) = %v, want %v", tt.model, got, tt.want) + } + }) + } + + t.Run("disabled allowlist allows everything", func(t *testing.T) { + disabled := GroupModelAllowlist{Enabled: false, Models: []string{"only-model"}} + if !disabled.Allows("anything") { + t.Fatal("disabled allowlist must allow all models") + } + }) + + t.Run("enabled empty allowlist denies everything", func(t *testing.T) { + empty := GroupModelAllowlist{Enabled: true} + if empty.Allows("anything") { + t.Fatal("enabled empty allowlist must deny all models") + } + }) + + t.Run("bare wildcard allows everything", func(t *testing.T) { + all := GroupModelAllowlist{Enabled: true, Models: []string{"*"}} + if !all.Allows("claude-opus-4.6") || !all.Allows("models/gemini-2.5-pro") { + t.Fatal("bare wildcard must allow all models") + } + }) +} + +func TestGroupModelAllowlistEnabled(t *testing.T) { + var nilGroup *Group + if nilGroup.ModelAllowlistEnabled() { + t.Fatal("nil group must not report enabled") + } + group := &Group{} + if group.ModelAllowlistEnabled() { + t.Fatal("default group must not report enabled") + } + group.ModelAllowlist = GroupModelAllowlist{Enabled: true, Models: []string{"m"}} + if !group.ModelAllowlistEnabled() { + t.Fatal("enabled group must report enabled") + } +} + +func TestGroupModelAllowlistFilterForListing(t *testing.T) { + source := []string{"claude-opus-4.6", "claude-sonnet-4.5", "gpt-5.4", "gpt-5.5-codex", "gpt-5.5-mini", "grok-4.6"} + + t.Run("passthrough when disabled", func(t *testing.T) { + disabled := GroupModelAllowlist{Enabled: false, Models: []string{"gpt-5.4"}} + got := disabled.FilterForListing(source) + if len(got) != len(source) { + t.Fatalf("disabled allowlist must not filter, got %#v", got) + } + }) + + t.Run("exact entries keep entry order and require source membership", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.5-codex", "claude-sonnet-4.5", "claude-haiku-4.5"}} + got := cfg.FilterForListing(source) + want := []string{"gpt-5.5-codex", "claude-sonnet-4.5"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("got %#v want %#v", got, want) + } + }) + + t.Run("bare wildcard expands to the full source list", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"*"}} + got := cfg.FilterForListing([]string{"gpt-5.4"}) + if strings.Join(got, ",") != "gpt-5.4" { + t.Fatalf("bare wildcard must expose the full source, got %#v", got) + } + }) + + t.Run("wildcard entries expand to source order", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"claude-*", "gpt-5.5-*"}} + got := cfg.FilterForListing(source) + want := []string{"claude-opus-4.6", "claude-sonnet-4.5", "gpt-5.5-codex", "gpt-5.5-mini"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("got %#v want %#v", got, want) + } + }) + + t.Run("mixed exact and wildcard dedupes globally", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*", "gpt-5.4"}} + got := cfg.FilterForListing(source) + want := []string{"gpt-5.4", "gpt-5.5-codex", "gpt-5.5-mini"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("got %#v want %#v", got, want) + } + }) + + t.Run("thinking-tolerant exact entries resolve against source", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"claude-sonnet-4.5-thinking"}} + got := cfg.FilterForListing(source) + want := []string{"claude-sonnet-4.5-thinking"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("got %#v want %#v", got, want) + } + }) + + t.Run("exact entries match case-insensitively against source", func(t *testing.T) { + // 归一化保留首次出现的拼写:条目大写、来源小写也必须命中, + // 与 Allows 的大小写不敏感语义保持「准入允许 ⇒ 列表可见」。 + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"GPT-5.4"}} + got := cfg.FilterForListing([]string{"gpt-5.4"}) + want := []string{"GPT-5.4"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("got %#v want %#v", got, want) + } + }) + + t.Run("wildcard source patterns match case-insensitively", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"claude-sonnet-4.20250514"}} + got := cfg.FilterForListing([]string{"Claude-Sonnet-4*"}) + want := []string{"claude-sonnet-4.20250514"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("got %#v want %#v", got, want) + } + }) + + t.Run("wildcard source patterns whitelist exact entries", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"claude-sonnet-4.20250514"}} + got := cfg.FilterForListing([]string{"claude-sonnet-4*"}) + want := []string{"claude-sonnet-4.20250514"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("got %#v want %#v", got, want) + } + }) + + t.Run("empty source yields empty output", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.4"}} + if got := cfg.FilterForListing(nil); len(got) != 0 { + t.Fatalf("expected empty output, got %#v", got) + } + }) + + t.Run("enabled empty config yields empty output", func(t *testing.T) { + cfg := GroupModelAllowlist{Enabled: true} + if got := cfg.FilterForListing(source); len(got) != 0 { + t.Fatalf("expected empty output for enabled empty config, got %#v", got) + } + }) +} diff --git a/backend/internal/service/group_models_list.go b/backend/internal/service/group_models_list.go deleted file mode 100644 index b10de724d9..0000000000 --- a/backend/internal/service/group_models_list.go +++ /dev/null @@ -1,32 +0,0 @@ -package service - -import "strings" - -func normalizeGroupModelsListConfig(cfg GroupModelsListConfig) GroupModelsListConfig { - out := GroupModelsListConfig{Enabled: cfg.Enabled} - if len(cfg.Models) == 0 { - return out - } - - seen := make(map[string]struct{}, len(cfg.Models)) - out.Models = make([]string, 0, len(cfg.Models)) - for _, model := range cfg.Models { - model = strings.TrimSpace(model) - if model == "" { - continue - } - if _, ok := seen[model]; ok { - continue - } - seen[model] = struct{}{} - out.Models = append(out.Models, model) - } - if len(out.Models) == 0 { - out.Models = nil - } - return out -} - -func (g *Group) CustomModelsListEnabled() bool { - return g != nil && g.ModelsListConfig.Enabled && len(g.ModelsListConfig.Models) > 0 -} diff --git a/backend/internal/service/group_service.go b/backend/internal/service/group_service.go index a78b5975be..c46cbf4db4 100644 --- a/backend/internal/service/group_service.go +++ b/backend/internal/service/group_service.go @@ -11,6 +11,7 @@ import ( var ( ErrGroupNotFound = infraerrors.NotFound("GROUP_NOT_FOUND", "group not found") ErrGroupExists = infraerrors.Conflict("GROUP_EXISTS", "group name already exists") + ErrGroupNotEmpty = infraerrors.Conflict("GROUP_NOT_EMPTY", "group contains accounts") ) type GroupRepository interface { @@ -51,6 +52,12 @@ type GroupDuplicateRepository interface { type AdminGroupRepository interface { GroupRepository GroupDuplicateRepository + EmptyGroupDeleteRepository +} + +// EmptyGroupDeleteRepository provides the guarded cascade used by simple mode. +type EmptyGroupDeleteRepository interface { + DeleteCascadeIfEmpty(ctx context.Context, id int64) ([]int64, error) } // GroupSortOrderUpdate 分组排序更新 diff --git a/backend/internal/service/http_upstream_profile.go b/backend/internal/service/http_upstream_profile.go index b2de6002cf..92020abb35 100644 --- a/backend/internal/service/http_upstream_profile.go +++ b/backend/internal/service/http_upstream_profile.go @@ -7,13 +7,15 @@ import "context" type HTTPUpstreamProfile string const ( - HTTPUpstreamProfileDefault HTTPUpstreamProfile = "" - HTTPUpstreamProfileOpenAI HTTPUpstreamProfile = "openai" - HTTPUpstreamProfileGrok HTTPUpstreamProfile = "grok" + HTTPUpstreamProfileDefault HTTPUpstreamProfile = "" + HTTPUpstreamProfileOpenAI HTTPUpstreamProfile = "openai" + HTTPUpstreamProfileGrok HTTPUpstreamProfile = "grok" + HTTPUpstreamProfileLongStream HTTPUpstreamProfile = "long_stream" ) type httpUpstreamProfileContextKey struct{} type httpUpstreamDisableRedirectsContextKey struct{} +type httpUpstreamPublicHostsOnlyContextKey struct{} // WithHTTPUpstreamProfile injects an upstream transport profile into ctx. func WithHTTPUpstreamProfile(ctx context.Context, profile HTTPUpstreamProfile) context.Context { @@ -36,7 +38,7 @@ func HTTPUpstreamProfileFromContext(ctx context.Context) HTTPUpstreamProfile { return HTTPUpstreamProfileDefault } switch profile { - case HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileGrok: + case HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileGrok, HTTPUpstreamProfileLongStream: return profile default: return HTTPUpstreamProfileDefault @@ -55,3 +57,18 @@ func WithHTTPUpstreamRedirectsDisabled(ctx context.Context) context.Context { func HTTPUpstreamRedirectsDisabled(ctx context.Context) bool { return ctx != nil && ctx.Value(httpUpstreamDisableRedirectsContextKey{}) == true } + +// WithHTTPUpstreamPublicHostsOnly marks a request whose destination, and every +// redirect hop after it, must resolve to a public address. The shared upstream +// client enforces it regardless of the security.url_allowlist configuration; +// use it for fetches whose URL comes from an untrusted upstream response. +func WithHTTPUpstreamPublicHostsOnly(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, httpUpstreamPublicHostsOnlyContextKey{}, true) +} + +func HTTPUpstreamPublicHostsOnly(ctx context.Context) bool { + return ctx != nil && ctx.Value(httpUpstreamPublicHostsOnlyContextKey{}) == true +} diff --git a/backend/internal/service/http_upstream_profile_test.go b/backend/internal/service/http_upstream_profile_test.go index 9cd4bf4ff9..38080664cf 100644 --- a/backend/internal/service/http_upstream_profile_test.go +++ b/backend/internal/service/http_upstream_profile_test.go @@ -20,6 +20,13 @@ func TestWithHTTPUpstreamProfile_OpenAI(t *testing.T) { } } +func TestWithHTTPUpstreamProfile_LongStream(t *testing.T) { + ctx := WithHTTPUpstreamProfile(context.TODO(), HTTPUpstreamProfileLongStream) + if profile := HTTPUpstreamProfileFromContext(ctx); profile != HTTPUpstreamProfileLongStream { + t.Fatalf("expected profile %q, got %q", HTTPUpstreamProfileLongStream, profile) + } +} + func TestWithHTTPUpstreamRedirectsDisabled(t *testing.T) { //nolint:staticcheck // Exercises the defensive nil-context fallback. ctx := WithHTTPUpstreamRedirectsDisabled(nil) @@ -30,3 +37,17 @@ func TestWithHTTPUpstreamRedirectsDisabled(t *testing.T) { t.Fatal("redirects should remain enabled by default") } } + +func TestWithHTTPUpstreamPublicHostsOnly(t *testing.T) { + //nolint:staticcheck // Exercises the defensive nil-context fallback. + ctx := WithHTTPUpstreamPublicHostsOnly(nil) + if !HTTPUpstreamPublicHostsOnly(ctx) { + t.Fatal("expected public-hosts-only marker to be set") + } + if HTTPUpstreamPublicHostsOnly(context.Background()) { + t.Fatal("marker must be absent by default") + } + if HTTPUpstreamRedirectsDisabled(ctx) { + t.Fatal("public-hosts-only must not disable redirects") + } +} diff --git a/backend/internal/service/identity_service.go b/backend/internal/service/identity_service.go index afa882a9c9..d117f720cc 100644 --- a/backend/internal/service/identity_service.go +++ b/backend/internal/service/identity_service.go @@ -28,6 +28,10 @@ var ( // /.. 之后必须紧跟空白或字符串结束。 // 版本号带 -local / -dev / +build 等后缀的本地构建一律不接受。 fingerprintUserAgentPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+/\d+\.\d+\.\d+(\s|$)`) + + // claudeCLIUAVersionPrefixRegex 匹配 claude-cli UA 开头的 "claude-cli/x.y.z" 版本号段, + // 供版本下限抬升时就地替换版本号使用;UA 其余部分(括号内的真实客户端形态等)原样保留。 + claudeCLIUAVersionPrefixRegex = regexp.MustCompile(`(?i)^(claude-cli)/\d+\.\d+\.\d+`) ) const ( @@ -36,7 +40,7 @@ const ( // maxFingerprintUserAgentLength 限制写入缓存的 User-Agent 长度。 maxFingerprintUserAgentLength = 256 // maxClaudeCLIMajorVersionSkew 是 claude-cli 主版本号相对 sub2api 自身伪装 - // 版本(claude.CLICurrentVersion)允许的最大超前量。给足两个大版本的升级 + // 版本(claude.CLIVersion())允许的最大超前量。给足两个大版本的升级 // 窗口,同时挡掉 999 这类哨兵版本号。 maxClaudeCLIMajorVersionSkew = 2 ) @@ -68,16 +72,48 @@ func isAcceptableFingerprintUserAgent(ua string) bool { if !ok { return false } - currentMajor, _, _, currentOK := parseUserAgentVersion(claudeCLIUserAgentProduct + "/" + claude.CLICurrentVersion) + currentMajor, _, _, currentOK := parseUserAgentVersion(claudeCLIUserAgentProduct + "/" + claude.CLIVersion()) if !currentOK { return true } return major <= currentMajor+maxClaudeCLIMajorVersionSkew } +// floorClaudeCLIUserAgentVersion 把 claude-cli UA 中的版本号抬升到 claude.CLICurrentVersion +// 下限:低于下限时就地升到下限并返回 changed=true,否则原样返回。 +// +// 为什么需要这条:账号级指纹"只升不降"、活跃账号懒续期后近乎永不过期,且系统内没有重置 +// 入口,defaultFingerprint 只在首次创建指纹时使用。存量账号缓存里的 claude-cli 版本停留在 +// 历史值(如 claude-cli/2.1.220),客户端送来更旧的版本时 isNewerVersion 不会触发升级—— +// 仅升 CLICurrentVersion 常量对所有已有账号完全无效,上游按指纹 UA 做客户端版本闸门 +// (如 Fable 5.1 要求 >= 2.1.251)时旧指纹永远过不去。 +// +// 约束: +// - 只对 claude-cli 产品生效,其它产品一律不动,避免误伤别的合法客户端; +// - 只升不降:版本等于或高于 CLICurrentVersion(含客户端上报的更新版本)时不做任何改动; +// - 只替换 claude-cli/ 后的版本号段,UA 其余部分(如 "(external, claude-desktop-3p, +// agent-sdk/0.3.100)")原样保留,不重建整个字符串、不退化为 defaultFingerprint.UserAgent; +// - X-Stainless-* 字段不在此处理,维持调用方的既有 merge 语义。 +func floorClaudeCLIUserAgentVersion(ua string) (string, bool) { + if extractProduct(ua) != claudeCLIUserAgentProduct { + return ua, false + } + floorUA := claudeCLIUserAgentProduct + "/" + claude.CLICurrentVersion + // isNewerVersion(floor, ua) 为 true 当且仅当下限版本严格高于 ua: + // ua 等于或高于下限、产品名不一致、或版本无法解析时都不做改动。 + if !isNewerVersion(floorUA, ua) { + return ua, false + } + floored := claudeCLIUAVersionPrefixRegex.ReplaceAllString(ua, "${1}/"+claude.CLICurrentVersion) + if floored == ua { + return ua, false + } + return floored, true +} + // 默认指纹值(当客户端未提供时使用) var defaultFingerprint = Fingerprint{ - UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)", + UserAgent: "claude-cli/" + claude.CLIVersion() + " (external, cli)", StainlessLang: "js", StainlessPackageVersion: "0.94.0", StainlessOS: "Linux", @@ -157,12 +193,29 @@ func (s *IdentityService) GetOrCreateFingerprint(ctx context.Context, accountID logger.LegacyPrintf("service.identity", "Replaced malformed cached fingerprint for account %d: %q -> %q", accountID, poisoned, cached.UserAgent) - } else if uaAcceptable && isNewerVersion(clientUA, cached.UserAgent) { - // 版本升级:merge 语义 — 仅更新请求中实际携带的字段,保留缓存值 - // 避免缺失的头被硬编码默认值覆盖(如新 CLI 版本 + 旧 SDK 默认值的不一致) - mergeHeadersIntoFingerprint(cached, headers) + } else { + // 客户端送来更新版本时的常规升级:merge 语义 — 仅更新请求中实际携带的字段, + // 保留缓存值,避免缺失的头被硬编码默认值覆盖(如新 CLI 版本 + 旧 SDK 默认值的不一致) + if uaAcceptable && isNewerVersion(clientUA, cached.UserAgent) { + mergeHeadersIntoFingerprint(cached, headers) + needWrite = true + logger.LegacyPrintf("service.identity", "Updated fingerprint for account %d: %s (merge update)", accountID, clientUA) + } + + } + + // 版本下限抬升(floor):heal 与 healthy 两条缓存命中路径共同的后置条件,与 + // 上面的客户端常规升级相互独立、二者取更新者。客户端送来更旧版本时 + // isNewerVersion 不触发,此处仍能把低于 CLICurrentVersion 的存量指纹就地抬到 + // 下限并持久化,否则升 CLICurrentVersion 对所有已有账号无效,新模型的客户端 + // 版本闸门(如 Fable 5.1 要求 >= 2.1.251)永远过不去。自愈路径同样必须经过: + // 畸形缓存 + 合法但过旧的客户端 UA 首次读取就要落到下限,不能先落库一个 + // 过旧版本、等下一次读取才纠正。 + if flooredUA, changed := floorClaudeCLIUserAgentVersion(cached.UserAgent); changed { + cached.UserAgent = flooredUA needWrite = true - logger.LegacyPrintf("service.identity", "Updated fingerprint for account %d: %s (merge update)", accountID, clientUA) + logger.LegacyPrintf("service.identity", + "Floored cached fingerprint claude-cli version for account %d: %s", accountID, flooredUA) } if !needWrite && time.Since(time.Unix(cached.UpdatedAt, 0)) > 24*time.Hour { @@ -208,7 +261,9 @@ func (s *IdentityService) createFingerprintFromHeaders(headers http.Header) *Fin // 获取User-Agent:只接受形态合法且版本合理的值,否则回退默认指纹。 // 首次创建同样是持久化写入,必须与升级路径共用同一套校验。 if ua := strings.TrimSpace(headers.Get("User-Agent")); isAcceptableFingerprintUserAgent(ua) { - fp.UserAgent = ua + // 首次创建与缓存命中路径共用同一个版本下限:合法但过旧的 claude-cli UA + // 落库时同样不能低于 CLICurrentVersion,否则新账号一开始就带着过旧的持久身份。 + fp.UserAgent, _ = floorClaudeCLIUserAgentVersion(ua) } else { fp.UserAgent = defaultFingerprint.UserAgent } diff --git a/backend/internal/service/identity_service_user_agent_validation_test.go b/backend/internal/service/identity_service_user_agent_validation_test.go index 5206eb04f0..1a52d12554 100644 --- a/backend/internal/service/identity_service_user_agent_validation_test.go +++ b/backend/internal/service/identity_service_user_agent_validation_test.go @@ -105,8 +105,9 @@ func TestGetOrCreateFingerprintRejectsMalformedUserAgentOnCreate(t *testing.T) { // 升级路径:哨兵版本号不得覆盖已缓存的真实指纹。 // isNewerVersion 是纯数值比较,999.0.0 恒大于任何真实版本,一旦写入永远无法夺回。 func TestGetOrCreateFingerprintRejectsSentinelVersionOnUpgrade(t *testing.T) { + // 缓存版本取 CLICurrentVersion(>= 下限),使本用例只验证哨兵拒绝,不与版本下限抬升耦合。 cached := &Fingerprint{ - UserAgent: "claude-cli/2.1.22 (external, cli)", + UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)", ClientID: "cid-1", UpdatedAt: time.Now().Unix(), } @@ -119,7 +120,7 @@ func TestGetOrCreateFingerprintRejectsSentinelVersionOnUpgrade(t *testing.T) { ) require.NoError(t, err) - require.Equal(t, "claude-cli/2.1.22 (external, cli)", fp.UserAgent, + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent, "真实指纹不得被哨兵版本覆盖") require.Zero(t, cache.setCalls, "被拒的 UA 不应触发任何写入") } @@ -133,7 +134,9 @@ func TestGetOrCreateFingerprintStillUpgradesOnValidNewerVersion(t *testing.T) { }} svc := NewIdentityService(cache) - newUA := "claude-cli/2.1.223 (external, cli)" + // 客户端版本取高于 CLICurrentVersion 的合法值,使本用例只验证常规升级, + // 不与版本下限抬升耦合(低于下限的客户端升级由 version_floor 测试覆盖)。 + newUA := "claude-cli/2.9.0 (external, cli)" fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(newUA)) require.NoError(t, err) @@ -176,8 +179,10 @@ func TestGetOrCreateFingerprintHealsPoisonedCacheUsingValidClientUA(t *testing.T fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(realUA)) require.NoError(t, err) - require.Equal(t, realUA, fp.UserAgent, - "真实客户端必须能从被毒化的指纹手中夺回账号身份") + // 真实客户端从被毒化的指纹手中夺回账号身份,但版本下限在自愈的同一读取内生效: + // 合法而过旧的 UA 不能先以过旧版本落库、等下次读取才被抬升。 + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent) + require.Equal(t, fp.UserAgent, cache.lastSet.UserAgent, "自愈与下限抬升应合并为同一次落库") require.Equal(t, 1, cache.setCalls) require.NotContains(t, cache.lastSet.UserAgent, "999.0.0") require.Equal(t, "cid-1", fp.ClientID, "自愈不应重置 ClientID") @@ -203,9 +208,10 @@ func TestGetOrCreateFingerprintHealsPoisonedCacheWithoutValidClientUA(t *testing } // 自愈只针对畸形缓存:合法缓存 + 非更新版本的合法 UA 不得触发额外写入。 +// 缓存版本取 CLICurrentVersion(>= 下限),低于下限的缓存抬升由 version_floor 测试覆盖。 func TestGetOrCreateFingerprintDoesNotRewriteHealthyCache(t *testing.T) { cache := &stubIdentityCache{fingerprint: &Fingerprint{ - UserAgent: "claude-cli/2.1.220 (external, cli)", + UserAgent: "claude-cli/" + claude.CLICurrentVersion + " (external, cli)", ClientID: "cid-1", UpdatedAt: time.Now().Unix(), }} @@ -217,7 +223,7 @@ func TestGetOrCreateFingerprintDoesNotRewriteHealthyCache(t *testing.T) { ) require.NoError(t, err) - require.Equal(t, "claude-cli/2.1.220 (external, cli)", fp.UserAgent) + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent) require.Zero(t, cache.setCalls) } diff --git a/backend/internal/service/identity_service_version_floor_test.go b/backend/internal/service/identity_service_version_floor_test.go new file mode 100644 index 0000000000..78a15627c4 --- /dev/null +++ b/backend/internal/service/identity_service_version_floor_test.go @@ -0,0 +1,146 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" +) + +// floorClaudeCLIUserAgentVersion 单元测试:版本下限抬升的各种形态。 +func TestFloorClaudeCLIUserAgentVersion(t *testing.T) { + floorUA := "claude-cli/" + claude.CLICurrentVersion + cases := []struct { + name string + ua string + want string + wantChanged bool + }{ + // 线上故障形态:历史版本低于 CLICurrentVersion,就地抬到下限。 + {"old_version_upgraded", "claude-cli/2.1.220 (external, cli)", floorUA + " (external, cli)", true}, + // 只替换版本号段,括号内的真实客户端形态原样保留。 + {"old_version_with_desktop_3p_suffix", + "claude-cli/2.1.100 (external, claude-desktop-3p, agent-sdk/0.3.100)", + floorUA + " (external, claude-desktop-3p, agent-sdk/0.3.100)", true}, + // 等于下限:不得改动。 + {"equal_to_floor", floorUA + " (external, cli)", floorUA + " (external, cli)", false}, + // 高于下限:只升不降,不得把客户端上报的更新版本压回去。 + {"newer_than_floor_not_downgraded", "claude-cli/2.9.0 (external, cli)", "claude-cli/2.9.0 (external, cli)", false}, + // 非 claude-cli 产品:一律不动。 + {"other_product_untouched", "opencode/1.2.3 (external, cli)", "opencode/1.2.3 (external, cli)", false}, + // 空串 / 畸形:一律不动。 + {"empty", "", "", false}, + {"no_version", "claude-cli (external, cli)", "claude-cli (external, cli)", false}, + {"unparseable_version", "claude-cli/abc (external, cli)", "claude-cli/abc (external, cli)", false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, changed := floorClaudeCLIUserAgentVersion(tc.ua) + require.Equal(t, tc.want, got) + require.Equal(t, tc.wantChanged, changed) + }) + } +} + +// GetOrCreateFingerprint 集成行为,直接对应线上故障:缓存指纹停留在历史版本 +// 2.1.220(生产 Redis 中账号 147 的实际值),客户端送来更旧的 2.1.75。 +// 修复前:isNewerVersion 不触发、UA 形态合法不触发自愈,旧指纹被原样返回并 +// 近乎永不过期——上游按指纹 UA 做客户端版本闸门(Fable 5.1 要求 >= 2.1.251), +// 仅升 CLICurrentVersion 对存量账号完全无效。 +func TestGetOrCreateFingerprintFloorsStaleCachedUserAgent(t *testing.T) { + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/2.1.220 (external, cli)", + ClientID: "cid-1", + StainlessPackageVersion: "0.91.1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 147, + headersWithUA("claude-cli/2.1.75 (external, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent) + require.Equal(t, 1, cache.setCalls, "下限抬升必须持久化写回缓存") + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", cache.lastSet.UserAgent) + // X-Stainless-* 维持既有 merge 语义:客户端未携带时保留缓存中的真实值,不被下限逻辑覆盖。 + require.Equal(t, "0.91.1", fp.StainlessPackageVersion) +} + +// 缓存版本高于下限:不得被降级,也不得触发多余写入。 +func TestGetOrCreateFingerprintDoesNotTouchCacheAboveFloor(t *testing.T) { + ua := "claude-cli/2.9.0 (external, cli)" + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: ua, + ClientID: "cid-1", + StainlessPackageVersion: "0.91.1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/2.1.75 (external, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, ua, fp.UserAgent, "高于下限的缓存版本不得被降级") + require.Zero(t, cache.setCalls) +} + +// 客户端送来高于下限的更新版本:常规升级照常生效,且不被下限压回。 +func TestGetOrCreateFingerprintClientNewerThanFloorStillWins(t *testing.T) { + newUA := "claude-cli/2.9.0 (external, cli)" + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/2.1.220 (external, cli)", + ClientID: "cid-1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint(context.Background(), 1, headersWithUA(newUA)) + + require.NoError(t, err) + require.Equal(t, newUA, fp.UserAgent, "客户端更新版本优先于下限,不得被压回") + require.Equal(t, 1, cache.setCalls) +} + +// 客户端版本高于缓存但低于下限:merge 升级后仍被抬到下限(二者取更新者)。 +func TestGetOrCreateFingerprintFloorWinsOverStaleClientUpgrade(t *testing.T) { + cache := &stubIdentityCache{fingerprint: &Fingerprint{ + UserAgent: "claude-cli/2.1.22 (external, cli)", + ClientID: "cid-1", + UpdatedAt: time.Now().Unix(), + }} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/2.1.223 (external, cli)"), + ) + + require.NoError(t, err) + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, cli)", fp.UserAgent) + require.Equal(t, 1, cache.setCalls) +} + +// 首次创建路径同样受下限约束:合法但过旧的 claude-cli UA 落库时必须抬到 CLICurrentVersion。 +func TestCreateFingerprintFromHeadersFloorsOldClientUserAgent(t *testing.T) { + cache := &stubIdentityCache{} + svc := NewIdentityService(cache) + + fp, err := svc.GetOrCreateFingerprint( + context.Background(), 1, + headersWithUA("claude-cli/2.1.75 (external, claude-desktop-3p)"), + ) + + require.NoError(t, err) + require.Equal(t, "claude-cli/"+claude.CLICurrentVersion+" (external, claude-desktop-3p)", fp.UserAgent) + require.Equal(t, 1, cache.setCalls) +} diff --git a/backend/internal/service/json_time.go b/backend/internal/service/json_time.go new file mode 100644 index 0000000000..7dae95af48 --- /dev/null +++ b/backend/internal/service/json_time.go @@ -0,0 +1,9 @@ +package service + +import "time" + +// isJSONTimeInRange rejects dates that PostgreSQL can store but time.Time cannot +// serialize in API responses. Nil represents an unset schedule or expiry. +func isJSONTimeInRange(value *time.Time) bool { + return value == nil || (value.Year() >= 0 && value.Year() <= 9999) +} diff --git a/backend/internal/service/json_time_validation_test.go b/backend/internal/service/json_time_validation_test.go new file mode 100644 index 0000000000..4ff05f8c2e --- /dev/null +++ b/backend/internal/service/json_time_validation_test.go @@ -0,0 +1,60 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestAnnouncementServiceRejectsOutOfRangeDates(t *testing.T) { + for _, year := range []int{-1, 10000} { + date := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC) + for _, field := range []string{"start", "end"} { + t.Run(date.String()+field, func(t *testing.T) { + repo := &announcementRepoStub{} + svc := NewAnnouncementService(repo, nil, nil, nil) + input := &CreateAnnouncementInput{Title: "test", Content: "test"} + if field == "start" { + input.StartsAt = &date + } else { + input.EndsAt = &date + } + _, err := svc.Create(context.Background(), input) + require.ErrorIs(t, err, ErrAnnouncementInvalidSchedule) + require.Nil(t, repo.item, "invalid dates must never reach persistence") + + repo.item = &Announcement{ID: 1, Title: "test", Content: "test"} + update := &UpdateAnnouncementInput{} + ptr := &date + if field == "start" { + update.StartsAt = &ptr + } else { + update.EndsAt = &ptr + } + _, err = svc.Update(context.Background(), 1, update) + require.ErrorIs(t, err, ErrAnnouncementInvalidSchedule) + require.Nil(t, repo.item.StartsAt) + require.Nil(t, repo.item.EndsAt) + }) + } + } +} + +func TestAnnouncementServiceAcceptsJSONDateBoundariesAndClear(t *testing.T) { + start := time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC) + end := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC) + repo := &announcementRepoStub{} + svc := NewAnnouncementService(repo, nil, nil, nil) + a, err := svc.Create(context.Background(), &CreateAnnouncementInput{Title: "test", Content: "test", StartsAt: &start, EndsAt: &end}) + require.NoError(t, err) + _, err = json.Marshal(a) + require.NoError(t, err) + var clear *time.Time + a, err = svc.Update(context.Background(), 1, &UpdateAnnouncementInput{StartsAt: &clear, EndsAt: &clear}) + require.NoError(t, err) + require.Nil(t, a.StartsAt) + require.Nil(t, a.EndsAt) +} diff --git a/backend/internal/service/model_not_found_error_test.go b/backend/internal/service/model_not_found_error_test.go index 2f8c83466f..d76d71d0f1 100644 --- a/backend/internal/service/model_not_found_error_test.go +++ b/backend/internal/service/model_not_found_error_test.go @@ -112,3 +112,29 @@ func TestIsOpenAICodexPlanGatedModelError(t *testing.T) { }) } } + +func TestIsOpenAICompatibleModelNotFound400(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + {name: "structured code", body: `{"error":{"code":"model_not_found","message":"No such deployment"}}`, want: true}, + {name: "unknown provider", body: `{"error":{"message":"Unknown provider for model claude-x"}}`, want: true}, + {name: "model not found", body: `{"error":{"message":"Model not found: claude-x"}}`, want: true}, + {name: "model unsupported", body: `{"error":{"message":"The requested model is not supported"}}`, want: true}, + {name: "plain text compatible gateway", body: `unknown provider for model claude-x`, want: true}, + {name: "invalid parameter remains terminal", body: `{"error":{"code":"invalid_request_error","message":"Invalid value for temperature"}}`, want: false}, + {name: "structured non-matching code overrides model not found message", body: `{"error":{"code":"invalid_request_error","message":"Model not found: claude-x"}}`, want: false}, + {name: "structured non-matching error code overrides unsupported message", body: `{"error":{"code":"upstream_error","message":"The requested model is not supported"}}`, want: false}, + {name: "echoed phrase is ignored", body: `{"error":{"message":"Invalid request"},"echo":"model not found"}`, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isOpenAICompatibleModelNotFound400([]byte(tt.body)); got != tt.want { + t.Fatalf("isOpenAICompatibleModelNotFound400() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/backend/internal/service/model_plaza_service.go b/backend/internal/service/model_plaza_service.go index fe2252c594..b1618acf78 100644 --- a/backend/internal/service/model_plaza_service.go +++ b/backend/internal/service/model_plaza_service.go @@ -231,7 +231,7 @@ func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaMode Platform: m.Platform, }) if err == nil && sched != nil && len(sched.Tiers) > 0 { - m.Pricing = plazaPricingFromSchedule(m.Pricing, sched) + m.Pricing = withDefaultMaxReasoningEffortMultiplier(plazaPricingFromSchedule(m.Pricing, sched), m.Name) if len(sched.Tiers) > 1 { m.LongContextBasis = sched.Basis } @@ -239,7 +239,20 @@ func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaMode return } } - m.Pricing = plazaImageDisplayPricing(m.Pricing, g) + m.Pricing = withDefaultMaxReasoningEffortMultiplier(plazaImageDisplayPricing(m.Pricing, g), m.Name) +} + +func withDefaultMaxReasoningEffortMultiplier(pricing *ChannelModelPricing, model string) *ChannelModelPricing { + if pricing == nil || pricing.MaxReasoningEffortMultiplier != nil { + return pricing + } + multiplier := defaultMaxReasoningEffortMultiplier(model) + if multiplier == nil { + return pricing + } + cloned := pricing.Clone() + cloned.MaxReasoningEffortMultiplier = multiplier + return &cloned } // plazaPricingFromSchedule 把阶梯表压成展示用的 ChannelModelPricing: @@ -250,11 +263,13 @@ func plazaPricingFromSchedule(raw *ChannelModelPricing, sched *ContextPricingSch out.ImageInputPrice = raw.ImageInputPrice out.ImageOutputPrice = raw.ImageOutputPrice out.PerRequestPrice = raw.PerRequestPrice + out.MaxReasoningEffortMultiplier = raw.MaxReasoningEffortMultiplier } first := sched.Tiers[0] out.InputPrice = first.Input out.OutputPrice = first.Output out.CacheWritePrice = first.CacheWrite + out.CacheWrite1hPrice = first.CacheWrite1h out.CacheReadPrice = first.CacheRead if len(sched.Tiers) > 1 { out.Intervals = plazaIntervalsFromTiers(sched.Tiers) @@ -266,14 +281,15 @@ func plazaIntervalsFromTiers(tiers []ContextPricingTier) []PricingInterval { intervals := make([]PricingInterval, 0, len(tiers)) for i, t := range tiers { intervals = append(intervals, PricingInterval{ - MinTokens: t.MinTokens, - MaxTokens: t.MaxTokens, - TierLabel: t.Label, - InputPrice: t.Input, - OutputPrice: t.Output, - CacheWritePrice: t.CacheWrite, - CacheReadPrice: t.CacheRead, - SortOrder: i, + MinTokens: t.MinTokens, + MaxTokens: t.MaxTokens, + TierLabel: t.Label, + InputPrice: t.Input, + OutputPrice: t.Output, + CacheWritePrice: t.CacheWrite, + CacheWrite1hPrice: t.CacheWrite1h, + CacheReadPrice: t.CacheRead, + SortOrder: i, }) } return intervals diff --git a/backend/internal/service/model_plaza_service_test.go b/backend/internal/service/model_plaza_service_test.go index 4a588ecb0f..e7c5f4ffb9 100644 --- a/backend/internal/service/model_plaza_service_test.go +++ b/backend/internal/service/model_plaza_service_test.go @@ -57,6 +57,19 @@ func TestListPlazaGroups_GroupCentricAggregation(t *testing.T) { require.Equal(t, "claude-sonnet", out[0].Models[1].Name) } +func TestWithDefaultMaxReasoningEffortMultiplier_Fable51(t *testing.T) { + base := &ChannelModelPricing{BillingMode: BillingModeToken} + got := withDefaultMaxReasoningEffortMultiplier(base, "claude-fable-5-1") + require.NotSame(t, base, got) + require.NotNil(t, got.MaxReasoningEffortMultiplier) + require.Equal(t, 3.0, *got.MaxReasoningEffortMultiplier) + require.Nil(t, base.MaxReasoningEffortMultiplier) + + configured := 1.25 + custom := &ChannelModelPricing{MaxReasoningEffortMultiplier: &configured} + require.Same(t, custom, withDefaultMaxReasoningEffortMultiplier(custom, "claude-fable-5-1")) +} + func TestListPlazaGroups_DedupFirstWinsWithPricingUpgrade(t *testing.T) { // 同名模型:先见者胜;仅当已存条目无定价而新条目有定价时升级替换。 unpriced := Channel{ @@ -339,7 +352,7 @@ func newPlazaServiceWithBilling(channels []Channel, groups []Group, groupPlatfor return groupPlatforms, nil }, } - cs := NewChannelService(repo, nil, nil, nil) + cs := NewChannelService(repo, nil, nil, nil, nil) bs := NewBillingService(&config.Config{}, catalog) return NewModelPlazaService(repo, &stubGroupRepoForAvailable{activeGroups: groups}, catalog, bs, NewModelPricingResolver(cs, bs)) } diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index 1a5382e7c3..ecd3d45209 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -247,6 +247,9 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin } resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier + if chPricing.MaxReasoningEffortMultiplier != nil { + resolved.BasePricing.MaxReasoningEffortMultiplier = chPricing.MaxReasoningEffortMultiplier + } // 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM) if chPricing.ImageOutputPrice != nil { resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice diff --git a/backend/internal/service/model_pricing_resolver_test.go b/backend/internal/service/model_pricing_resolver_test.go index 3fb3e1c63f..a0373a1247 100644 --- a/backend/internal/service/model_pricing_resolver_test.go +++ b/backend/internal/service/model_pricing_resolver_test.go @@ -230,7 +230,7 @@ func newResolverWithChannel(t *testing.T, pricing []ChannelModelPricing) *ModelP return map[int64]string{groupID: "anthropic"}, nil }, } - cs := NewChannelService(repo, nil, nil, nil) + cs := NewChannelService(repo, nil, nil, nil, nil) bs := newTestBillingServiceForResolver() return NewModelPricingResolver(cs, bs) } @@ -566,7 +566,7 @@ func TestResolve_WithChannelOverride_CacheError(t *testing.T) { return nil, errors.New("database unavailable") }, } - cs := NewChannelService(repo, nil, nil, nil) + cs := NewChannelService(repo, nil, nil, nil, nil) bs := newTestBillingServiceForResolver() r := NewModelPricingResolver(cs, bs) diff --git a/backend/internal/service/ollama_cloud_messages_max_tokens.go b/backend/internal/service/ollama_cloud_messages_max_tokens.go new file mode 100644 index 0000000000..d178488460 --- /dev/null +++ b/backend/internal/service/ollama_cloud_messages_max_tokens.go @@ -0,0 +1,28 @@ +package service + +import ( + "strings" + + "github.com/tidwall/gjson" +) + +// clampOllamaCloudAnthropicMessagesMaxTokens 是 Anthropic Messages 出站 +// (/v1/messages,含 CC / Responses 桥接与 passthrough)的 max_tokens clamp 钩子, +// 与 raw CC / Responses 路径共用 cap 配置与实现。判定与出站 URL 组装同源:调用方 +// 传入本次实际选用的 Anthropic 上游 base_url(GetBaseURL 或 +// GetAnthropicProtocolBaseURL,不用 GetOpenAIBaseURL——adaptive 时那是 CC/Responses +// 地址),与真实出站组装一样先 TrimRight "/"(urlvalidator 与 +// nativeAnthropicTargetURL 同款归一化),仅当其为 ollama.com 且 body 中映射后出站 +// model 为 DeepSeek 系时压到 cap,否则原样返回。 +func clampOllamaCloudAnthropicMessagesMaxTokens(account *Account, baseURL string, body []byte) []byte { + if account == nil || len(body) == 0 { + return body + } + if !isOllamaCloudBaseURL(strings.TrimRight(strings.TrimSpace(baseURL), "/")) { + return body + } + if !isDeepSeekModel(gjson.GetBytes(body, "model").String()) { + return body + } + return clampOllamaCloudMaxTokens(account, body) +} diff --git a/backend/internal/service/ollama_cloud_messages_max_tokens_test.go b/backend/internal/service/ollama_cloud_messages_max_tokens_test.go new file mode 100644 index 0000000000..66b3e6e157 --- /dev/null +++ b/backend/internal/service/ollama_cloud_messages_max_tokens_test.go @@ -0,0 +1,342 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// Anthropic Messages 出站(三个 builder)的 Ollama Cloud DeepSeek max_tokens clamp +// 接线验证。实测背景:POST ollama.com/v1/messages(Bearer)max_tokens=256000 被上游 +// 以 "max_tokens (256000) exceeds model's maximum output tokens (65536)" 400 拒绝。 +// 用例直接调用各 builder 实际生成 *http.Request,同时检查出站 URL 与 wire body。 + +func messagesClampTestConfig() *config.Config { + cfg := rawChatCompletionsTestConfig() + cfg.Gateway = config.GatewayConfig{MaxLineSize: defaultMaxLineSize} + return cfg +} + +func newMessagesClampTestContext(t *testing.T) *gin.Context { + t.Helper() + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + return c +} + +// messagesClampOllamaAccount 构造挂在实际 ollama.com 上游的 APIKey 账号。 +// builder A / B 的 Messages base 取 GetBaseURL()(凭证 base_url),builder C 的 +// Anthropic 协议 base 取 GetAnthropicProtocolBaseURL()(anthropic 协议时同为 +// 凭证 base_url)。 +func messagesClampOllamaAccount(id int64, platform string) *Account { + return &Account{ + ID: id, + Name: "ollama-cloud-messages", + Platform: platform, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://ollama.com", + }, + Extra: map[string]any{}, + } +} + +func messagesClampBody(model string, maxTokens int) []byte { + return []byte(`{"model":"` + model + `","max_tokens":` + strconv.Itoa(maxTokens) + + `,"stream":false,"messages":[{"role":"user","content":"hi"}]}`) +} + +// TestBuildUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens 覆盖 GatewayService +// .buildUpstreamRequest(Anthropic 平台原生 Messages + 共享重试路径)。 +func TestBuildUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens(t *testing.T) { + svc := &GatewayService{cfg: messagesClampTestConfig()} + c := newMessagesClampTestContext(t) + + body := messagesClampBody("deepseek-v4-flash", 256000) + account := messagesClampOllamaAccount(401, PlatformAnthropic) + + req, wireBody, err := svc.buildUpstreamRequest( + context.Background(), c, account, body, "sk-test", "api_key", + "deepseek-v4-flash", false, false, + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String()) + require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(wireBody, "model").String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + + t.Run("official anthropic base is untouched", func(t *testing.T) { + official := messagesClampOllamaAccount(402, PlatformAnthropic) + official.Credentials["base_url"] = "https://api.anthropic.com" + _, wire, err := svc.buildUpstreamRequest( + context.Background(), c, official, body, "sk-test", "api_key", + "deepseek-v4-flash", false, false, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int()) + }) + + t.Run("non deepseek model untouched", func(t *testing.T) { + nonDeepSeek := messagesClampBody("k3-256k", 256000) + _, wire, err := svc.buildUpstreamRequest( + context.Background(), c, account, nonDeepSeek, "sk-test", "api_key", + "k3-256k", false, false, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int()) + }) + + t.Run("at cap and extra cap zero untouched", func(t *testing.T) { + atCap := messagesClampBody("deepseek-v4-flash", 65535) + _, wire, err := svc.buildUpstreamRequest( + context.Background(), c, account, atCap, "sk-test", "api_key", + "deepseek-v4-flash", false, false, + ) + require.NoError(t, err) + require.Equal(t, int64(65535), gjson.GetBytes(wire, "max_tokens").Int()) + + disabled := messagesClampOllamaAccount(403, PlatformAnthropic) + disabled.Extra[OllamaCloudMaxTokensCapExtraKey] = 0 + _, wire, err = svc.buildUpstreamRequest( + context.Background(), c, disabled, body, "sk-test", "api_key", + "deepseek-v4-flash", false, false, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int()) + }) +} + +// TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_ClampsOllamaCloudDeepSeekMaxTokens +// 覆盖 Anthropic 平台 APIKey passthrough builder。 +func TestBuildUpstreamRequestAnthropicAPIKeyPassthrough_ClampsOllamaCloudDeepSeekMaxTokens(t *testing.T) { + svc := &GatewayService{cfg: messagesClampTestConfig()} + c := newMessagesClampTestContext(t) + + body := messagesClampBody("deepseek-v4-flash", 256000) + account := messagesClampOllamaAccount(411, PlatformAnthropic) + + req, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough( + context.Background(), c, account, body, "sk-test", + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + + // 官方 Anthropic(api.anthropic.com)即使 max_tokens 超过 65535 也字节级不变。 + official := newAnthropicAPIKeyAccountForTest() + _, wire, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough( + context.Background(), c, official, body, "upstream-anthropic-key", + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wire, "max_tokens").Int()) +} + +// TestBuildNativeAnthropicUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens 覆盖 +// 国产供应商原生 Anthropic 协议 builder(Messages / Responses / CC 桥接共享)。 +func TestBuildNativeAnthropicUpstreamRequest_ClampsOllamaCloudDeepSeekMaxTokens(t *testing.T) { + svc := &OpenAIGatewayService{cfg: messagesClampTestConfig()} + c := newMessagesClampTestContext(t) + + body := messagesClampBody("deepseek-v4-flash", 256000) + + t.Run("anthropic protocol ollama base is clamped", func(t *testing.T) { + account := messagesClampOllamaAccount(421, PlatformDeepseek) + account.Credentials["api_protocol"] = APIProtocolAnthropic + + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages", targetURL) + + req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, body, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages", req.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + // adaptive 账号按 Anthropic 协议地址(api_base_urls[anthropic])判定,而非 CC + // 地址:anthropic 指向 ollama.com、chat_completions 指向官方 DeepSeek 时命中。 + t.Run("adaptive uses anthropic base not cc base", func(t *testing.T) { + account := messagesClampOllamaAccount(422, PlatformDeepseek) + account.Credentials["api_protocol"] = APIProtocolAdaptive + account.Credentials["api_base_urls"] = map[string]any{ + APIProtocolAnthropic: "https://ollama.com", + APIProtocolChatCompletions: "https://api.deepseek.com", + } + + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, body, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages", req.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + // 反向:实际 Anthropic 上游非 ollama.com(官方 anthropic 端点),即使 CC 地址 + // 指向 ollama.com 且残留 usage extra,也不 clamp。 + t.Run("non-ollama anthropic base with cc ollama base and usage extra untouched", func(t *testing.T) { + account := messagesClampOllamaAccount(423, PlatformDeepseek) + account.Credentials["api_protocol"] = APIProtocolAdaptive + account.Credentials["api_base_urls"] = map[string]any{ + APIProtocolAnthropic: "https://api.deepseek.com/anthropic", + APIProtocolChatCompletions: "https://ollama.com", + } + account.Extra[OllamaCloudUsageSnapshotExtraKey] = map[string]any{"status": "ok"} + + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, body, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, "https://api.deepseek.com/anthropic/v1/messages", req.URL.String()) + require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + // 官方 DeepSeek Anthropic 协议(平台默认端点)字节级不变。 + t.Run("official deepseek anthropic default untouched", func(t *testing.T) { + account := &Account{ + ID: 424, + Name: "official-deepseek-anthropic", + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "api_protocol": APIProtocolAnthropic, + }, + Extra: map[string]any{}, + } + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + _, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, body, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + // 非 DeepSeek 模型(映射后出站 model)不变。 + t.Run("non deepseek model untouched", func(t *testing.T) { + account := messagesClampOllamaAccount(425, PlatformDeepseek) + account.Credentials["api_protocol"] = APIProtocolAnthropic + nonDeepSeek := messagesClampBody("glm-4.7", 256000) + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + _, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, nonDeepSeek, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) +} + +// messagesClampProductionAccount 复刻生产账号 162:platform=deepseek、type=apikey、 +// api_protocol=adaptive、base_url 与 api_base_urls 均指向 ollama.com(anthropic 地址 +// 带 trailing '/',CC/Responses 地址带 /v1 后缀)。 +func messagesClampProductionAccount(id int64) *Account { + return &Account{ + ID: id, + Name: "ollama-cloud-prod", + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "api_protocol": APIProtocolAdaptive, + "base_url": "https://ollama.com/v1", + "api_base_urls": map[string]any{ + APIProtocolAnthropic: "https://ollama.com/", + APIProtocolResponses: "https://ollama.com/v1", + APIProtocolChatCompletions: "https://ollama.com/v1", + }, + }, + Extra: map[string]any{}, + } +} + +// TestBuildNativeAnthropicUpstreamRequest_ClampsTrailingSlashOllamaBase 复刻生产 +// 失败:anthropic 协议地址配置为 "https://ollama.com/"(trailing '/')。真实出站 +// URL 组装(ValidateURLFormat 与 nativeAnthropicTargetURL 均做 TrimRight "/")会 +// 正确打到 https://ollama.com/v1/messages,因此 clamp 判定也必须对同一归一化后的 +// base 生效,max_tokens=256000 不得原样透传被上游 400。 +func TestBuildNativeAnthropicUpstreamRequest_ClampsTrailingSlashOllamaBase(t *testing.T) { + svc := &OpenAIGatewayService{cfg: messagesClampTestConfig()} + c := newMessagesClampTestContext(t) + account := messagesClampProductionAccount(431) + body := messagesClampBody("deepseek-v4-flash", 256000) + + // 真实出站 URL:trailing '/' 被既有组装归一化掉,请求确实可达 ollama.com。 + targetURL, err := svc.nativeAnthropicTargetURL(account) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages", targetURL) + + req, wireBody, err := svc.buildNativeAnthropicUpstreamRequest( + context.Background(), c, account, body, "sk-test", targetURL, + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages", req.URL.String()) + require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(wireBody, "model").String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int(), + "anthropic base trailing '/' 时仍须命中 clamp(生产 400 回归)") +} + +// TestBuildUpstreamRequest_ClampsTrailingSlashOllamaBase 覆盖 builder A / B 的相同 +// 场景:GetBaseURL() 原样返回 trailing '/',而 validateUpstreamBaseURL 归一化后 +// 实际出站 URL 可达 ollama.com/v1/messages,clamp 判定须与归一化同源。 +func TestBuildUpstreamRequest_ClampsTrailingSlashOllamaBase(t *testing.T) { + svc := &GatewayService{cfg: messagesClampTestConfig()} + c := newMessagesClampTestContext(t) + body := messagesClampBody("deepseek-v4-flash", 256000) + + newAccount := func(id int64) *Account { + account := messagesClampOllamaAccount(id, PlatformAnthropic) + account.Credentials["base_url"] = "https://ollama.com/" + return account + } + + t.Run("builder A trailing slash base is clamped", func(t *testing.T) { + req, wireBody, err := svc.buildUpstreamRequest( + context.Background(), c, newAccount(441), body, "sk-test", "api_key", + "deepseek-v4-flash", false, false, + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + t.Run("builder B trailing slash base is clamped", func(t *testing.T) { + req, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough( + context.Background(), c, newAccount(442), body, "sk-test", + ) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages?beta=true", req.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + }) + + t.Run("evil suffix and custom path never match", func(t *testing.T) { + for _, base := range []string{"https://ollama.com.evil.com/", "https://ollama.com/anthropic/"} { + account := newAccount(443) + account.Credentials["base_url"] = base + _, wireBody, err := svc.buildUpstreamRequest( + context.Background(), c, account, body, "sk-test", "api_key", + "deepseek-v4-flash", false, false, + ) + require.NoError(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(wireBody, "max_tokens").Int(), + "base %q 不得被识别为 Ollama Cloud", base) + } + }) +} diff --git a/backend/internal/service/ollama_cloud_usage.go b/backend/internal/service/ollama_cloud_usage.go index 2956cc9e3b..08f1cbbcc6 100644 --- a/backend/internal/service/ollama_cloud_usage.go +++ b/backend/internal/service/ollama_cloud_usage.go @@ -1014,7 +1014,7 @@ func OllamaCloudUsageStateFromAccount(account *Account) *OllamaCloudUsageState { // (ollamaCloudUsagePlatformsSQL)是本列表的镜像,两侧必须同步修改。 func isOllamaCloudUsagePlatform(platform string) bool { switch platform { - case PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek: + case PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: return true default: return false diff --git a/backend/internal/service/ollama_cloud_usage_test.go b/backend/internal/service/ollama_cloud_usage_test.go index 5ad87d69d5..5fcfd7e407 100644 --- a/backend/internal/service/ollama_cloud_usage_test.go +++ b/backend/internal/service/ollama_cloud_usage_test.go @@ -497,7 +497,7 @@ func TestIsOllamaCloudUsageAccountStrictOfficialHost(t *testing.T) { // oauth 类型账号即使平台与 base_url 都命中也不进用量窗口(仅 apikey 账号)。 func TestIsOllamaCloudUsageAccountRejectsOAuthType(t *testing.T) { - for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek} { + for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { account := ollamaUsageAccount(1) account.Platform = platform account.Type = AccountTypeOAuth diff --git a/backend/internal/service/openai_access_state_failover_test.go b/backend/internal/service/openai_access_state_failover_test.go index a46f9ca571..48c8fb617e 100644 --- a/backend/internal/service/openai_access_state_failover_test.go +++ b/backend/internal/service/openai_access_state_failover_test.go @@ -77,7 +77,7 @@ func TestOpenAIUpstreamAccessStateClassification(t *testing.T) { if !tt.want { return } - require.True(t, (&OpenAIGatewayService{}).shouldFailoverOpenAIUpstreamResponse(http.StatusForbidden, "", body)) + require.True(t, (&OpenAIGatewayService{}).shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(), http.StatusForbidden, "", body)) require.True(t, shouldFailoverOpenAIPassthroughResponse(&Account{Type: AccountTypeOAuth}, http.StatusForbidden, body)) err := newOpenAIUpstreamFailoverError(http.StatusForbidden, nil, body, "", true) @@ -105,7 +105,7 @@ func TestOpenAIHTTPAccessStateDoesNotTrustBadRequestMessage(t *testing.T) { require.False(t, isOpenAIUpstreamAccessStateError("", body), "free-form stream messages are not durable account evidence") require.False(t, isOpenAIHTTPUpstreamAccessStateError(http.StatusBadRequest, "", body)) - require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadRequest, "", body)) + require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(), http.StatusBadRequest, "", body)) require.False(t, shouldFailoverOpenAIPassthroughResponse(&Account{Type: AccountTypeOAuth}, http.StatusBadRequest, body)) err := newOpenAIUpstreamFailoverError(http.StatusBadRequest, nil, body, "", false) @@ -148,7 +148,7 @@ func TestOpenAIHTTPAccessStateTrustsStructuredCode(t *testing.T) { body := []byte(`{"error":{"code":"organization_deactivated","message":"request rejected"}}`) require.True(t, isOpenAIHTTPUpstreamAccessStateError(http.StatusBadRequest, "", body)) - require.True(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadRequest, "", body)) + require.True(t, svc.shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(), http.StatusBadRequest, "", body)) require.True(t, svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusBadRequest, nil, body)) require.Equal(t, 1, repo.setErrorCalls) require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) @@ -189,7 +189,7 @@ func TestOpenAIHTTPAuthMessagesUseExistingStatusPolicies(t *testing.T) { func TestOpenAICyberPolicyWrapped5xxNeverFailsOver(t *testing.T) { body := []byte(`{"error":{"code":"cyber_policy","message":"blocked"}}`) svc := &OpenAIGatewayService{} - require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadGateway, "wrapped upstream failure", body)) + require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(), http.StatusBadGateway, "wrapped upstream failure", body)) require.False(t, shouldFailoverOpenAIPassthroughResponse(&Account{Type: AccountTypeOAuth}, http.StatusBadGateway, body)) } diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go index 3551d8ecb2..1e0303a7d1 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath.go @@ -37,9 +37,8 @@ const ( openAIOAuth429QuotaReset ) -// classifyOpenAIOAuth429 区分账号配额耗尽信号与普通瞬时 429。明确窗口达到 -// 100% 时以该窗口为准;没有 100% 标记但包含重置头时,沿用 v179 的兼容语义, -// 仍视为配额限流信号。 +// classifyOpenAIOAuth429 区分账号配额耗尽信号与普通瞬时 429。只有窗口达到 +// 100% 或响应体明确给出 reset 时间时,才视为配额限流信号。 func classifyOpenAIOAuth429(headers http.Header, responseBody []byte) (openAIOAuth429Disposition, *time.Time) { if snapshot := ParseCodexRateLimitHeaders(headers); snapshot != nil { if normalized := snapshot.Normalize(); normalized != nil { @@ -228,13 +227,17 @@ func (s *OpenAIGatewayService) markOpenAIOAuth429RateLimited(ctx context.Context return } - cooldownUntil := time.Now().Add(openAIOAuth429FallbackCooldown) - if resetAt != nil && resetAt.After(time.Now()) { + now := time.Now() + cooldownUntil := now.Add(openAIOAuth429FallbackCooldown) + if resetAt != nil && resetAt.After(now) { cooldownUntil = *resetAt } else if s.rateLimitService != nil { - if cooldown, ok := s.rateLimitService.get429FallbackCooldown(ctx, account); ok && cooldown > 0 { - cooldownUntil = time.Now().Add(cooldown) + cooldown, ok := s.rateLimitService.get429FallbackCooldown(ctx, account) + if !ok || cooldown <= 0 { + s.openaiOAuth429RetryStartedAt.Delete(account.ID) + return } + cooldownUntil = now.Add(cooldown) } s.BlockAccountScheduling(account, cooldownUntil, "429") s.openaiOAuth429RetryStartedAt.Delete(account.ID) @@ -473,8 +476,94 @@ func (s *OpenAIGatewayService) isOpenAIAccountModelRuntimeBlocked(account *Accou return state.isBlocked(account.ID, openAIAccountModelTransientModel(canonicalModel), time.Now()) } +func accountPersistedSchedulingCooldownActive(account *Account) bool { + if account == nil { + return false + } + now := time.Now() + if account.TempUnschedulableUntil != nil && now.Before(*account.TempUnschedulableUntil) { + return true + } + if account.RateLimitResetAt != nil && now.Before(*account.RateLimitResetAt) { + return true + } + if account.OverloadUntil != nil && now.Before(*account.OverloadUntil) { + return true + } + return false +} + +type openAIAccountRuntimeBlockSnapshot struct { + until time.Time + generation uint64 + blocked bool +} + +func (s *OpenAIGatewayService) peekOpenAIAccountRuntimeBlock(account *Account) openAIAccountRuntimeBlockSnapshot { + if s == nil || !isOpenAIAccount(account) { + return openAIAccountRuntimeBlockSnapshot{} + } + mu := s.openAIAccountRuntimeBlockLock(account.ID) + mu.Lock() + defer mu.Unlock() + value, ok := s.openaiAccountRuntimeBlockUntil.Load(account.ID) + if !ok { + return openAIAccountRuntimeBlockSnapshot{} + } + until, isTime := value.(time.Time) + if !isTime || until.IsZero() || !time.Now().Before(until) { + s.openaiAccountRuntimeBlockUntil.Delete(account.ID) + s.openaiOAuth429RetryStartedAt.Delete(account.ID) + s.openaiAccountRuntimeBlockGeneration.Store(account.ID, s.openaiAccountRuntimeBlockSequence.Add(1)) + return openAIAccountRuntimeBlockSnapshot{} + } + generation, _ := s.openaiAccountRuntimeBlockGeneration.Load(account.ID) + gen, _ := generation.(uint64) + return openAIAccountRuntimeBlockSnapshot{until: until, generation: gen, blocked: true} +} + +// clearOpenAIAccountRuntimeBlockIfUnchanged deletes the in-process account block +// only when generation and deadline are unchanged. A newer block installed after +// peek must be kept even if its deadline happens to match. +func (s *OpenAIGatewayService) clearOpenAIAccountRuntimeBlockIfUnchanged(accountID int64, snapshot openAIAccountRuntimeBlockSnapshot) { + if s == nil || accountID <= 0 || !snapshot.blocked { + return + } + mu := s.openAIAccountRuntimeBlockLock(accountID) + mu.Lock() + defer mu.Unlock() + generation, ok := s.openaiAccountRuntimeBlockGeneration.Load(accountID) + if !ok || generation != snapshot.generation { + return + } + current, ok := s.openaiAccountRuntimeBlockUntil.Load(accountID) + currentUntil, isTime := current.(time.Time) + if !ok || !isTime || !currentUntil.Equal(snapshot.until) { + return + } + s.openaiAccountRuntimeBlockUntil.Delete(accountID) + s.openaiOAuth429RetryStartedAt.Delete(accountID) + s.openaiAccountRuntimeBlockGeneration.Store(accountID, s.openaiAccountRuntimeBlockSequence.Add(1)) +} + +// isOpenAIAccountRequestRuntimeBlocked treats persisted cooldown fields on the +// scheduling Account as source of truth. When TempUnschedulableUntil, +// RateLimitResetAt, and OverloadUntil are all inactive, a stale local account +// block is dropped with generation+deadline CAS. Model-scoped transient blocks +// are left alone. This is fail-open if a DB write failed or the snapshot has +// not caught up yet: empty cooldown fields drop the local account-level block. func (s *OpenAIGatewayService) isOpenAIAccountRequestRuntimeBlocked(account *Account, requestedModel string) bool { - return s != nil && (s.isOpenAIAccountRuntimeBlocked(account) || s.isOpenAIAccountModelRuntimeBlocked(account, requestedModel)) + if s == nil { + return false + } + snapshot := s.peekOpenAIAccountRuntimeBlock(account) + if snapshot.blocked { + if accountPersistedSchedulingCooldownActive(account) { + return true + } + s.clearOpenAIAccountRuntimeBlockIfUnchanged(account.ID, snapshot) + } + return s.isOpenAIAccountModelRuntimeBlocked(account, requestedModel) } func (s *OpenAIGatewayService) recordOpenAIOAuth429() { diff --git a/backend/internal/service/openai_account_runtime_block_fastpath_test.go b/backend/internal/service/openai_account_runtime_block_fastpath_test.go index 543c999359..22076fe9f9 100644 --- a/backend/internal/service/openai_account_runtime_block_fastpath_test.go +++ b/backend/internal/service/openai_account_runtime_block_fastpath_test.go @@ -72,6 +72,47 @@ func TestOpenAI429FastPath_BlocksOAuthOnlyAfterRetryWindow(t *testing.T) { require.False(t, svc.shouldRetryOpenAIOAuth429OnSameAccount(account, http.StatusTooManyRequests, false)) } +func TestOpenAI429FastPath_DoesNotBlockOAuthWhenFallbackDisabled(t *testing.T) { + repo := &oauth429RateLimitRepo{} + settingRepo := newMockSettingRepo() + settingRepo.data[SettingKeyRateLimit429CooldownSettings] = `{"enabled":false,"cooldown_seconds":12}` + rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + rateLimitService.SetSettingService(NewSettingService(settingRepo, &config.Config{})) + svc := &OpenAIGatewayService{rateLimitService: rateLimitService} + rateLimitService.SetAccountRuntimeBlocker(svc) + account := &Account{ID: 425, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + svc.openaiOAuth429RetryStartedAt.Store(account.ID, time.Now().Add(-openAIOAuth429RetryWindow-time.Second)) + + svc.markOpenAIOAuth429RateLimited(context.Background(), account, http.Header{}, []byte(`{"detail":"Rate limit exceeded"}`)) + + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account), "disabled 429 fallback must not create an OAuth runtime cooldown") + require.Zero(t, repo.setRateLimitedCalls, "disabled 429 fallback must not persist a scheduler cooldown") +} + +func TestOpenAI429FastPath_DoesNotBlockOAuthWhenQuotaWindowIsNotExhausted(t *testing.T) { + repo := &oauth429RateLimitRepo{} + settingRepo := newMockSettingRepo() + settingRepo.data[SettingKeyRateLimit429CooldownSettings] = `{"enabled":false,"cooldown_seconds":12}` + rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + rateLimitService.SetSettingService(NewSettingService(settingRepo, &config.Config{})) + svc := &OpenAIGatewayService{rateLimitService: rateLimitService} + rateLimitService.SetAccountRuntimeBlocker(svc) + account := &Account{ID: 426, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + svc.openaiOAuth429RetryStartedAt.Store(account.ID, time.Now().Add(-openAIOAuth429RetryWindow-time.Second)) + headers := http.Header{} + headers.Set("x-codex-primary-used-percent", "37") + headers.Set("x-codex-primary-reset-after-seconds", "604800") + headers.Set("x-codex-primary-window-minutes", "10080") + headers.Set("x-codex-secondary-used-percent", "20") + headers.Set("x-codex-secondary-reset-after-seconds", "3600") + headers.Set("x-codex-secondary-window-minutes", "300") + + svc.handleOpenAIAccountUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, []byte(`{"detail":"Rate limit exceeded"}`)) + + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account), "non-exhausted quota headers must use the configurable fallback") + require.Zero(t, repo.setRateLimitedCalls, "disabled 429 fallback must not persist a scheduler cooldown") +} + func TestOpenAI429FastPath_BlocksOAuthImmediatelyWhenSevenDayQuotaIsExhausted(t *testing.T) { repo := &oauth429RateLimitRepo{} rateLimits := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) @@ -307,7 +348,7 @@ func TestOpenAIHTTP429StillUsesQuotaResetHeaders(t *testing.T) { account := &Account{ID: 422, Platform: PlatformOpenAI, Type: AccountTypeOAuth} svc.openaiOAuth429RetryStartedAt.Store(account.ID, time.Now().Add(-openAIOAuth429RetryWindow-time.Second)) headers := http.Header{} - headers.Set("x-codex-primary-used-percent", "37") + headers.Set("x-codex-primary-used-percent", "100") headers.Set("x-codex-primary-reset-after-seconds", "604800") headers.Set("x-codex-primary-window-minutes", "10080") @@ -725,6 +766,45 @@ func TestOpenAIRuntimeBlock_ClearAccountSchedulingBlock(t *testing.T) { require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) } +func TestRuntimeBlockHonorsClearedPersistedCooldown(t *testing.T) { + svc := &OpenAIGatewayService{} + account := &Account{ID: 92, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true} + svc.BlockAccountScheduling(account, time.Now().Add(30*time.Minute), "grok payment required") + require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "grok-3")) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + +func TestRuntimeBlockConditionalClearSkipsNewerGeneration(t *testing.T) { + svc := &OpenAIGatewayService{} + account := &Account{ID: 94, Platform: PlatformGrok, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true} + firstUntil := time.Now().Add(10 * time.Minute) + svc.BlockAccountScheduling(account, firstUntil, "stale") + snapshot := svc.peekOpenAIAccountRuntimeBlock(account) + require.True(t, snapshot.blocked) + newerUntil := time.Now().Add(30 * time.Minute) + svc.BlockAccountScheduling(account, newerUntil, "fresh") + svc.clearOpenAIAccountRuntimeBlockIfUnchanged(account.ID, snapshot) + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "grok-3")) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + +func TestRuntimeBlockKeepsActivePersistedCooldown(t *testing.T) { + svc := &OpenAIGatewayService{} + until := time.Now().Add(30 * time.Minute) + account := &Account{ + ID: 93, + Platform: PlatformGrok, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + TempUnschedulableUntil: &until, + } + svc.BlockAccountScheduling(account, until, "grok payment required") + require.True(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "grok-3")) + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) +} + func TestShouldStopOpenAIOAuth429Failover_AfterBoundedFullWindows(t *testing.T) { svc := &OpenAIGatewayService{} account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth} diff --git a/backend/internal/service/openai_account_runtime_transient_test.go b/backend/internal/service/openai_account_runtime_transient_test.go index 4ba1f16a55..b25794fe77 100644 --- a/backend/internal/service/openai_account_runtime_transient_test.go +++ b/backend/internal/service/openai_account_runtime_transient_test.go @@ -111,6 +111,8 @@ func TestHandleOpenAITransientError_HardDisableStillBlocksWholeAccount(t *testin svc.BlockAccountScheduling(account, time.Now().Add(time.Minute), "upstream_disable") - require.True(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.5")) - require.True(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.6-sol")) + require.True(t, svc.isOpenAIAccountRuntimeBlocked(account)) + require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.5")) + require.False(t, svc.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.6-sol")) + require.False(t, svc.isOpenAIAccountRuntimeBlocked(account)) } diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index ac242f050c..c80673405f 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -91,7 +91,7 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co if resp.StatusCode >= http.StatusBadRequest { upstreamMessage := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) - if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMessage, respBody) || + if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMessage, respBody) || isOpenAIAlphaSearchEndpointUnsupported(account, resp.StatusCode) { resp.Body = io.NopCloser(bytes.NewReader(respBody)) // alpha/search 是独立的工具端点,单次 401 不能证明账号的模型调用 @@ -128,11 +128,12 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co return nil, nil } return &OpenAIForwardResult{ - RequestID: strings.TrimSpace(resp.Header.Get("x-request-id")), - Model: requestedModel, - UpstreamModel: upstreamModel, - Duration: time.Since(upstreamStart), - WebSearchCalls: 1, + RequestID: strings.TrimSpace(resp.Header.Get("x-request-id")), + UpstreamHeaders: resp.Header, + Model: requestedModel, + UpstreamModel: upstreamModel, + Duration: time.Since(upstreamStart), + WebSearchCalls: 1, }, nil } @@ -174,7 +175,7 @@ func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch( if resp.StatusCode >= http.StatusBadRequest { upstreamMessage := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) - if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMessage, respBody) { + if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMessage, respBody) { resp.Body = io.NopCloser(bytes.NewReader(respBody)) // 仍按 alpha/search 工具请求处理:PAT 的工具链路失败不能直接永久置错。 shouldDisable := false @@ -212,6 +213,7 @@ func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch( c.Data(http.StatusOK, "application/json", alphaRespBody) return &OpenAIForwardResult{ RequestID: strings.TrimSpace(resp.Header.Get("x-request-id")), + UpstreamHeaders: resp.Header, Model: requestedModel, UpstreamModel: upstreamModel, UpstreamEndpoint: "/v1/responses", diff --git a/backend/internal/service/openai_astra_pro_mode_test.go b/backend/internal/service/openai_astra_pro_mode_test.go new file mode 100644 index 0000000000..945d3a2542 --- /dev/null +++ b/backend/internal/service/openai_astra_pro_mode_test.go @@ -0,0 +1,244 @@ +//go:build unit + +package service + +// Integration-level tests for GPT-6 Astra reasoning.mode preservation through +// OpenAIGatewayService.Forward. +// +// These exercise the real forward pipeline through the httpUpstreamRecorder mock +// (no real network / credentials / config), covering the OpenAI OAuth account +// (native Codex transform when passthrough is disabled, and the passthrough +// branch when enabled). Client stream=true and stream=false both receive an +// upstream SSE fixture, because Codex upstreams stream: the stream=false client +// path exercises the real SSE->JSON aggregation instead of a synthetic JSON body. + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +const astraProCodexResponsesURL = "https://chatgpt.com/backend-api/codex/responses" + +type astraForwardSetup struct { + upstream *httpUpstreamRecorder + svc *OpenAIGatewayService + c *gin.Context + rec *httptest.ResponseRecorder + account *Account +} + +// newAstraOAuthSetup builds an OpenAI OAuth account using the minimal +// svc+account harness pattern (mirrors openai_oauth_passthrough_test.go). When +// passthrough is true the account routes through forwardOpenAIPassthrough. +func newAstraOAuthSetup(t *testing.T, passthrough bool) *astraForwardSetup { + t.Helper() + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1") + upstream := &httpUpstreamRecorder{} + svc := &OpenAIGatewayService{ + cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}}, + httpUpstream: upstream, + } + account := &Account{ + ID: 888, + Name: "oauth-astra", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"}, + Extra: map[string]any{"openai_passthrough": passthrough}, + Status: StatusActive, + Schedulable: true, + RateMultiplier: f64p(1), + } + return &astraForwardSetup{upstream: upstream, svc: svc, c: c, rec: rec, account: account} +} + +func codexCompletedSSE(inner string) string { + return "data: {\"type\":\"response.completed\",\"response\":" + inner + "}\n\ndata: [DONE]\n\n" +} + +func astraRequestBody(model string, stream bool, mode, effort string) []byte { + var reasoningParts []string + if mode != "" { + reasoningParts = append(reasoningParts, `"mode":"`+mode+`"`) + } + if effort != "" { + reasoningParts = append(reasoningParts, `"effort":"`+effort+`"`) + } + reasoning := "" + if len(reasoningParts) > 0 { + reasoning = `,"reasoning":{` + strings.Join(reasoningParts, ",") + `}` + } + streamJSON := "false" + if stream { + streamJSON = "true" + } + return []byte(`{"model":"` + model + `","stream":` + streamJSON + `,` + + `"instructions":"test","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]` + + reasoning + `}`) +} + +// TestForward_AstraOAuth_NonAstraLegacyStillStrips asserts the historical strip +// behavior is preserved for a non-Astra model on an OAuth account. +func TestForward_AstraOAuth_NonAstraLegacyStillStrips(t *testing.T) { + gin.SetMode(gin.TestMode) + s := newAstraOAuthSetup(t, false) + inner := `{"id":"resp_test","model":"gpt-5.6-sol","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}` + s.upstream.resp = &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(codexCompletedSSE(inner))), + } + body := astraRequestBody("gpt-5.6-sol", true, "pro", "") + + result, err := s.svc.Forward(context.Background(), s.c, s.account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, s.upstream.lastReq) + require.Equal(t, astraProCodexResponsesURL, s.upstream.lastReq.URL.String()) + + forwarded := s.upstream.lastBody + require.False(t, gjson.GetBytes(forwarded, "reasoning.mode").Exists(), + "non-Astra legacy must strip reasoning.mode") + require.Equal(t, "max", gjson.GetBytes(forwarded, "reasoning.effort").String(), + "non-Astra legacy mode=pro without effort injects max") +} + +// TestForward_AstraOAuth_ModeMatrix_Preserved runs the pro/standard/missing-mode +// x effort=max matrix across both forward branches and both client stream modes. +// Every attempt receives an upstream SSE fixture; assertions check the final +// upstream request keeps mode/effort and the URL is the Codex responses endpoint. +func TestForward_AstraOAuth_ModeMatrix_Preserved(t *testing.T) { + cases := []struct { + name string + mode string + effort string + wantMod string // gjson string value; "" == absent + wantEff string + }{ + {name: "pro+max", mode: "pro", effort: "max", wantMod: "pro", wantEff: "max"}, + {name: "standard+max", mode: "standard", effort: "max", wantMod: "standard", wantEff: "max"}, + {name: "missing mode + max", mode: "", effort: "max", wantMod: "", wantEff: "max"}, + {name: "pro+high", mode: "pro", effort: "high", wantMod: "pro", wantEff: "high"}, + {name: "pro no effort", mode: "pro", effort: "", wantMod: "pro", wantEff: ""}, + } + for _, passthrough := range []bool{false, true} { + branch := "native-codex" + if passthrough { + branch = "passthrough" + } + for _, stream := range []bool{true, false} { + for _, tt := range cases { + t.Run(tt.name+"/"+branch+"/stream="+map[bool]string{true: "true", false: "false"}[stream], func(t *testing.T) { + s := newAstraOAuthSetup(t, passthrough) + inner := `{"id":"resp_test","model":"gpt-6-astra","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}` + s.upstream.resp = &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(codexCompletedSSE(inner))), + } + body := astraRequestBody("gpt-6-astra", stream, tt.mode, tt.effort) + + result, err := s.svc.Forward(context.Background(), s.c, s.account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, s.upstream.lastReq) + require.Equal(t, astraProCodexResponsesURL, s.upstream.lastReq.URL.String(), + "OAuth account must hit the Codex responses endpoint") + + forwarded := s.upstream.lastBody + require.Equal(t, "gpt-6-astra", gjson.GetBytes(forwarded, "model").String()) + + mode := gjson.GetBytes(forwarded, "reasoning.mode") + if tt.wantMod == "" { + require.False(t, mode.Exists(), "mode should be absent for %q", tt.name) + } else { + require.True(t, mode.Exists(), "Astra mode must be preserved for %q", tt.name) + require.Equal(t, tt.wantMod, mode.String()) + } + eff := gjson.GetBytes(forwarded, "reasoning.effort") + if tt.wantEff == "" { + require.False(t, eff.Exists(), "no effort injected when omitted for %q", tt.name) + } else { + require.Equal(t, tt.wantEff, eff.String(), "Astra effort preserved for %q", tt.name) + } + }) + } + } + } +} + +// TestForward_AstraOAuth_NonStreamAggregation_PreservesResponseReasoningMode +// drives client stream=false (which is forced to an SSE upstream) and asserts the +// aggregated JSON response object still carries response.reasoning.mode after +// SSE->JSON aggregation. +func TestForward_AstraOAuth_NonStreamAggregation_PreservesResponseReasoningMode(t *testing.T) { + gin.SetMode(gin.TestMode) + s := newAstraOAuthSetup(t, false) + // response.completed.response carries a top-level reasoning:{mode:pro,effort:max}. + inner := `{"id":"resp_astra","object":"response","created_at":0,"status":"completed","model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"max"},"output":[{"type":"reasoning","id":"rs_1","summary":[{"type":"summary_text","text":"think"}]},{"type":"message","id":"msg_1","role":"assistant","content":[{"type":"output_text","text":"hi"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}` + s.upstream.resp = &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(codexCompletedSSE(inner))), + } + body := astraRequestBody("gpt-6-astra", false, "pro", "max") + + result, err := s.svc.Forward(context.Background(), s.c, s.account, body) + require.NoError(t, err) + require.NotNil(t, result) + + downstream := s.rec.Body.String() + // Downstream is JSON (SSE->JSON aggregation for the non-streaming client). + require.True(t, gjson.Valid(downstream), "downstream must be aggregated JSON, got: %s", downstream) + require.Equal(t, "pro", gjson.Get(downstream, "reasoning.mode").String(), + "aggregated response object must keep reasoning.mode") + require.Equal(t, "max", gjson.Get(downstream, "reasoning.effort").String()) +} + +// TestForward_AstraOAuth_Stream_PreservesResponseReasoningMode drives client +// stream=true and asserts the streamed response.completed event's response object +// still carries response.reasoning.mode. +func TestForward_AstraOAuth_Stream_PreservesResponseReasoningMode(t *testing.T) { + gin.SetMode(gin.TestMode) + s := newAstraOAuthSetup(t, false) + inner := `{"id":"resp_astra","object":"response","created_at":0,"status":"completed","model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"max"},"output":[{"type":"message","id":"msg_1","role":"assistant","content":[{"type":"output_text","text":"hi"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}` + s.upstream.resp = &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(codexCompletedSSE(inner))), + } + body := astraRequestBody("gpt-6-astra", true, "pro", "max") + + result, err := s.svc.Forward(context.Background(), s.c, s.account, body) + require.NoError(t, err) + require.NotNil(t, result) + + downstream := s.rec.Body.String() + require.Contains(t, downstream, "response.completed", "streaming downstream must include completed event") + // Extract response.completed line and assert response.reasoning.mode inside it. + var completedJSON string + for _, line := range strings.Split(downstream, "\n") { + if strings.HasPrefix(line, "data: ") && strings.Contains(line, `"type":"response.completed"`) { + completedJSON = strings.TrimPrefix(line, "data: ") + break + } + } + require.NotEmpty(t, completedJSON, "no response.completed data line found in: %s", downstream) + require.Equal(t, "pro", gjson.Get(completedJSON, "response.reasoning.mode").String(), + "streamed completed response object must keep reasoning.mode") + require.Equal(t, "max", gjson.Get(completedJSON, "response.reasoning.effort").String()) +} diff --git a/backend/internal/service/openai_astra_pro_retry_test.go b/backend/internal/service/openai_astra_pro_retry_test.go new file mode 100644 index 0000000000..246884b674 --- /dev/null +++ b/backend/internal/service/openai_astra_pro_retry_test.go @@ -0,0 +1,92 @@ +package service + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +const openAICodexResponsesTestURL = "https://chatgpt.com/backend-api/codex/responses" + +// newOpenAIAstraProRetryContext returns a gin context plus its recorder so the +// caller can assert the service-layer client error shape (type/code/param/message). +func newOpenAIAstraProRetryContext(body []byte) (*gin.Context, *httptest.ResponseRecorder) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c, recorder +} + +// TestOpenAIGatewayService_OAuthAstraProModeRejectionPassesThrough verifies that +// when the Codex OAuth upstream rejects an Astra pro+max request with a +// deterministic HTTP 400 on error.param=reasoning.mode, the gateway passes the +// client error through unchanged (upstream type/code/param/message, code not +// treated as official), makes exactly one upstream call, keeps reasoning.mode=pro +// and reasoning.effort=max in the sent body, and does not replay without mode. +func TestOpenAIGatewayService_OAuthAstraProModeRejectionPassesThrough(t *testing.T) { + body := []byte(`{"model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"max"},"input":"hello"}`) + upstream := &httpUpstreamRecorder{resp: newOpenAIRejectedFieldTestResponse( + http.StatusBadRequest, + `{"error":{"type":"invalid_request_error","code":"model_specific_rejection","message":"reasoning.mode is not supported for this model","param":"reasoning.mode"}}`, + )} + svc := newOpenAIRejectedFieldTestService(upstream) + c, recorder := newOpenAIAstraProRetryContext(body) + + _, err := svc.Forward(context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body) + require.Error(t, err) + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.Len(t, upstream.bodies, 1, "deterministic 400 must not trigger a retry") + require.Len(t, upstream.requests, 1) + require.Equal(t, openAICodexResponsesTestURL, upstream.requests[0].URL.String()) + + sent := upstream.bodies[0] + require.Equal(t, "gpt-6-astra", gjson.GetBytes(sent, "model").String()) + require.Equal(t, "pro", gjson.GetBytes(sent, "reasoning.mode").String()) + require.Equal(t, "max", gjson.GetBytes(sent, "reasoning.effort").String()) + + respJSON := recorder.Body.String() + require.Equal(t, "invalid_request_error", gjson.Get(respJSON, "error.type").String()) + require.Equal(t, "model_specific_rejection", gjson.Get(respJSON, "error.code").String()) + require.Equal(t, "reasoning.mode", gjson.Get(respJSON, "error.param").String()) + require.Equal(t, "reasoning.mode is not supported for this model", gjson.Get(respJSON, "error.message").String()) +} + +// TestOpenAIGatewayService_OAuthAstraProModeKeptAcrossRejectedFieldRetry reuses +// the existing OAuth rejected-field retry fixture (input[0].status is rejected, +// stripped, then replayed to success) to confirm Astra pro+max reasoning is kept +// across every in-service retry: each captured Codex URL request body still +// carries reasoning.mode=pro and reasoning.effort=max with model=gpt-6-astra, +// and only the second attempt drops the rejected status field. +func TestOpenAIGatewayService_OAuthAstraProModeKeptAcrossRejectedFieldRetry(t *testing.T) { + body := []byte(`{"model":"gpt-6-astra","stream":true,"instructions":"test","reasoning":{"mode":"pro","effort":"max"},"input":[{"type":"message","role":"user","status":"completed","content":"hello"}]}`) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[0].status'.","param":"input[0].status"}}`), + newOpenAIRejectedFieldTestResponse(http.StatusOK, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ok\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n"), + }} + upstream.responses[1].Header.Set("Content-Type", "text/event-stream") + svc := newOpenAIRejectedFieldTestService(upstream) + c, _ := newOpenAIAstraProRetryContext(body) + + result, err := svc.Forward(context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body) + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, upstream.bodies, 2) + require.Len(t, upstream.requests, 2) + for i := range upstream.bodies { + require.Equal(t, openAICodexResponsesTestURL, upstream.requests[i].URL.String(), "attempt %d URL", i) + sent := upstream.bodies[i] + require.Equal(t, "gpt-6-astra", gjson.GetBytes(sent, "model").String(), "attempt %d model", i) + require.Equal(t, "pro", gjson.GetBytes(sent, "reasoning.mode").String(), "attempt %d reasoning.mode", i) + require.Equal(t, "max", gjson.GetBytes(sent, "reasoning.effort").String(), "attempt %d reasoning.effort", i) + } + require.Equal(t, "completed", gjson.GetBytes(upstream.bodies[0], "input.0.status").String()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "input.0.status").Exists()) +} diff --git a/backend/internal/service/openai_capacity_shed_test.go b/backend/internal/service/openai_capacity_shed_test.go index ac32fa5749..537ec435c5 100644 --- a/backend/internal/service/openai_capacity_shed_test.go +++ b/backend/internal/service/openai_capacity_shed_test.go @@ -298,6 +298,59 @@ func TestOpenAIStreamCapacityShedAfterOutputRewritesCodeForClient(t *testing.T) require.True(t, logSink.ContainsFieldValue("upstream_request_id", "rid-shed-after-output")) } +func TestOpenAIStreamProcessingFailureAfterOutputIsRecorded(t *testing.T) { + gin.SetMode(gin.TestMode) + stream := strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_processing_failure"}}`, + "", + "event: response.output_text.delta", + `data: {"type":"response.output_text.delta","delta":"partial"}`, + "", + "event: response.failed", + `data: {"type":"response.failed","response":{"id":"resp_processing_failure","status":"failed","error":{"code":"server_error","message":"An error occurred while processing your request. Please include the request ID rid-processing-failure in your message."}}}`, + "", + }, "\n") + + for _, passthrough := range []bool{false, true} { + t.Run(map[bool]string{false: "native", true: "passthrough"}[passthrough], func(t *testing.T) { + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(stream)), + Header: http.Header{"X-Request-Id": []string{"rid-processing-failure"}}, + } + account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "codex-account"} + + var err error + if passthrough { + _, err = svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, account, time.Now(), "gpt-5.6-terra", "gpt-5.6-terra") + } else { + _, err = svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "gpt-5.6-terra", "gpt-5.6-terra") + } + + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + require.NotEmpty(t, rec.Body.String()) + require.Contains(t, rec.Body.String(), "response.failed") + require.NotNil(t, c) + rawEvents, ok := c.Get(OpsUpstreamErrorsKey) + require.True(t, ok) + events, ok := rawEvents.([]*OpsUpstreamErrorEvent) + require.True(t, ok) + require.NotEmpty(t, events) + event := events[len(events)-1] + require.Equal(t, "stream_failed", event.Kind) + require.Equal(t, "rid-processing-failure", event.UpstreamRequestID) + require.Contains(t, event.Message, "An error occurred while processing your request") + }) + } +} + // helper 单测:只有降载码被改写,其余错误码(尤其 rate_limit_exceeded,客户端 // 依赖其原码解析重试延时)必须原样保留。 func TestSanitizeOpenAICapacityShedErrorCodeForClient(t *testing.T) { diff --git a/backend/internal/service/openai_codex_astra_image_test.go b/backend/internal/service/openai_codex_astra_image_test.go new file mode 100644 index 0000000000..ec4cd3eeea --- /dev/null +++ b/backend/internal/service/openai_codex_astra_image_test.go @@ -0,0 +1,72 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildCodexModelsManifestForGroupCorrectsOfficialAstraStaleModalities(t *testing.T) { + t.Parallel() + + const groupID int64 = 780 + account := Account{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test"}, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{ + Models: map[string]UpstreamModelMetadata{ + "gpt-6-astra": {ID: "gpt-6-astra", InputModalities: []string{"text"}}, + }, + }) + + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{groupID: {account}}, + }} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + "", + []string{"gpt-6-astra"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) +} + +func TestBuildCodexModelsManifestForGroupPreservesCompatibleAstraTextOnlyMetadata(t *testing.T) { + t.Parallel() + + const groupID int64 = 781 + account := Account{ + ID: 2, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://relay.example.test/v1"}, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{ + Models: map[string]UpstreamModelMetadata{ + "gpt-6-astra": {ID: "gpt-6-astra", InputModalities: []string{"text"}}, + }, + }) + + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{groupID: {account}}, + }} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + "", + []string{"gpt-6-astra"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) +} diff --git a/backend/internal/service/openai_codex_model_metadata.go b/backend/internal/service/openai_codex_model_metadata.go index c779a05ab9..445b3b7899 100644 --- a/backend/internal/service/openai_codex_model_metadata.go +++ b/backend/internal/service/openai_codex_model_metadata.go @@ -1,11 +1,112 @@ package service -import "strings" +import ( + "bytes" + "encoding/json" + "net/url" + "strings" +) + +var codexToolCapabilityFields = []string{ + "supports_search_tool", "apply_patch_tool_type", "comp_hash", "tool_mode", "use_responses_lite", + "multi_agent_reasoning_effort", "multi_agent_version", +} + +func applyCodexToolCapabilities(dst, src map[string]json.RawMessage, overwrite bool) bool { + changed := false + for _, field := range codexToolCapabilityFields { + value := bytes.TrimSpace(src[field]) + if len(value) == 0 { + continue + } + // These Codex fields are nullable booleans or strings, never arbitrary objects. + if !bytes.Equal(value, []byte("null")) { + if field == "supports_search_tool" || field == "use_responses_lite" { + if !bytes.Equal(value, []byte("true")) && !bytes.Equal(value, []byte("false")) { + continue + } + } else { + var text string + if json.Unmarshal(value, &text) != nil { + continue + } + } + } + current, exists := dst[field] + if (exists && !overwrite) || bytes.Equal(current, value) { + continue + } + dst[field] = append(json.RawMessage(nil), value...) + changed = true + } + return changed +} + +func accountCodexToolCapabilities(account *Account, modelID string) map[string]json.RawMessage { + capabilities := make(map[string]json.RawMessage) + if account == nil { + return capabilities + } + if metadata, ok := account.GetUpstreamModelMetadata(modelID); ok { + applyCodexToolCapabilities(capabilities, metadata.CodexToolCapabilities, true) + } + if account.IsOpenAI() && shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + // This bridge implements client-side tool discovery, even without a native manifest. + applyCodexToolCapabilities(capabilities, map[string]json.RawMessage{"supports_search_tool": json.RawMessage("true")}, false) + } + // Codex 0.153's bundled Astra catalog verifies these values. API-key routes + // use standard Responses, not the ChatGPT-only Responses Lite wire. + baseURL := strings.TrimSpace(account.GetCredential("base_url")) + if baseURL == "" { + baseURL = account.GetOpenAIBaseURL() + } + parsed, err := url.Parse(baseURL) + official := err == nil && (strings.EqualFold(parsed.Hostname(), "api.openai.com") || + (account.IsOpenAIOAuth() && strings.EqualFold(parsed.Hostname(), "chatgpt.com"))) + if account.IsOpenAI() && isOpenAIGPT6AstraModel(modelID) && official { + defaults := map[string]json.RawMessage{ + "supports_search_tool": json.RawMessage("true"), + "apply_patch_tool_type": json.RawMessage(`"freeform"`), + "comp_hash": json.RawMessage(`"3000"`), + "tool_mode": json.RawMessage("null"), + "use_responses_lite": json.RawMessage("false"), + } + if account.IsOpenAIOAuth() { + defaults["tool_mode"] = json.RawMessage(`"code_mode_only"`) + defaults["use_responses_lite"] = json.RawMessage("true") + } + applyCodexToolCapabilities(capabilities, defaults, false) + } + if account.IsOpenAIApiKey() { + target := modelID + if isOpenAIGPT6AstraModel(target) { + target = "gpt-6-astra" + } + _, disabled := apiKeyCodexModelsWithoutResponsesLite[target] + if disabled && bytes.Equal(capabilities["use_responses_lite"], []byte("true")) { + capabilities["use_responses_lite"] = json.RawMessage("false") + } + } + return capabilities +} + +// codexModelRoutingAccountIDs 返回分组显式为该公开别名声明的账号集合。 +// +// 返回非空表示运营者已经用 model_routing 指明“这个别名由这些账号服务”,此时别名的 +// 归属不再是需要推断的未知量:能力声明只看这些账号,且不再因为它们映射到不同上游而 +// 判定为冲突。返回空表示没有相关规则,保持原有的全量推断与失败即关闭行为。 +func codexModelRoutingAccountIDs(group *Group, modelID string) []int64 { + if group == nil { + return nil + } + return group.GetRoutingAccountIDs(strings.TrimSpace(modelID)) +} func groupCodexModelMetadata( platform string, modelID string, accounts []Account, + group *Group, compositeRoutes []CompositeModelRoute, compositeRoutesAvailable bool, ) (codexModelMetadataOverride, bool) { @@ -13,6 +114,9 @@ func groupCodexModelMetadata( if modelID == "" { return codexModelMetadataOverride{}, false } + // 显式路由规则本身与平台无关,先取出来供后续两处判定共用。 + routedAccountIDs := codexModelRoutingAccountIDs(group, modelID) + routed := len(routedAccountIDs) > 0 upstreamModel := modelID if platform == PlatformComposite { var resolved bool @@ -23,7 +127,7 @@ func groupCodexModelMetadata( compositeRoutesAvailable, ) if !resolved { - if codexExplicitModelTargetsConflict(accounts, modelID) { + if !routed && codexExplicitModelTargetsConflict(accounts, modelID) { return codexModelMetadataOverride{ reasoningConflict: true, inputModalitiesConflict: true, @@ -38,21 +142,32 @@ func groupCodexModelMetadata( explicitClaims := false if upstreamModel == modelID { - for _, account := range accounts { - if account.Platform == platform && codexExplicitModelMappingClaims(account, modelID) { + for i := range accounts { + account := &accounts[i] + if routed && !containsInt64(routedAccountIDs, account.ID) { + continue + } + if account.Platform == platform && codexExplicitModelMappingClaims(*account, modelID) { explicitClaims = true break } } } - explicitTargetsConflict := explicitClaims && codexExplicitModelTargetsConflictForPlatform(accounts, platform, modelID) + // 别名已被显式路由声明时,"多个账号映射到不同上游"是故障转移的正常写法,不是歧义, + // 因此不再失败即关闭;能力回落到与单账号无快照时相同的按名推导。 + explicitTargetsConflict := explicitClaims && !routed && + codexExplicitModelTargetsConflictForPlatform(accounts, platform, modelID) publicAlias := upstreamModel != modelID candidates := make([]UpstreamModelMetadata, 0) + missingMetadata := false for i := range accounts { account := &accounts[i] if account.Platform != platform { continue } + if routed && !containsInt64(routedAccountIDs, account.ID) { + continue + } var lookupModel string if explicitClaims { if !codexExplicitModelMappingClaims(*account, modelID) { @@ -76,14 +191,20 @@ func groupCodexModelMetadata( inputModalitiesConflict: true, }, true } - return codexModelMetadataOverride{}, false + missingMetadata = true } + metadata.CodexToolCapabilities = accountCodexToolCapabilities(account, lookupModel) candidates = append(candidates, metadata) } if len(candidates) == 0 { return codexModelMetadataOverride{}, false } metadata := intersectUpstreamModelMetadata(modelID, candidates) + if missingMetadata { + metadata = codexModelMetadataOverride{UpstreamModelMetadata: UpstreamModelMetadata{ + CodexToolCapabilities: metadata.CodexToolCapabilities, + }} + } if publicAlias { metadata.DisplayName = modelID metadata.Description = configuredCodexCustomDescription @@ -124,6 +245,27 @@ func codexExplicitModelTargetsConflictForPlatform(accounts []Account, platform, func intersectUpstreamModelMetadata(modelID string, candidates []UpstreamModelMetadata) codexModelMetadataOverride { result := codexModelMetadataOverride{UpstreamModelMetadata: UpstreamModelMetadata{ID: strings.TrimSpace(modelID)}} + result.CodexToolCapabilities = make(map[string]json.RawMessage) + for _, field := range codexToolCapabilityFields { + value := bytes.TrimSpace(candidates[0].CodexToolCapabilities[field]) + shared := len(value) > 0 + declared := shared + for _, candidate := range candidates[1:] { + declared = declared || len(candidate.CodexToolCapabilities[field]) > 0 + if !bytes.Equal(value, bytes.TrimSpace(candidate.CodexToolCapabilities[field])) { + shared = false + } + } + if shared { + result.CodexToolCapabilities[field] = value + } else if declared { + fallback := json.RawMessage("null") + if field == "supports_search_tool" || field == "use_responses_lite" { + fallback = json.RawMessage("false") + } + result.CodexToolCapabilities[field] = fallback + } + } for _, candidate := range candidates { if result.DisplayName == "" && strings.TrimSpace(candidate.DisplayName) != "" { result.DisplayName = strings.TrimSpace(candidate.DisplayName) @@ -281,6 +423,8 @@ func configuredCodexReasoningLevelDescription(level string) string { return "Extra-high reasoning depth for difficult tasks" case "max": return "Maximum reasoning depth for complex tasks" + case "ultra": + return "Maximum reasoning with automatic task delegation" default: return "Reasoning effort supported by the upstream model" } diff --git a/backend/internal/service/openai_codex_model_metadata_test.go b/backend/internal/service/openai_codex_model_metadata_test.go index b058fdc0df..cb757a1faa 100644 --- a/backend/internal/service/openai_codex_model_metadata_test.go +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -2,12 +2,144 @@ package service import ( "context" + "encoding/json" "errors" "testing" "github.com/stretchr/testify/require" ) +func TestAstraUltraCatalogPreservesWorkflowMetadata(t *testing.T) { + // Ultra is a Codex workflow. Its inference effort must survive catalog sync, + // aliases and group generation instead of silently falling back to max. + _, metadata, err := extractUpstreamModelCatalog([]byte(`{"models":[{ + "slug":"gpt-6-astra","supported_reasoning_levels":[{"effort":"high"},{"effort":"ultra"}], + "multi_agent_reasoning_effort":"high","multi_agent_version":"v2" + }]}`), false) + require.NoError(t, err) + account := Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "base_url": "https://relay.example/v1", "model_mapping": map[string]any{"public-astra": "gpt-6-astra"}, + }} + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: metadata}) + body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"public-astra"}, []Account{account}, nil, nil, true) + require.NoError(t, err) + model := decodeCodexManifestModels(t, body)[0] + require.Equal(t, "high", model["multi_agent_reasoning_effort"]) + require.Equal(t, "v2", model["multi_agent_version"]) + require.Equal(t, []string{"high", "ultra"}, effortsFromManifestModel(t, model)) + + peer := Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: account.Credentials} + body, err = buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"public-astra"}, []Account{account, peer}, nil, nil, true) + require.NoError(t, err) + model = decodeCodexManifestModels(t, body)[0] + require.Nil(t, model["multi_agent_reasoning_effort"], "do not advertise one account's override for all peers") + require.Nil(t, model["multi_agent_version"]) +} + +func TestAstraUltraCatalogPreservesExplicitWorkflowOverrides(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://relay.example/v1"}} + for _, fields := range []string{ + `"multi_agent_reasoning_effort":"high","multi_agent_version":"v1"`, + `"multi_agent_reasoning_effort":null,"multi_agent_version":null`, + } { + for _, source := range []string{ + `{"models":[{"slug":"gpt-6-astra",` + fields + `}]}`, + `{"data":[{"id":"gpt-6-astra",` + fields + `}]}`, + } { + converted := convertOpenAIModelListToCodexManifestForAccount([]byte(source), account) + body, err := completeAPIKeyCodexModelsManifestMetadata(converted, true, account) + require.NoError(t, err) + model := decodeCodexManifestModels(t, body)[0] + var expected map[string]any + require.NoError(t, json.Unmarshal([]byte(`{`+fields+`}`), &expected)) + for key, value := range expected { + require.Contains(t, model, key) + require.Equal(t, value, model[key]) + } + } + } +} + +func TestAstraCodexToolCapabilitiesUseAccountScopeAndSharedDeclarations(t *testing.T) { + newAccount := func(baseURL string) Account { + return Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "base_url": baseURL, "model_mapping": map[string]any{"public-astra": "gpt-6-astra"}, + }} + } + official := newAccount("https://api.openai.com/v1") + custom := newAccount("https://relay.example/v1") + bridge := newAccount("https://bridge.example/v1") + bridge.Extra = map[string]any{"openai_responses_supported": false} + for _, tt := range []struct { + name string + accounts []Account + search bool + }{ + {"official fallback", []Account{official}, true}, + {"custom host has no guessed capability", []Account{custom}, false}, + {"missing peer capability", []Account{official, custom}, false}, + {"implemented chat bridge", []Account{bridge}, true}, + } { + t.Run(tt.name, func(t *testing.T) { + body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"public-astra"}, tt.accounts, nil, nil, true) + require.NoError(t, err) + model := decodeCodexManifestModels(t, body)[0] + require.Equal(t, "public-astra", model["slug"]) + require.Equal(t, tt.search, model["supports_search_tool"]) + require.Equal(t, false, model["use_responses_lite"]) + if tt.name == "official fallback" { + require.Equal(t, "freeform", model["apply_patch_tool_type"]) + require.Equal(t, "3000", model["comp_hash"]) + } + }) + } + custom.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "gpt-6-astra": {CodexToolCapabilities: map[string]json.RawMessage{ + "supports_search_tool": json.RawMessage("true"), "apply_patch_tool_type": json.RawMessage("null"), + "comp_hash": json.RawMessage(`"3000"`), "tool_mode": json.RawMessage("null"), "use_responses_lite": json.RawMessage("false"), + }}, + }}) + body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"public-astra"}, []Account{official, custom}, nil, nil, true) + require.NoError(t, err) + model := decodeCodexManifestModels(t, body)[0] + require.Equal(t, true, model["supports_search_tool"]) + require.Nil(t, model["apply_patch_tool_type"], "conflicting tool protocols must not be advertised") + require.Equal(t, "3000", model["comp_hash"]) +} + +func TestAstraCodexToolCapabilitiesPreserveLiveNullAndFalse(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://api.openai.com/v1"}} + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "gpt-6-astra": {CodexToolCapabilities: map[string]json.RawMessage{ + "supports_search_tool": json.RawMessage("true"), "apply_patch_tool_type": json.RawMessage(`"freeform"`), + "comp_hash": json.RawMessage(`"3000"`), "tool_mode": json.RawMessage(`"code_mode_only"`), + }}, + }}) + for _, source := range []string{ + `{"models":[{"slug":"gpt-6-astra","supports_search_tool":false,"apply_patch_tool_type":null,"tool_mode":null}]}`, + `{"data":[{"id":"gpt-6-astra","supports_search_tool":false,"apply_patch_tool_type":null,"tool_mode":null}]}`, + } { + converted := convertOpenAIModelListToCodexManifestForAccount([]byte(source), account) + body, err := applySyncedAPIKeyCodexModelMetadata(converted, account, source[2:6] == "data") + require.NoError(t, err) + body, err = completeAPIKeyCodexModelsManifestMetadata(body, true, account) + require.NoError(t, err) + model := decodeCodexManifestModels(t, body)[0] + require.Equal(t, false, model["supports_search_tool"]) + require.Nil(t, model["apply_patch_tool_type"]) + require.Nil(t, model["tool_mode"]) + require.Equal(t, "3000", model["comp_hash"]) + } + fields := make(map[string]json.RawMessage) + applyCodexToolCapabilities(fields, map[string]json.RawMessage{ + "supports_search_tool": json.RawMessage(`"true"`), "comp_hash": json.RawMessage(`{"unexpected":true}`), + "model_messages": json.RawMessage(`"do not persist prompts"`), + }, true) + require.Empty(t, fields) +} + // Scenario: mixed groups prefer capability metadata synced for the routed account. func TestBuildCodexModelsManifestForGroupUsesSyncedAccountMetadata(t *testing.T) { t.Parallel() @@ -93,6 +225,83 @@ func TestBuildCodexModelsManifestForGroupUsesNoneForExplicitNonReasoningMetadata require.Equal(t, []string{"none"}, effortsFromManifestModel(t, models[0])) } +func TestBuildCodexModelsManifestForGroupAdvertisesSearchOnlyForChatBridgeRoutes(t *testing.T) { + t.Parallel() + + newAccount := func(id int64, nativeResponses bool) Account { + return Account{ + ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + "model_mapping": map[string]any{"company-coding-model": "company-coding-model"}, + }, + Extra: map[string]any{"openai_responses_supported": nativeResponses}, + } + } + + for _, tc := range []struct { + name string + accounts []Account + want bool + }{ + {name: "chat bridge", accounts: []Account{newAccount(80, false)}, want: true}, + {name: "native responses", accounts: []Account{newAccount(81, true)}, want: false}, + {name: "mixed routes", accounts: []Account{newAccount(82, false), newAccount(83, true)}, want: false}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + body, err := buildCodexModelsManifestForAccounts( + PlatformOpenAI, []string{"company-coding-model"}, tc.accounts, nil, nil, true, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, tc.want, models[0]["supports_search_tool"]) + }) + } +} + +func TestCompleteAPIKeyCodexManifestSearchCapabilityPreservesUpstreamAndFailsClosed(t *testing.T) { + t.Parallel() + + nativeAccount := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + }, + Extra: map[string]any{"openai_responses_supported": true}, + } + body, err := completeAPIKeyCodexModelsManifestMetadata([]byte(`{"models":[ + {"slug":"explicit","supports_search_tool":true}, + {"slug":"missing"} + ]}`), true, nativeAccount) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Equal(t, true, models[0]["supports_search_tool"]) + require.Equal(t, false, models[1]["supports_search_tool"]) + + chatAccount := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://provider.example/v1", + }, + Extra: map[string]any{"openai_responses_supported": false}, + } + body, err = completeAPIKeyCodexModelsManifestMetadata( + []byte(`{"models":[ + {"slug":"explicit-disabled","supports_search_tool":false}, + {"slug":"generated"} + ]}`), true, chatAccount, + ) + require.NoError(t, err) + models = decodeCodexManifestModels(t, body) + require.Equal(t, false, models[0]["supports_search_tool"]) + require.Equal(t, true, models[1]["supports_search_tool"]) +} + // Scenario: multiple schedulable accounts advertise only their shared capabilities. func TestBuildCodexModelsManifestForGroupIntersectsSyncedAccountMetadata(t *testing.T) { t.Parallel() @@ -395,3 +604,141 @@ func TestBuildCodexModelsManifestForGroupDoesNotAdvertiseNoneWhenAccountReasonin require.False(t, hasDefault) require.Empty(t, models[0]["supported_reasoning_levels"]) } + +func TestAstraCodexToolCapabilitiesFollowAPIKeyAlias(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "base_url": "https://relay.example/v1", "model_mapping": map[string]any{"my-astra": "gpt-6-astra"}, + }} + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "gpt-6-astra": {CodexToolCapabilities: map[string]json.RawMessage{ + "supports_search_tool": json.RawMessage("true"), "apply_patch_tool_type": json.RawMessage(`"freeform"`), + "use_responses_lite": json.RawMessage("false"), + }}, + }}) + for _, source := range []string{`{"data":[{"id":"my-astra"}]}`, `{"models":[{"slug":"my-astra"}]}`} { + converted := convertOpenAIModelListToCodexManifestForAccount([]byte(source), account) + body, err := completeAPIKeyCodexModelsManifestMetadata(converted, true, account) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + model := models[0] + require.Equal(t, "my-astra", model["slug"]) + require.Equal(t, "my-astra", model["display_name"]) + require.Equal(t, true, model["supports_search_tool"]) + require.Equal(t, "freeform", model["apply_patch_tool_type"]) + require.Equal(t, false, model["use_responses_lite"]) + } +} + +func TestAstraCodexToolCapabilitiesKeepAPIKeyResponsesLiteGuard(t *testing.T) { + account := Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "base_url": "https://relay.example/v1", "model_mapping": map[string]any{"my-astra": "gpt-6-astra"}, + }} + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "gpt-6-astra": {CodexToolCapabilities: map[string]json.RawMessage{"use_responses_lite": json.RawMessage("true")}}, + }}) + body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"my-astra"}, []Account{account}, nil, nil, true) + require.NoError(t, err) + require.Equal(t, false, decodeCodexManifestModels(t, body)[0]["use_responses_lite"]) + body, err = adjustAPIKeyCodexModelsManifest([]byte(`{"models":[{"slug":"my-astra","use_responses_lite":true}]}`), &account) + require.NoError(t, err) + require.Equal(t, false, decodeCodexManifestModels(t, body)[0]["use_responses_lite"]) +} + +// Scenario: an OpenAI group keeps a failover account whose mapping points the +// shared public alias at a different upstream model. Without an explicit routing +// rule the alias target is ambiguous and capabilities must fail closed; with one, +// the operator has declared who serves the alias, so the reasoning slider and +// image input must survive. +func TestCodexAliasFailoverMappingHonorsModelRouting(t *testing.T) { + newAccount := func(id int64, target string, priority int) Account { + return Account{ + ID: id, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Priority: priority, + Credentials: map[string]any{ + "base_url": "https://relay.example/v1", + "model_mapping": map[string]any{"gpt-6-astra": target}, + }, + } + } + // Account 16-alike serves the alias natively; account 1-alike is the failover + // and can only reach a different upstream model. + primary := newAccount(16, "gpt-6-astra", 0) + failover := newAccount(1, "gpt-5.6-sol", 10) + accounts := []Account{primary, failover} + + // supported_reasoning_levels entries are {effort, description} objects; the + // slider only cares about the effort names. + manifestFieldsOf := func(t *testing.T, group *Group) ([]string, any, any) { + t.Helper() + body, err := buildCodexModelsManifestForAccounts( + PlatformOpenAI, []string{"gpt-6-astra"}, accounts, group, nil, true, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + model := models[0] + require.Equal(t, "gpt-6-astra", model["slug"]) + var efforts []string + levels, _ := model["supported_reasoning_levels"].([]any) + for _, level := range levels { + entry, ok := level.(map[string]any) + require.True(t, ok, "reasoning level entry must be an object") + effort, ok := entry["effort"].(string) + require.True(t, ok, "reasoning level entry must carry an effort name") + efforts = append(efforts, effort) + } + return efforts, model["default_reasoning_level"], model["input_modalities"] + } + + t.Run("no routing rule still fails closed", func(t *testing.T) { + levels, def, modalities := manifestFieldsOf(t, nil) + require.Empty(t, levels, "ambiguous alias target must not advertise reasoning levels") + require.Nil(t, def) + require.Equal(t, []any{"text", "image"}, modalities, + "image input is resolved per-account independently of routing rules") + }) + + t.Run("routing rule resolves the alias", func(t *testing.T) { + group := &Group{ + ID: 2, + Platform: PlatformOpenAI, + ModelRoutingEnabled: true, + ModelRouting: map[string][]int64{"gpt-6-astra": {16, 1}}, + } + levels, def, modalities := manifestFieldsOf(t, group) + require.Equal(t, + []string{"low", "medium", "high", "xhigh", "max", "ultra"}, + levels, + "routed alias must keep a movable reasoning slider", + ) + require.Equal(t, "medium", def) + require.Contains(t, modalities, "image") + }) + + t.Run("routing disabled falls back to failing closed", func(t *testing.T) { + group := &Group{ + ID: 2, + Platform: PlatformOpenAI, + ModelRoutingEnabled: false, + ModelRouting: map[string][]int64{"gpt-6-astra": {16, 1}}, + } + levels, _, _ := manifestFieldsOf(t, group) + require.Empty(t, levels, "a disabled routing rule must not resolve the alias") + }) + + t.Run("routing rule for another alias does not leak", func(t *testing.T) { + group := &Group{ + ID: 2, + Platform: PlatformOpenAI, + ModelRoutingEnabled: true, + ModelRouting: map[string][]int64{"gpt-5.6-sol": {1}}, + } + levels, _, _ := manifestFieldsOf(t, group) + require.Empty(t, levels, "unrelated routing rules must not resolve this alias") + }) +} + +// Scenario: mixed groups prefer capability metadata synced for the routed account. diff --git a/backend/internal/service/openai_codex_models_pinned.go b/backend/internal/service/openai_codex_models_pinned.go new file mode 100644 index 0000000000..b74b3eafdc --- /dev/null +++ b/backend/internal/service/openai_codex_models_pinned.go @@ -0,0 +1,225 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "sync" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// ErrNoPinnedCodexModelsAccounts signals that an enabled pinned-accounts +// manifest config has no usable account right now (inactive, unschedulable, +// or expired members are skipped, and remaining IDs are no longer bound). +var ErrNoPinnedCodexModelsAccounts = errors.New("no usable pinned codex models manifest accounts") + +// isPinnedCodexModelsAccountUsable decides whether a pinned account may fetch +// the manifest. Deliberately narrower than Account.IsSchedulable(): priority, +// load factor, rate-limit windows, overload windows and temporary +// unschedulable cooldowns are ignored, because the pinned mode exists to keep +// the manifest deterministic regardless of scheduler state. +func isPinnedCodexModelsAccountUsable(account *Account) bool { + if account == nil || !account.IsActive() || !account.Schedulable { + return false + } + if account.AutoPauseOnExpired && account.ExpiresAt != nil && !account.ExpiresAt.After(time.Now()) { + return false + } + return true +} + +// mergeCodexModelsManifestBodies merges manifest bodies from the pinned +// accounts: the first body's top-level envelope is the base, and the models +// arrays are unioned by slug with first-seen (config-order) entries winning. +// Entries without a usable slug are kept once in order of appearance. +func mergeCodexModelsManifestBodies(bodies [][]byte) ([]byte, error) { + if len(bodies) == 0 { + return nil, errors.New("no codex models manifest bodies to merge") + } + var base map[string]json.RawMessage + if err := json.Unmarshal(bodies[0], &base); err != nil { + return nil, fmt.Errorf("decode base codex models manifest: %w", err) + } + if base == nil { + return nil, errors.New("base codex models manifest is not a JSON object") + } + + mergedModels := make([]json.RawMessage, 0, len(bodies)*8) + seenSlug := make(map[string]struct{}) + seenSlugLess := make(map[string]struct{}) + for _, body := range bodies { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("decode codex models manifest body: %w", err) + } + modelsRaw, ok := envelope["models"] + if !ok { + continue + } + var models []json.RawMessage + if err := json.Unmarshal(modelsRaw, &models); err != nil { + return nil, fmt.Errorf("decode codex models manifest models array: %w", err) + } + for _, raw := range models { + var model struct { + Slug string `json:"slug"` + } + slug := "" + if err := json.Unmarshal(raw, &model); err == nil { + slug = strings.TrimSpace(model.Slug) + } + if slug != "" { + if _, exists := seenSlug[slug]; exists { + continue + } + seenSlug[slug] = struct{}{} + } else { + fingerprint := string(bytes.TrimSpace(raw)) + if _, exists := seenSlugLess[fingerprint]; exists { + continue + } + seenSlugLess[fingerprint] = struct{}{} + } + mergedModels = append(mergedModels, raw) + } + } + + encodedModels, err := json.Marshal(mergedModels) + if err != nil { + return nil, fmt.Errorf("encode merged codex models: %w", err) + } + base["models"] = encodedModels + merged, err := json.Marshal(base) + if err != nil { + return nil, fmt.Errorf("encode merged codex models manifest: %w", err) + } + return merged, nil +} + +// FetchPinnedCodexModelsManifest fetches the Codex models manifest using only +// the accounts pinned on the group's codex_models_manifest_config, ignoring +// scheduler priorities, load factors and rate-limit/overload windows. All +// usable accounts are fetched concurrently; successful bodies are merged by +// slug with config-order precedence. It returns the merged manifest and the +// first successful account (in config order) for ops attribution. +// +// - no usable account → ErrNoPinnedCodexModelsAccounts +// - every fetch failed → the last upstream error +// - partial failure → successful accounts are still merged and a warning +// naming the failed account IDs is logged. +func (s *OpenAIGatewayService) FetchPinnedCodexModelsManifest(ctx context.Context, group *Group, clientVersion string) (*OpenAIModelsResponse, *Account, error) { + results, err := s.fetchPinnedOpenAIModels(ctx, group, func(ctx context.Context, account *Account) (*OpenAIModelsResponse, error) { + manifest, err := s.FetchCodexModelsManifest(ctx, account, clientVersion, "") + if err != nil { + return nil, err + } + if err := s.CompleteAPIKeyCodexModelsManifestForClient(manifest, account); err != nil { + return nil, err + } + if err := ApplyPinnedCodexModelsMapping(manifest, account, group); err != nil { + return nil, err + } + return manifest, nil + }) + if err != nil { + return nil, nil, err + } + bodies := make([][]byte, 0, len(results)) + for _, result := range results { + bodies = append(bodies, result.response.Body) + } + merged, err := mergeCodexModelsManifestBodies(bodies) + if err != nil { + return nil, nil, fmt.Errorf("merge pinned codex models manifests: %w", err) + } + return &OpenAIModelsResponse{Body: merged, ETag: codexModelsManifestBodyETag(merged)}, results[0].account, nil +} + +type pinnedOpenAIModelsResult struct { + account *Account + response *OpenAIModelsResponse +} + +// fetchPinnedOpenAIModels shares membership, eligibility, fanout and partial +// failure policy between ordinary model lists and Codex manifests. +func (s *OpenAIGatewayService) fetchPinnedOpenAIModels(ctx context.Context, group *Group, fetch func(context.Context, *Account) (*OpenAIModelsResponse, error)) ([]pinnedOpenAIModelsResult, error) { + if s == nil || s.accountRepo == nil || group == nil { + return nil, ErrNoPinnedCodexModelsAccounts + } + cfg := group.CodexModelsManifestConfig + if group.Platform != PlatformOpenAI || !cfg.Enabled || len(cfg.AccountIDs) == 0 { + return nil, ErrNoPinnedCodexModelsAccounts + } + + members, err := s.accountRepo.ListByGroup(ctx, group.ID) + if err != nil { + return nil, fmt.Errorf("load pinned codex models manifest accounts: %w", err) + } + memberByID := make(map[int64]Account, len(members)) + for _, member := range members { + memberByID[member.ID] = member + } + + // 按配置顺序筛选可用账号;已解绑/已删除的 ID 直接跳过。 + usable := make([]Account, 0, len(cfg.AccountIDs)) + for _, id := range cfg.AccountIDs { + member, ok := memberByID[id] + if !ok || member.Platform != PlatformOpenAI { + continue + } + if !isPinnedCodexModelsAccountUsable(&member) { + continue + } + usable = append(usable, member) + } + if len(usable) == 0 { + return nil, ErrNoPinnedCodexModelsAccounts + } + + results := make([]pinnedOpenAIModelsResult, len(usable)) + fetchErrs := make([]error, len(usable)) + var fetchGroup sync.WaitGroup + for i := range usable { + fetchGroup.Add(1) + go func() { + defer fetchGroup.Done() + response, err := fetch(ctx, &usable[i]) + results[i] = pinnedOpenAIModelsResult{account: &usable[i], response: response} + fetchErrs[i] = err + }() + } + fetchGroup.Wait() + if err := ctx.Err(); err != nil { + return nil, err + } + successes := make([]pinnedOpenAIModelsResult, 0, len(results)) + failedIDs := make([]int64, 0) + var lastErr error + for i, result := range results { + if fetchErrs[i] != nil || result.response == nil { + failedIDs = append(failedIDs, usable[i].ID) + if fetchErrs[i] != nil { + lastErr = fetchErrs[i] + } + continue + } + successes = append(successes, result) + } + if len(successes) == 0 { + if lastErr == nil { + lastErr = infraerrors.New(http.StatusBadGateway, "OPENAI_MODELS_UPSTREAM_FAILED", "pinned model discovery accounts all failed") + } + return nil, lastErr + } + if len(failedIDs) > 0 { + slog.Warn("openai_models_pinned_partial_failure", "group_id", group.ID, "failed_account_ids", failedIDs) + } + return successes, nil +} diff --git a/backend/internal/service/openai_codex_models_pinned_test.go b/backend/internal/service/openai_codex_models_pinned_test.go new file mode 100644 index 0000000000..eee3100c76 --- /dev/null +++ b/backend/internal/service/openai_codex_models_pinned_test.go @@ -0,0 +1,82 @@ +//go:build unit + +package service + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMergeCodexModelsManifestBodiesUnionAndOrder(t *testing.T) { + first := `{"object":"codex.manifest","models":[{"slug":"model-a","display_name":"A from first"},{"slug":"model-b"}]}` + second := `{"object":"codex.manifest","models":[{"slug":"model-a","display_name":"A from second"},{"slug":"model-c"}]}` + + merged, err := mergeCodexModelsManifestBodies([][]byte{[]byte(first), []byte(second)}) + require.NoError(t, err) + + var envelope map[string]json.RawMessage + require.NoError(t, json.Unmarshal(merged, &envelope)) + var objectField string + require.NoError(t, json.Unmarshal(envelope["object"], &objectField)) + require.Equal(t, "codex.manifest", objectField) + + var models []map[string]any + require.NoError(t, json.Unmarshal(envelope["models"], &models)) + require.Len(t, models, 3) + require.Equal(t, "model-a", models[0]["slug"]) + require.Equal(t, "A from first", models[0]["display_name"], "重复 slug 必须取配置顺序靠前账号的条目") + require.Equal(t, "model-b", models[1]["slug"]) + require.Equal(t, "model-c", models[2]["slug"]) +} + +func TestMergeCodexModelsManifestBodiesEnvelopeFromFirstBody(t *testing.T) { + first := `{"object":"codex.manifest","extra_field":"from-first","models":[{"slug":"model-a"}]}` + second := `{"object":"other","extra_field":"from-second","another":"only-in-second","models":[]}` + + merged, err := mergeCodexModelsManifestBodies([][]byte{[]byte(first), []byte(second)}) + require.NoError(t, err) + + var envelope map[string]any + require.NoError(t, json.Unmarshal(merged, &envelope)) + require.Equal(t, "codex.manifest", envelope["object"]) + require.Equal(t, "from-first", envelope["extra_field"], "顶层字段以第一个信封为基底") + require.NotContains(t, envelope, "another") +} + +func TestMergeCodexModelsManifestBodiesHandlesSluglessEntries(t *testing.T) { + first := `{"models":[{"slug":"model-a"},{"display_name":"no slug one"}]}` + second := `{"models":[{"display_name":"no slug one"},{"display_name":"no slug two"},{"slug":"model-a"}]}` + + merged, err := mergeCodexModelsManifestBodies([][]byte{[]byte(first), []byte(second)}) + require.NoError(t, err) + + var envelope struct { + Models []map[string]any `json:"models"` + } + require.NoError(t, json.Unmarshal(merged, &envelope)) + require.Len(t, envelope.Models, 3) + require.Equal(t, "model-a", envelope.Models[0]["slug"]) + require.Equal(t, "no slug one", envelope.Models[1]["display_name"], "无 slug 条目按出现顺序保留一次") + require.Equal(t, "no slug two", envelope.Models[2]["display_name"]) +} + +func TestMergeCodexModelsManifestBodiesSingleBody(t *testing.T) { + body := `{"models":[{"slug":"model-a"}]}` + + merged, err := mergeCodexModelsManifestBodies([][]byte{[]byte(body)}) + require.NoError(t, err) + require.JSONEq(t, body, string(merged)) +} + +func TestMergeCodexModelsManifestBodiesRejectsInvalidInput(t *testing.T) { + _, err := mergeCodexModelsManifestBodies(nil) + require.Error(t, err) + + _, err = mergeCodexModelsManifestBodies([][]byte{[]byte(`{`)}) + require.Error(t, err) + + _, err = mergeCodexModelsManifestBodies([][]byte{[]byte(`{}`), []byte(`{"models":{}`)}) + require.Error(t, err, "models 非数组必须报错") +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 6c97eb9b84..d4f0f48e6e 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -30,24 +30,34 @@ import ( var chatgptCodexModelsURL = "https://chatgpt.com/backend-api/codex/models" const ( - codexModelsManifestCacheBodyLimit = 1 << 20 - codexModelsManifestCacheMaxEntries = 64 - codexModelsManifestCacheTTL = 30 * time.Second - codexModelsManifestCacheStaleTTL = 5 * time.Minute - codexModelsManifestRequestTimeout = 15 * time.Second - codexAutoModelPrefix = "codex-auto-" + openAIModelsCacheBodyLimit = 1 << 20 + // openAIModelsCacheMaxEntries 上限按「账号数 × 客户端版本数 × 代理形态」 + // 估算:缓存同时覆盖 OAuth 与 API Key 账号,且缓存键含 Authorization 与 + // Version 头,不同客户端版本各自占一条;64 条在大规模部署下会被淘汰导致 + // 额外上游请求。单条清单通常几十 KB,512 条最坏内存占用在几十 MB 量级。 + openAIModelsCacheMaxEntries = 512 + // 三段时效:≤TTL 为新鲜(直接返回缓存,零上游请求);TTL 到 StaleTTL 之间 + // 乐观返回旧值并后台单飞刷新(携带上游 ETag,304 续期);超过 StaleTTL 丢弃 + // 缓存同步等待刷新。TTL 取 1 分钟:manifest 变化低频,1 分钟内同账号重复 + // 请求完全吸收;StaleTTL 取 5 分钟,控制旧内容最长可见时间在分钟级。 + openAIModelsCacheTTL = 60 * time.Second + openAIModelsCacheStaleTTL = 5 * time.Minute + codexModelsManifestRequestTimeout = 15 * time.Second + codexAutoModelPrefix = "codex-auto-" ) // FilterCodexModelIDsForGroup removes dedicated media-generation models, // wildcard mapping keys, and Codex automatic modes from a client catalog. -// Automatic modes are retained only when the group's enabled custom model list +// Automatic modes are retained only when the group's enabled model allowlist // explicitly selects the exact slug; account model mappings describe routing // and are not feature opt-ins. Wildcard keys such as "foo-*" are routing -// patterns, not concrete Codex models. +// patterns, not concrete Codex models. When the allowlist is enabled the +// catalog is additionally restricted by FilterForListing (wildcard entries +// expand against the catalog). func FilterCodexModelIDsForGroup(modelIDs []string, group *Group) []string { explicitlyEnabled := make(map[string]struct{}) - if group != nil && group.CustomModelsListEnabled() { - for _, modelID := range group.ModelsListConfig.Models { + if group != nil && group.ModelAllowlistEnabled() { + for _, modelID := range group.ModelAllowlist.Models { modelID = strings.TrimSpace(modelID) if strings.HasPrefix(modelID, codexAutoModelPrefix) { explicitlyEnabled[modelID] = struct{}{} @@ -74,6 +84,9 @@ func FilterCodexModelIDsForGroup(modelIDs []string, group *Group) []string { } filtered = append(filtered, modelID) } + if group != nil && group.ModelAllowlistEnabled() { + filtered = group.ModelAllowlist.FilterForListing(filtered) + } return filtered } @@ -92,8 +105,9 @@ func codexProviderQualifiedModelID(modelID string) string { return strings.TrimPrefix(modelID, "models/") } -// CodexModelsManifest carries the client representation plus caching metadata. -type CodexModelsManifest struct { +// OpenAIModelsResponse carries either an OpenAI model list or a Codex manifest +// together with upstream and client caching metadata. +type OpenAIModelsResponse struct { Body []byte ETag string upstreamETag string @@ -102,15 +116,15 @@ type CodexModelsManifest struct { NotModified bool } -// BuildGroupConfiguredCodexModelsManifest builds a Codex catalog exclusively -// from the public model names configured on accounts in an OpenAI group. The +// BuildGroupConfiguredCodexModelsManifest builds a Codex catalog from configured +// public model names, supplemented by defaults for unmapped OpenAI accounts. The // boolean result distinguishes "no explicit configuration" from a configured // catalog that becomes empty after group-level filtering. func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( ctx context.Context, group *Group, ifNoneMatch string, -) (*CodexModelsManifest, bool, error) { +) (*OpenAIModelsResponse, bool, error) { if s == nil || s.accountRepo == nil || group == nil || group.Platform != PlatformOpenAI { return nil, false, nil } @@ -128,6 +142,7 @@ func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( PlatformOpenAI, configuredModels, catalog, + group, nil, true, ) @@ -137,13 +152,13 @@ func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( body, _, err = mergeConfiguredCodexModelsManifest( body, nil, - group.ModelsListConfig.Models, - group.CustomModelsListEnabled(), + group.ModelAllowlist.Models, + group.ModelAllowlistEnabled(), ) if err != nil { return nil, false, fmt.Errorf("build group configured Codex models: %w", err) } - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: body, ETag: codexModelsManifestBodyETag(body), } @@ -161,7 +176,7 @@ func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels( ctx context.Context, group *Group, - manifest *CodexModelsManifest, + manifest *OpenAIModelsResponse, ifNoneMatch string, ) error { if s == nil || s.accountRepo == nil || group == nil || manifest == nil || manifest.NotModified { @@ -171,19 +186,30 @@ func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels( return nil } - configuredModels, err := s.groupConfiguredCodexModelIDs(ctx, group) - if err != nil { - return fmt.Errorf("load group configured Codex models: %w", err) + var configuredModels []string + if !group.CodexModelsManifestConfig.Enabled { + var err error + configuredModels, err = s.groupConfiguredCodexModelIDs(ctx, group) + if err != nil { + return fmt.Errorf("load group configured Codex models: %w", err) + } } body, changed, err := mergeConfiguredCodexModelsManifest( manifest.Body, configuredModels, - group.ModelsListConfig.Models, - group.CustomModelsListEnabled(), + group.ModelAllowlist.Models, + group.ModelAllowlistEnabled(), ) if err != nil { return fmt.Errorf("merge group configured Codex models: %w", err) } + if group.CodexModelsManifestConfig.Enabled && group.ModelAllowlistEnabled() { + body, err = orderPinnedCodexModelsBySelection(body, group.ModelAllowlist) + if err != nil { + return fmt.Errorf("order pinned Codex models: %w", err) + } + changed = true + } if changed { manifest.Body = body manifest.ETag = codexModelsManifestBodyETag(body) @@ -235,6 +261,7 @@ func loadCodexGroupCatalogAccounts(ctx context.Context, repo AccountRepository, PlatformKimi, PlatformZhipu, PlatformDeepseek, + PlatformMiniMax, }, false, ) @@ -269,16 +296,16 @@ func openAIConfiguredCodexModelIDs(accounts []Account) []string { } func openAIConfiguredCodexModelIDsForGroup(accounts []Account, group *Group) []string { - models := openAIConfiguredCodexModelIDs(accounts) - if group == nil || !group.CustomModelsListEnabled() { + models := supplementUnmappedOpenAIModels(accounts, openAIConfiguredCodexModelIDs(accounts)) + if group == nil || !group.ModelAllowlistEnabled() { return models } - seen := make(map[string]struct{}, len(models)+len(group.ModelsListConfig.Models)) + seen := make(map[string]struct{}, len(models)+len(group.ModelAllowlist.Models)) for _, modelID := range models { seen[modelID] = struct{}{} } - for _, selectedModel := range group.ModelsListConfig.Models { + for _, selectedModel := range group.ModelAllowlist.Models { selectedModel = strings.TrimSpace(selectedModel) if selectedModel == "" || strings.Contains(selectedModel, "*") { continue @@ -311,6 +338,7 @@ const ( configuredCodexGrokContext = 500_000 configuredCodexGrokBuildContext = 256_000 configuredCodexGPT56MaxContext = 872_000 + configuredCodexGPT6AstraContext = 1_050_000 configuredCodexToolOutputMaxTokens = 10_000 ) @@ -352,6 +380,7 @@ type configuredCodexModelDescriptor struct { Description string `json:"description"` DefaultReasoningLevel *string `json:"default_reasoning_level,omitempty"` SupportedReasoningLevels []configuredCodexReasoningLevel `json:"supported_reasoning_levels"` + MultiAgentReasoningEffort *string `json:"multi_agent_reasoning_effort,omitempty"` ShellType string `json:"shell_type"` Visibility string `json:"visibility"` SupportedInAPI bool `json:"supported_in_api"` @@ -469,15 +498,7 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr descriptor.DisplayName = openaiCodexDisplayName(modelID) descriptor.Description = "OpenAI GPT coding model routed through Sub2API." descriptor.SupportsParallelToolCalls = true - if configuredCodexSupportsPriorityServiceTier(modelID) { - descriptor.ServiceTiers = []configuredCodexServiceTier{ - { - ID: "priority", - Name: "Fast", - Description: "Priority processing for lower latency.", - }, - } - } + descriptor.ServiceTiers = configuredCodexServiceTiersForModel(modelID) if isOpenAICodexReasoningGPTModel(modelID) { defaultReasoningLevel := "medium" if getNormalizedCodexModel(modelID) == "gpt-5.6-sol" { @@ -490,6 +511,15 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr if isOpenAIGPT56Model(modelID) { descriptor.MaxContextWindow = configuredCodexGPT56MaxContext } + if isOpenAIGPT6AstraModel(modelID) { + // Codex resolves the Ultra workflow to this effort before inference. + // openai/codex a9896da3: codex-rs/models-manager/models.json. + multiAgentEffort := "xhigh" + descriptor.MultiAgentReasoningEffort = &multiAgentEffort + descriptor.MultiAgentVersion = "v2" + descriptor.ContextWindow = configuredCodexGPT6AstraContext + descriptor.MaxContextWindow = configuredCodexGPT6AstraContext + } } if SupportsVerbosity(modelID) { defaultVerbosity := "low" @@ -501,6 +531,25 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr return descriptor } +func configuredCodexServiceTiersForModel(modelID string) []configuredCodexServiceTier { + tiers := make([]configuredCodexServiceTier, 0, 2) + if configuredCodexSupportsPriorityServiceTier(modelID) { + tiers = append(tiers, configuredCodexServiceTier{ + ID: OpenAIFastTierPriority, + Name: "Fast", + Description: "Priority processing for lower latency.", + }) + } + if configuredCodexSupportsUltrafastServiceTier(modelID) { + tiers = append(tiers, configuredCodexServiceTier{ + ID: OpenAIFastTierUltrafast, + Name: "Ultrafast", + Description: "Ultra-low latency processing.", + }) + } + return tiers +} + func configuredCodexSupportsPriorityServiceTier(modelID string) bool { normalized := canonicalizeOpenAIModelAliasSpelling(modelID) for _, family := range []string{"gpt-5.4", "gpt-5.5", "gpt-5.6"} { @@ -508,7 +557,12 @@ func configuredCodexSupportsPriorityServiceTier(modelID string) bool { return true } } - return false + // GPT-6 Astra advertises Fast via service_tier=priority in public model metadata. + return isOpenAIGPT6AstraModel(modelID) +} + +func configuredCodexSupportsUltrafastServiceTier(modelID string) bool { + return normalizeKnownOpenAICodexModel(modelID) == "gpt-5.6-sol" } func configuredCodexGrokReasoningLevels(modelID string) []configuredCodexReasoningLevel { @@ -567,13 +621,13 @@ func configuredCodexGPTReasoningLevels(modelID string) []configuredCodexReasonin {Effort: "xhigh", Description: "Extra-high reasoning depth for difficult tasks"}, } normalized := getNormalizedCodexModel(modelID) - if isOpenAIGPT56Model(modelID) { + if isOpenAIGPT56Model(modelID) || isOpenAIGPT6AstraModel(modelID) { levels = append(levels, configuredCodexReasoningLevel{ Effort: "max", Description: "Maximum reasoning depth for complex tasks", }) } - if normalized == "gpt-5.6-sol" || normalized == "gpt-5.6-terra" { + if isOpenAIGPT6AstraModel(modelID) || normalized == "gpt-5.6-sol" || normalized == "gpt-5.6-terra" { levels = append(levels, configuredCodexReasoningLevel{ Effort: "ultra", Description: "Maximum reasoning with automatic task delegation", @@ -592,12 +646,13 @@ func isOpenAICodexGPTModel(modelID string) bool { func isOpenAICodexReasoningGPTModel(modelID string) bool { normalized := canonicalizeOpenAIModelAliasSpelling(modelID) - return strings.HasPrefix(normalized, "gpt-5") + return isOpenAIGPT6AstraModel(normalized) || strings.HasPrefix(normalized, "gpt-5") } func isOpenAICodexImageInputModel(modelID string) bool { normalized := canonicalizeOpenAIModelAliasSpelling(modelID) - return strings.HasPrefix(normalized, "gpt-5") || + return isOpenAIGPT6AstraModel(normalized) || + strings.HasPrefix(normalized, "gpt-5") || strings.HasPrefix(normalized, "gpt-4o") || strings.HasPrefix(normalized, "gpt-4.1") || strings.HasPrefix(normalized, "gpt-4.5") || @@ -734,7 +789,7 @@ func claudeCodexDisplayName(modelID string) string { // routed through a custom provider. The response is also suitable for saving // as model_catalog_json in clients that do not refresh custom-provider catalogs. func BuildCodexModelsManifest(modelIDs []string) ([]byte, error) { - return buildCodexModelsManifest(modelIDs, nil, nil, nil) + return buildCodexModelsManifest(modelIDs, nil, nil, nil, nil) } // BuildCodexModelsManifestForGroup derives input capabilities from the @@ -776,6 +831,7 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup( effectivePlatform, modelIDs, catalog, + group, compositeRoutes, compositeRoutesAvailable, ) @@ -785,10 +841,12 @@ func buildCodexModelsManifestForAccounts( effectivePlatform string, modelIDs []string, accounts []Account, + group *Group, compositeRoutes []CompositeModelRoute, compositeRoutesAvailable bool, ) ([]byte, error) { imageInputModels := make(map[string]bool, len(modelIDs)) + searchToolModels := make(map[string]bool, len(modelIDs)) metadataModels := codexCatalogMetadataModels( effectivePlatform, modelIDs, @@ -808,27 +866,38 @@ func buildCodexModelsManifestForAccounts( ) { imageInputModels[modelID] = true } + if groupCodexModelSupportsSearchTool( + effectivePlatform, + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) { + searchToolModels[modelID] = true + } if metadata, ok := groupCodexModelMetadata( effectivePlatform, modelID, accounts, + group, compositeRoutes, compositeRoutesAvailable, ); ok { modelMetadata[modelID] = metadata } } - return buildCodexModelsManifest(modelIDs, imageInputModels, metadataModels, modelMetadata) + return buildCodexModelsManifest(modelIDs, imageInputModels, searchToolModels, metadataModels, modelMetadata) } func buildCodexModelsManifest( modelIDs []string, imageInputModels map[string]bool, + searchToolModels map[string]bool, metadataModels map[string]string, modelMetadata map[string]codexModelMetadataOverride, ) ([]byte, error) { seen := make(map[string]struct{}, len(modelIDs)) - models := make([]configuredCodexModelDescriptor, 0, len(modelIDs)) + models := make([]json.RawMessage, 0, len(modelIDs)) for _, modelID := range modelIDs { modelID = strings.TrimSpace(modelID) if modelID == "" { @@ -847,20 +916,38 @@ func buildCodexModelsManifest( seen[modelID] = struct{}{} descriptor := newConfiguredCodexModelDescriptor(metadataModelID) descriptor.Slug = modelID - if imageInputModels[modelID] { - descriptor.InputModalities = []string{"text", "image"} - } + descriptor.SupportsSearchTool = searchToolModels[modelID] if metadata, ok := modelMetadata[modelID]; ok { applyUpstreamModelMetadataToCodexDescriptor(&descriptor, metadata) } + if imageInputModels[modelID] { + // Apply the capability-derived modality after upstream metadata so + // a stale official Astra snapshot cannot downgrade the catalog. + descriptor.InputModalities = []string{"text", "image"} + } if metadataModelID != modelID { descriptor.DisplayName = modelID descriptor.Description = configuredCodexCustomDescription } - models = append(models, descriptor) + encoded, err := json.Marshal(descriptor) + if err != nil { + return nil, err + } + if capabilities := modelMetadata[modelID].CodexToolCapabilities; len(capabilities) > 0 { + var fields map[string]json.RawMessage + if err := json.Unmarshal(encoded, &fields); err != nil { + return nil, err + } + applyCodexToolCapabilities(fields, capabilities, true) + encoded, err = json.Marshal(fields) + if err != nil { + return nil, err + } + } + models = append(models, encoded) } return json.Marshal(struct { - Models []configuredCodexModelDescriptor `json:"models"` + Models []json.RawMessage `json:"models"` }{Models: models}) } @@ -1005,6 +1092,52 @@ func groupCodexModelSupportsImageInput( return candidates > 0 } +// groupCodexModelSupportsSearchTool advertises client-side tool discovery only +// when every account that may serve the model uses the gateway's Responses to +// Chat Completions bridge. Native Responses routes must declare the capability +// in their upstream Codex manifest instead of having it inferred here. +func groupCodexModelSupportsSearchTool( + platform string, + modelID string, + accounts []Account, + compositeRoutes []CompositeModelRoute, + compositeRoutesAvailable bool, +) bool { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + return false + } + upstreamModel := modelID + if platform == PlatformComposite { + var resolved bool + platform, upstreamModel, resolved = resolveCodexCompositeModelTarget( + modelID, + accounts, + compositeRoutes, + compositeRoutesAvailable, + ) + if !resolved { + return false + } + } + if platform != PlatformOpenAI { + return false + } + + candidates := 0 + for i := range accounts { + account := &accounts[i] + if account.Platform != platform || !account.IsModelSupported(upstreamModel) { + continue + } + candidates++ + if !shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + return false + } + } + return candidates > 0 +} + func resolveCodexCompositeModelTarget( modelID string, accounts []Account, @@ -1083,6 +1216,13 @@ func accountCodexModelSupportsImageInput(account *Account, upstreamModel string) case PlatformOpenAI: if metadata, ok := account.GetUpstreamModelMetadata(upstreamModel); ok { if modalities := normalizeCodexInputModalities(metadata.InputModalities); len(modalities) > 0 { + // Official GPT-6 Astra metadata briefly shipped with a stale + // text-only modality list. Keep explicit provider metadata + // authoritative for compatible hosts, but repair that stale + // official snapshot at the capability boundary. + if isOpenAIGPT6AstraModel(upstreamModel) && isOfficialOpenAICodexAccount(account) { + return true + } return stringSliceContains(modalities, "image") } } @@ -1109,6 +1249,16 @@ func accountCodexModelSupportsImageInput(account *Account, upstreamModel string) } } +func isOfficialOpenAICodexAccount(account *Account) bool { + if account == nil || account.Platform != PlatformOpenAI { + return false + } + if account.IsOpenAIOAuth() { + return true + } + return account.IsOpenAIApiKey() && isOfficialOpenAIModelsBaseURL(account.GetOpenAIBaseURL()) +} + func isGrokCodexImageInputModel(model string) bool { switch strings.ToLower(strings.TrimSpace(model)) { case "grok-4.3", @@ -1160,6 +1310,8 @@ func mergeConfiguredCodexModelsManifest( selected[modelID] = struct{}{} } } + // 白名单条目匹配统一走 GroupModelAllowlist.Allows(通配条目按前缀展开)。 + allowlist := GroupModelAllowlist{Enabled: filterBySelection, Models: selectedModels} seen := make(map[string]struct{}, len(upstreamModels)+len(configuredModels)) merged := make([]json.RawMessage, 0, len(upstreamModels)+len(configuredModels)) changed := false @@ -1180,11 +1332,9 @@ func mergeConfiguredCodexModelsManifest( changed = true continue } - if filterBySelection { - if _, allowed := selected[descriptor.Slug]; !allowed { - changed = true - continue - } + if filterBySelection && !allowlist.Allows(descriptor.Slug) { + changed = true + continue } if strings.HasPrefix(descriptor.Slug, codexAutoModelPrefix) { _, explicitlyEnabled := selected[descriptor.Slug] @@ -1208,10 +1358,8 @@ func mergeConfiguredCodexModelsManifest( if isCodexDedicatedMediaModel(modelID) { continue } - if filterBySelection { - if _, allowed := selected[modelID]; !allowed { - continue - } + if filterBySelection && !allowlist.Allows(modelID) { + continue } if strings.HasPrefix(modelID, codexAutoModelPrefix) { if _, explicitlyEnabled := selected[modelID]; !filterBySelection || !explicitlyEnabled { @@ -1374,7 +1522,7 @@ func isRetryableCodexModelsManifestTransportError(err error) bool { return false } -type codexModelsManifestRequest struct { +type openAIModelsRequest struct { url string headers http.Header proxyURL string @@ -1383,61 +1531,63 @@ type codexModelsManifestRequest struct { credentialAccount *Account accountConcurrency int useAPIKeyUpstream bool + // Cached bodies have already been converted to their requested format. + standardModelsList bool } -type codexModelsManifestCacheEntry struct { - manifest *CodexModelsManifest +type openAIModelsCacheEntry struct { + manifest *OpenAIModelsResponse order uint64 expiresAt time.Time staleUntil time.Time } -type codexModelsManifestCacheState uint8 +type openAIModelsCacheState uint8 const ( - codexModelsManifestCacheMiss codexModelsManifestCacheState = iota - codexModelsManifestCacheFresh - codexModelsManifestCacheStale + openAIModelsCacheMiss openAIModelsCacheState = iota + openAIModelsCacheFresh + openAIModelsCacheStale ) -type codexModelsManifestCache struct { +type openAIModelsCache struct { mu sync.Mutex - entries map[string]codexModelsManifestCacheEntry + entries map[string]openAIModelsCacheEntry nextOrder uint64 refresh singleflight.Group } -func (c *codexModelsManifestCache) get(key string, now time.Time) (*CodexModelsManifest, codexModelsManifestCacheState) { +func (c *openAIModelsCache) get(key string, now time.Time) (*OpenAIModelsResponse, openAIModelsCacheState) { c.mu.Lock() defer c.mu.Unlock() entry, ok := c.entries[key] if !ok { - return nil, codexModelsManifestCacheMiss + return nil, openAIModelsCacheMiss } if !now.Before(entry.staleUntil) { delete(c.entries, key) - return nil, codexModelsManifestCacheMiss + return nil, openAIModelsCacheMiss } if now.Before(entry.expiresAt) { - return entry.manifest, codexModelsManifestCacheFresh + return entry.manifest, openAIModelsCacheFresh } - return entry.manifest, codexModelsManifestCacheStale + return entry.manifest, openAIModelsCacheStale } -func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest, now time.Time) { - if manifest == nil || len(manifest.Body) > codexModelsManifestCacheBodyLimit { +func (c *openAIModelsCache) set(key string, manifest *OpenAIModelsResponse, now time.Time) { + if manifest == nil || len(manifest.Body) > openAIModelsCacheBodyLimit { return } - remainingBodyBudget := codexModelsManifestCacheBodyLimit - len(manifest.Body) + remainingBodyBudget := openAIModelsCacheBodyLimit - len(manifest.Body) if len(manifest.upstreamSourceBody) > remainingBodyBudget { return } c.mu.Lock() defer c.mu.Unlock() if c.entries == nil { - c.entries = make(map[string]codexModelsManifestCacheEntry) + c.entries = make(map[string]openAIModelsCacheEntry) } - if _, exists := c.entries[key]; !exists && len(c.entries) >= codexModelsManifestCacheMaxEntries { + if _, exists := c.entries[key]; !exists && len(c.entries) >= openAIModelsCacheMaxEntries { oldestKey := "" var oldestOrder uint64 for candidateKey, entry := range c.entries { @@ -1450,16 +1600,16 @@ func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest oldestOrder = entry.order } } - if len(c.entries) >= codexModelsManifestCacheMaxEntries && oldestKey != "" { + if len(c.entries) >= openAIModelsCacheMaxEntries && oldestKey != "" { delete(c.entries, oldestKey) } } c.nextOrder++ - c.entries[key] = codexModelsManifestCacheEntry{ + c.entries[key] = openAIModelsCacheEntry{ manifest: manifest, order: c.nextOrder, - expiresAt: now.Add(codexModelsManifestCacheTTL), - staleUntil: now.Add(codexModelsManifestCacheStaleTTL), + expiresAt: now.Add(openAIModelsCacheTTL), + staleUntil: now.Add(openAIModelsCacheStaleTTL), } } @@ -1469,7 +1619,7 @@ func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest // After validating the stable top-level envelope, OAuth response bodies are // passed through verbatim. Custom API key manifests receive only the narrowly // scoped compatibility adjustments required by custom-provider Codex clients. -func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, account *Account, clientVersion, ifNoneMatch string) (*CodexModelsManifest, error) { +func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, account *Account, clientVersion, ifNoneMatch string) (*OpenAIModelsResponse, error) { if account == nil { return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_ACCOUNT_REQUIRED", "account is required") } @@ -1557,7 +1707,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc proxyURL = account.Proxy.URL() } - request := codexModelsManifestRequest{ + request := openAIModelsRequest{ url: requestURL.String(), headers: headers, proxyURL: proxyURL, @@ -1568,30 +1718,35 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc useAPIKeyUpstream: useAPIKeyUpstream, } if useAPIKeyUpstream { - return s.fetchCachedAPIKeyCodexModelsManifest(ctx, request, ifNoneMatch) + return s.fetchCachedOpenAIModels(ctx, request, s.fetchCodexModelsManifestUpstreamForRequest(request), ifNoneMatch) } - manifest, fetchErr := s.fetchCodexModelsManifestUpstream(ctx, request, ifNoneMatch) - if !credAccount.IsOpenAIAgentIdentity() || !isAgentIdentityTaskInvalidCodexModelsError(fetchErr) { - s.handleCodexModelsManifestAccountAuthError(ctx, account, credAccount, fetchErr) - return manifest, fetchErr - } - expectedTaskID := strings.TrimSpace(credAccount.GetCredential("task_id")) - if recoverErr := s.recoverAgentIdentityTask(ctx, credAccount, expectedTaskID); recoverErr != nil { - return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_AUTH_FAILED", "agent identity task recovery failed: %v", recoverErr) - } - authHeaders, authErr := s.buildOpenAIAuthenticationHeaders(ctx, credAccount, "") - if authErr != nil { - return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_AUTH_FAILED", "build Codex models authentication after task recovery: %v", authErr) - } - request.headers.Del("Authorization") - request.headers.Del("ChatGPT-Account-ID") - for key, values := range authHeaders { - for _, value := range values { - request.headers.Add(key, value) + // OAuth 账号同样经过账号级缓存;闭包保留 agent identity 任务恢复逻辑, + // 错误时仍交给 handleCodexModelsManifestAccountAuthError 处理账号状态。 + oauthFetch := func(fetchCtx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error) { + manifest, fetchErr := s.fetchCodexModelsManifestUpstream(fetchCtx, request, ifNoneMatch) + if !credAccount.IsOpenAIAgentIdentity() || !isAgentIdentityTaskInvalidCodexModelsError(fetchErr) { + s.handleCodexModelsManifestAccountAuthError(fetchCtx, account, credAccount, fetchErr) + return manifest, fetchErr } + expectedTaskID := strings.TrimSpace(credAccount.GetCredential("task_id")) + if recoverErr := s.recoverAgentIdentityTask(fetchCtx, credAccount, expectedTaskID); recoverErr != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_AUTH_FAILED", "agent identity task recovery failed: %v", recoverErr) + } + authHeaders, authErr := s.buildOpenAIAuthenticationHeaders(fetchCtx, credAccount, "") + if authErr != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_CODEX_MODELS_AUTH_FAILED", "build Codex models authentication after task recovery: %v", authErr) + } + request.headers.Del("Authorization") + request.headers.Del("ChatGPT-Account-ID") + for key, values := range authHeaders { + for _, value := range values { + request.headers.Add(key, value) + } + } + setOpenAIChatGPTAccountHeaders(request.headers, credAccount) + return s.fetchCodexModelsManifestUpstream(fetchCtx, request, ifNoneMatch) } - setOpenAIChatGPTAccountHeaders(request.headers, credAccount) - return s.fetchCodexModelsManifestUpstream(ctx, request, ifNoneMatch) + return s.fetchCachedOpenAIModels(ctx, request, oauthFetch, ifNoneMatch) } func isAgentIdentityTaskInvalidCodexModelsError(err error) bool { @@ -1631,18 +1786,18 @@ func (s *OpenAIGatewayService) handleCodexModelsManifestAccountAuthError(ctx con s.handleOpenAIAccountUpstreamError(ctx, account, upstreamErr.statusCode, headers, upstreamErr.body) } -func (s *OpenAIGatewayService) fetchCachedAPIKeyCodexModelsManifest(ctx context.Context, request codexModelsManifestRequest, ifNoneMatch string) (*CodexModelsManifest, error) { +func (s *OpenAIGatewayService) fetchCachedOpenAIModels(ctx context.Context, request openAIModelsRequest, fetch func(ctx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error), ifNoneMatch string) (*OpenAIModelsResponse, error) { if err := ctx.Err(); err != nil { return nil, err } - cacheKey := buildCodexModelsManifestCacheKey(request) - manifest, state := s.codexModelsManifestCache.get(cacheKey, time.Now()) - if state == codexModelsManifestCacheFresh { - return codexModelsManifestForClient(manifest, ifNoneMatch), nil + cacheKey := buildOpenAIModelsCacheKey(request) + manifest, state := s.openAIModelsCache.get(cacheKey, time.Now()) + if state == openAIModelsCacheFresh { + return openAIModelsResponseForClient(manifest, ifNoneMatch), nil } - resultCh := s.refreshCachedAPIKeyCodexModelsManifest(cacheKey, request) - if state == codexModelsManifestCacheStale { - return codexModelsManifestForClient(manifest, ifNoneMatch), nil + resultCh := s.refreshCachedOpenAIModels(cacheKey, request, fetch) + if state == openAIModelsCacheStale { + return openAIModelsResponseForClient(manifest, ifNoneMatch), nil } select { case <-ctx.Done(): @@ -1651,37 +1806,45 @@ func (s *OpenAIGatewayService) fetchCachedAPIKeyCodexModelsManifest(ctx context. if result.Err != nil { return nil, result.Err } - manifest, ok := result.Val.(*CodexModelsManifest) + manifest, ok := result.Val.(*OpenAIModelsResponse) if !ok || manifest == nil { return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_CODEX_MODELS_REQUEST_FAILED", "invalid shared Codex models manifest result") } - return codexModelsManifestForClient(manifest, ifNoneMatch), nil + return openAIModelsResponseForClient(manifest, ifNoneMatch), nil } } -func (s *OpenAIGatewayService) refreshCachedAPIKeyCodexModelsManifest(cacheKey string, request codexModelsManifestRequest) <-chan singleflight.Result { - return s.codexModelsManifestCache.refresh.DoChan(cacheKey, func() (any, error) { - cached, _ := s.codexModelsManifestCache.get(cacheKey, time.Now()) +func (s *OpenAIGatewayService) refreshCachedOpenAIModels(cacheKey string, request openAIModelsRequest, fetch func(ctx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error)) <-chan singleflight.Result { + return s.openAIModelsCache.refresh.DoChan(cacheKey, func() (any, error) { + ctx, cancel := context.WithTimeout(context.Background(), codexModelsManifestRequestTimeout) + defer cancel() + cached, _ := s.openAIModelsCache.get(cacheKey, time.Now()) ifNoneMatch := "" if cached != nil { ifNoneMatch = cached.upstreamETag } - manifest, err := s.fetchCodexModelsManifestUpstream(context.Background(), request, ifNoneMatch) + manifest, err := fetch(ctx, ifNoneMatch) if err != nil { return nil, err } if manifest.NotModified && cached != nil { - s.codexModelsManifestCache.set(cacheKey, cached, time.Now()) + s.openAIModelsCache.set(cacheKey, cached, time.Now()) return cached, nil } if !manifest.NotModified { - s.codexModelsManifestCache.set(cacheKey, manifest, time.Now()) + s.openAIModelsCache.set(cacheKey, manifest, time.Now()) } return manifest, nil }) } -func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Context, request codexModelsManifestRequest, ifNoneMatch string) (*CodexModelsManifest, error) { +func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstreamForRequest(request openAIModelsRequest) func(ctx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error) { + return func(ctx context.Context, ifNoneMatch string) (*OpenAIModelsResponse, error) { + return s.fetchCodexModelsManifestUpstream(ctx, request, ifNoneMatch) + } +} + +func (s *OpenAIGatewayService) fetchOpenAIModelsUpstream(ctx context.Context, request openAIModelsRequest, ifNoneMatch string) (*OpenAIModelsResponse, error) { reqCtx, cancel := context.WithTimeout(ctx, codexModelsManifestRequestTimeout) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, request.url, nil) @@ -1726,7 +1889,7 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusNotModified { - return &CodexModelsManifest{ETag: resp.Header.Get("ETag"), NotModified: true}, nil + return &OpenAIModelsResponse{ETag: resp.Header.Get("ETag"), NotModified: true}, nil } if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) @@ -1758,6 +1921,17 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont retryable: true, } } + etag := resp.Header.Get("ETag") + return &OpenAIModelsResponse{Body: body, ETag: etag, upstreamETag: etag}, nil +} + +func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Context, request openAIModelsRequest, ifNoneMatch string) (*OpenAIModelsResponse, error) { + response, err := s.fetchOpenAIModelsUpstream(ctx, request, ifNoneMatch) + if err != nil || response.NotModified { + return response, err + } + body := response.Body + upstreamBody := body convertedFromOpenAIModelList := false if request.useAPIKeyUpstream { @@ -1793,7 +1967,7 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont retryable: true, } } - body, err = adjustAPIKeyCodexModelsManifest(body) + body, err = adjustAPIKeyCodexModelsManifest(body, request.credentialAccount) if err != nil { return nil, &codexModelsManifestUpstreamError{ err: infraerrors.Newf( @@ -1806,18 +1980,16 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont } } } - etag := resp.Header.Get("ETag") - manifest := &CodexModelsManifest{ + etag := response.upstreamETag + manifest := &OpenAIModelsResponse{ Body: body, ETag: etag, + upstreamETag: etag, upstreamSourceBody: append([]byte(nil), upstreamBody...), convertedFromOpenAIModelList: convertedFromOpenAIModelList, } - if request.useAPIKeyUpstream { - manifest.upstreamETag = etag - if !bytes.Equal(body, upstreamBody) { - manifest.ETag = codexModelsManifestBodyETag(body) - } + if request.useAPIKeyUpstream && !bytes.Equal(body, upstreamBody) { + manifest.ETag = codexModelsManifestBodyETag(body) } return manifest, nil } @@ -1834,6 +2006,7 @@ func CodexModelsManifestETag(body []byte) string { } var apiKeyCodexModelsWithoutResponsesLite = map[string]struct{}{ + "gpt-6-astra": {}, "gpt-5.6-sol": {}, "gpt-5.6-terra": {}, "gpt-5.6-luna": {}, @@ -1843,7 +2016,7 @@ var apiKeyCodexModelsWithoutResponsesLite = map[string]struct{}{ // Lite for custom API key providers. Those clients do not install web.run in // Lite mode, so the affected model manifests must advertise the full Responses // path. Return the original body when no targeted true value is present. -func adjustAPIKeyCodexModelsManifest(body []byte) ([]byte, error) { +func adjustAPIKeyCodexModelsManifest(body []byte, account *Account) ([]byte, error) { var envelope map[string]json.RawMessage if err := json.Unmarshal(body, &envelope); err != nil { return nil, fmt.Errorf("decode JSON object: %w", err) @@ -1863,7 +2036,14 @@ func adjustAPIKeyCodexModelsManifest(body []byte) ([]byte, error) { if err := json.Unmarshal(model["slug"], &slug); err != nil { continue } - if _, targeted := apiKeyCodexModelsWithoutResponsesLite[slug]; !targeted { + target := slug + if account != nil { + target = account.GetMappedModel(slug) + } + if isOpenAIGPT6AstraModel(target) { + target = "gpt-6-astra" + } + if _, targeted := apiKeyCodexModelsWithoutResponsesLite[target]; !targeted { continue } var useResponsesLite bool @@ -1916,19 +2096,33 @@ func convertOpenAIModelListToCodexManifestForAccount(body []byte, account *Accou if !ok { return body } - var entries []struct { - ID string `json:"id"` - } + var entries []map[string]json.RawMessage if err := json.Unmarshal(data, &entries); err != nil { return body } modelIDs := make([]string, 0, len(entries)) + modelMetadata := make(map[string]codexModelMetadataOverride, len(entries)) + metadataModels := make(map[string]string, len(entries)) for _, entry := range entries { - id := strings.TrimSpace(entry.ID) + var id string + if err := json.Unmarshal(entry["id"], &id); err != nil { + continue + } + id = strings.TrimSpace(id) if id == "" { continue } modelIDs = append(modelIDs, id) + capabilityModel := id + if account != nil { + capabilityModel = account.GetMappedModel(id) + } + metadataModels[id] = capabilityModel + capabilities := accountCodexToolCapabilities(account, capabilityModel) + applyCodexToolCapabilities(capabilities, entry, true) + modelMetadata[id] = codexModelMetadataOverride{UpstreamModelMetadata: UpstreamModelMetadata{ + CodexToolCapabilities: capabilities, + }} } if len(modelIDs) == 0 { return body @@ -1939,7 +2133,13 @@ func convertOpenAIModelListToCodexManifestForAccount(body []byte, account *Accou imageInputModels[modelID] = true } } - converted, err := buildCodexModelsManifest(modelIDs, imageInputModels, nil, nil) + searchToolModels := make(map[string]bool, len(modelIDs)) + if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { + for _, modelID := range modelIDs { + searchToolModels[modelID] = true + } + } + converted, err := buildCodexModelsManifest(modelIDs, imageInputModels, searchToolModels, metadataModels, modelMetadata) if err != nil { return body } @@ -1952,7 +2152,7 @@ func convertOpenAIModelListToCodexManifestForAccount(body []byte, account *Accou // CompleteAPIKeyCodexModelsManifestForClient fills the complete ModelInfo // contract immediately before a group-specific API key manifest is returned. // The shared upstream cache remains independent from local group policy. -func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manifest *CodexModelsManifest, account *Account) error { +func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manifest *OpenAIModelsResponse, account *Account) error { if manifest == nil || account == nil || !account.IsOpenAIApiKey() || manifest.NotModified || len(manifest.Body) == 0 { return nil } @@ -1976,7 +2176,7 @@ func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manife if err != nil { return err } - body, err = adjustAPIKeyCodexModelsManifest(body) + body, err = adjustAPIKeyCodexModelsManifest(body, account) if err != nil { return err } @@ -2011,10 +2211,15 @@ func applySyncedAPIKeyCodexModelMetadata(body []byte, account *Account, overwrit continue } slug = strings.TrimSpace(slug) - metadata, ok := snapshot.Models[slug] + lookupModel := account.GetMappedModel(slug) + metadata, ok := snapshot.Models[lookupModel] if !ok { continue } + if lookupModel != slug { + metadata.DisplayName = "" + metadata.Description = "" + } descriptor := newConfiguredCodexModelDescriptor(slug) applyUpstreamModelMetadataToCodexDescriptor( @@ -2047,7 +2252,8 @@ func applySyncedAPIKeyCodexModelMetadata(body []byte, account *Account, overwrit fields = append(fields, "context_window", "max_context_window") } - modelChanged := false + // List conversion has already applied live fields over account capabilities. + modelChanged := applyCodexToolCapabilities(model, metadata.CodexToolCapabilities, false) for _, field := range fields { value, exists := syncedFields[field] if !exists { @@ -2141,6 +2347,7 @@ func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll bool, ac } descriptor := newConfiguredCodexModelDescriptor(slug) + descriptor.SupportsSearchTool = shouldForwardOpenAIResponsesViaRawChatCompletions(account) if accountCodexModelSupportsImageInput(account, slug) { descriptor.InputModalities = []string{"text", "image"} } @@ -2157,13 +2364,18 @@ func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll bool, ac return nil, fmt.Errorf("decode default model %q: %w", slug, err) } - modelChanged := false + capabilityModel := slug + if account != nil { + capabilityModel = account.GetMappedModel(slug) + } + capabilities := accountCodexToolCapabilities(account, capabilityModel) + modelChanged := applyCodexToolCapabilities(model, capabilities, false) if completeDescriptor { merged, err := mergeMissingCodexModelFields(model, defaults) if err != nil { return nil, fmt.Errorf("complete model %q: %w", slug, err) } - modelChanged = merged + modelChanged = merged || modelChanged } if forceOfficialImage { modalities, err := json.Marshal([]string{"text", "image"}) @@ -2210,6 +2422,9 @@ func mergeMissingCodexModelFields(current, defaults map[string]json.RawMessage) changed := false for key, defaultValue := range defaults { currentValue, exists := current[key] + if exists && stringSliceContains(codexToolCapabilityFields, key) { + continue + } if !exists || (bytes.Equal(bytes.TrimSpace(currentValue), []byte("null")) && !bytes.Equal(bytes.TrimSpace(defaultValue), []byte("null"))) { current[key] = defaultValue @@ -2265,9 +2480,9 @@ func validateCodexModelsManifestEnvelope(body []byte) error { return nil } -func buildCodexModelsManifestCacheKey(request codexModelsManifestRequest) string { +func buildOpenAIModelsCacheKey(request openAIModelsRequest) string { hasher := sha256.New() - _, _ = fmt.Fprintf(hasher, "%d\n%d\n%s\n%s\n", request.accountID, request.credentialAccountID, request.proxyURL, request.url) + _, _ = fmt.Fprintf(hasher, "%d\n%d\n%t\n%s\n%s\n", request.accountID, request.credentialAccountID, request.standardModelsList, request.proxyURL, request.url) headerNames := make([]string, 0, len(request.headers)) for name := range request.headers { headerNames = append(headerNames, name) @@ -2282,7 +2497,7 @@ func buildCodexModelsManifestCacheKey(request codexModelsManifestRequest) string return fmt.Sprintf("%x", hasher.Sum(nil)) } -func cloneCodexModelsManifest(manifest *CodexModelsManifest) *CodexModelsManifest { +func cloneOpenAIModelsResponse(manifest *OpenAIModelsResponse) *OpenAIModelsResponse { if manifest == nil { return nil } @@ -2296,14 +2511,14 @@ func cloneCodexModelsManifest(manifest *CodexModelsManifest) *CodexModelsManifes return &cloned } -func codexModelsManifestForClient(manifest *CodexModelsManifest, ifNoneMatch string) *CodexModelsManifest { +func openAIModelsResponseForClient(manifest *OpenAIModelsResponse, ifNoneMatch string) *OpenAIModelsResponse { if manifest == nil { return nil } if codexModelsManifestETagMatches(ifNoneMatch, manifest.ETag) { - return &CodexModelsManifest{ETag: manifest.ETag, NotModified: true} + return &OpenAIModelsResponse{ETag: manifest.ETag, NotModified: true} } - return cloneCodexModelsManifest(manifest) + return cloneOpenAIModelsResponse(manifest) } func codexModelsManifestETagMatches(ifNoneMatch, etag string) bool { diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 49b5f4e462..d16de67d7e 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -349,6 +349,36 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt56Luna.SupportedReasoningLevels)) require.Equal(t, "medium", *gpt56Luna.DefaultReasoningLevel) + gpt6Astra := newConfiguredCodexModelDescriptor("gpt-6-astra") + require.Equal(t, "GPT-6 Astra", gpt6Astra.DisplayName) + require.True(t, strings.HasPrefix(strings.TrimSpace(gpt6Astra.ModelMessages.InstructionsTemplate), "You are Codex, an agent based on GPT-6.")) + require.NotNil(t, gpt6Astra.DefaultReasoningLevel) + require.Equal(t, "medium", *gpt6Astra.DefaultReasoningLevel) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt6Astra.SupportedReasoningLevels)) + require.NotNil(t, gpt6Astra.MultiAgentReasoningEffort) + require.Equal(t, "xhigh", *gpt6Astra.MultiAgentReasoningEffort) + require.Equal(t, "v2", gpt6Astra.MultiAgentVersion) + require.NotContains(t, gpt6Astra.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"}) + require.True(t, configuredCodexSupportsPriorityServiceTier("gpt-6-astra")) + require.Equal(t, []configuredCodexServiceTier{{ + ID: "priority", + Name: "Fast", + Description: "Priority processing for lower latency.", + }}, gpt6Astra.ServiceTiers) + require.True(t, isOpenAICodexImageInputModel("gpt-6-astra")) + require.True(t, isOpenAICodexReasoningGPTModel("openai/gpt-6-astra")) + require.True(t, isOpenAIGPT6AstraModel("gpt-6-astra-2026-09-01")) + require.False(t, isOpenAIGPT6AstraModel("gpt-6-other")) + require.Equal(t, int64(1_050_000), gpt6Astra.ContextWindow) + require.Equal(t, int64(1_050_000), gpt6Astra.MaxContextWindow) + gpt6 := newConfiguredCodexModelDescriptor("gpt-6") + require.Equal(t, "GPT-6 (Astra)", gpt6.DisplayName) + require.True(t, strings.HasPrefix(strings.TrimSpace(gpt6.ModelMessages.InstructionsTemplate), "You are Codex, an agent based on GPT-6.")) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt6.SupportedReasoningLevels)) + require.NotNil(t, gpt6.MultiAgentReasoningEffort) + require.Equal(t, "xhigh", *gpt6.MultiAgentReasoningEffort) + require.Equal(t, int64(1_050_000), gpt6.ContextWindow) + gpt55 := newConfiguredCodexModelDescriptor("gpt-5.5") require.Equal(t, "GPT-5.5", gpt55.DisplayName) require.NotNil(t, gpt55.DefaultReasoningLevel) @@ -385,6 +415,25 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t require.Equal(t, configuredCodexTruncationPolicy{Mode: "bytes", Limit: 10_000}, custom.TruncationPolicy) } +func TestBuildCodexModelsManifestUsesGPT6AstraInstructions(t *testing.T) { + body, err := BuildCodexModelsManifest([]string{"gpt-6-astra"}) + require.NoError(t, err) + + var manifest struct { + Models []struct { + ModelMessages struct { + InstructionsTemplate string `json:"instructions_template"` + } `json:"model_messages"` + } `json:"models"` + } + require.NoError(t, json.Unmarshal(body, &manifest)) + require.Len(t, manifest.Models, 1) + require.True(t, strings.HasPrefix( + strings.TrimSpace(manifest.Models[0].ModelMessages.InstructionsTemplate), + "You are Codex, an agent based on GPT-6.", + )) +} + func effortsFromConfiguredCodexLevels(levels []configuredCodexReasoningLevel) []string { efforts := make([]string, 0, len(levels)) for _, level := range levels { @@ -434,7 +483,7 @@ func TestBuildCodexModelsManifestAdvertisesPriorityServiceTierForFastGPTModels(t body, err := BuildCodexModelsManifest([]string{ "gpt-5.4-mini", "gpt-5.5", - "gpt-5.6-sol", + "gpt-5.6-terra", }) require.NoError(t, err) models := decodeCodexManifestModels(t, body) @@ -452,6 +501,36 @@ func TestBuildCodexModelsManifestAdvertisesPriorityServiceTierForFastGPTModels(t } } +// Scenario: GPT-5.6 Sol 在 Fast 之外额外声明 ultrafast service tier。 +func TestBuildCodexModelsManifestAdvertisesUltrafastServiceTierForSol(t *testing.T) { + t.Parallel() + + body, err := BuildCodexModelsManifest([]string{ + "gpt-5.6-sol", + "gpt-5.6", + }) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 2) + + wantTiers := []any{ + map[string]any{ + "id": "priority", + "name": "Fast", + "description": "Priority processing for lower latency.", + }, + map[string]any{ + "id": "ultrafast", + "name": "Ultrafast", + "description": "Ultra-low latency processing.", + }, + } + for _, model := range models { + require.Equal(t, wantTiers, model["service_tiers"]) + require.Nil(t, model["default_service_tier"]) + } +} + // Scenario: 未明确支持 Fast 的型号不推测 service tier。 func TestBuildCodexModelsManifestLeavesServiceTiersEmptyForOtherModels(t *testing.T) { t.Parallel() @@ -959,6 +1038,7 @@ func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) { require.Contains(t, repo.platforms, PlatformOpenAI) require.Contains(t, repo.platforms, PlatformGrok) require.Contains(t, repo.platforms, PlatformDeepseek) + require.Contains(t, repo.platforms, PlatformMiniMax) require.NotContains(t, repo.platforms, PlatformComposite) } @@ -1027,7 +1107,7 @@ func TestMergeGroupConfiguredCodexModelsInjectsCurrentGroupAliases(t *testing.T) }, }, }} - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6","unknown":{"kept":true}}],"metadata":{"version":1}}`), } @@ -1051,7 +1131,7 @@ func TestMergeGroupConfiguredCodexModelsInjectsCurrentGroupAliases(t *testing.T) require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) } -// Scenario: OpenAI 分组存在账号模型配置时直接生成本地 Codex 清单。 +// Mixed groups retain configured metadata alongside defaults for unmapped accounts. func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t *testing.T) { t.Parallel() @@ -1096,8 +1176,10 @@ func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t require.NoError(t, err) require.True(t, configured) models := decodeCodexManifestModels(t, manifest.Body) - require.Len(t, models, 1) require.Equal(t, "glm-5.3", models[0]["slug"]) + require.Contains(t, codexManifestModelSlugs(t, manifest.Body), "gpt-5.6-sol") + require.NotContains(t, codexManifestModelSlugs(t, manifest.Body), "gpt-image-2") + require.NotContains(t, codexManifestModelSlugs(t, manifest.Body), "codex-auto-review") require.Equal(t, "GLM 5.3", models[0]["display_name"]) require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0])) require.Equal(t, "medium", models[0]["default_reasoning_level"]) @@ -1136,7 +1218,7 @@ func TestBuildGroupConfiguredCodexModelsManifestExpandsSelectedModelCoveredByWil group := &Group{ ID: groupID, Platform: PlatformOpenAI, - ModelsListConfig: GroupModelsListConfig{ + ModelAllowlist: GroupModelAllowlist{ Enabled: true, Models: []string{"gpt-5.6"}, }, @@ -1268,7 +1350,7 @@ func TestMergeGroupConfiguredCodexModelsFiltersAutoReviewByDefault(t *testing.T) const groupID int64 = 74 svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}} - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"codex-auto-future","visibility":"list"},{"slug":"gpt-image-2","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`), } @@ -1303,7 +1385,7 @@ func TestMergeGroupConfiguredCodexModelsFiltersAccountMappedAutoReviewByDefault( }, }, }} - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"hide","model_messages":{"auto_review":{"enabled":true}}},{"slug":"gpt-5.6","visibility":"list"}]}`), } @@ -1322,13 +1404,13 @@ func TestMergeGroupConfiguredCodexModelsKeepsExplicitAutoReviewSelection(t *test const groupID int64 = 76 svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}} - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`), } group := &Group{ ID: groupID, Platform: PlatformOpenAI, - ModelsListConfig: GroupModelsListConfig{ + ModelAllowlist: GroupModelAllowlist{ Enabled: true, Models: []string{openai.CodexUsageProbeModel}, }, @@ -1360,13 +1442,13 @@ func TestMergeGroupConfiguredCodexModelsHonorsCustomListAndFinalETag(t *testing. group := &Group{ ID: groupID, Platform: PlatformOpenAI, - ModelsListConfig: GroupModelsListConfig{ + ModelAllowlist: GroupModelAllowlist{ Enabled: true, Models: []string{"deepseek-4-pro"}, }, } upstreamBody := []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6"}]}`) - manifest := &CodexModelsManifest{Body: upstreamBody} + manifest := &OpenAIModelsResponse{Body: upstreamBody} require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, "")) models := decodeCodexManifestModels(t, manifest.Body) @@ -1374,7 +1456,7 @@ func TestMergeGroupConfiguredCodexModelsHonorsCustomListAndFinalETag(t *testing. requireCompleteConfiguredCodexModel(t, models[0], "deepseek-4-pro") finalETag := manifest.ETag - second := &CodexModelsManifest{Body: upstreamBody} + second := &OpenAIModelsResponse{Body: upstreamBody} require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, second, finalETag)) require.True(t, second.NotModified) require.Empty(t, second.Body) @@ -1737,11 +1819,12 @@ func TestFetchCodexModelsManifestDefaultClientVersion(t *testing.T) { } func TestFetchCodexModelsManifestNotModified(t *testing.T) { - var gotIfNoneMatch string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotIfNoneMatch = r.Header.Get("If-None-Match") + manifestBody := `{"models":[{"slug":"gpt-5.5"}]}` + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) w.Header().Set("ETag", `W/"abc123"`) - w.WriteHeader(http.StatusNotModified) + _, _ = w.Write([]byte(manifestBody)) })) defer server.Close() @@ -1750,15 +1833,32 @@ func TestFetchCodexModelsManifestNotModified(t *testing.T) { defer func() { chatgptCodexModelsURL = original }() s := &OpenAIGatewayService{} - manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", `W/"abc123"`) + account := newCodexModelsTestAccount() + first, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") if err != nil { t.Fatalf("FetchCodexModelsManifest returned error: %v", err) } - if !manifest.NotModified { - t.Error("expected NotModified to be true") + if first.NotModified { + t.Fatal("cold fetch must return the manifest body") } - if gotIfNoneMatch != `W/"abc123"` { - t.Errorf("if-none-match header: got %q", gotIfNoneMatch) + if first.ETag != `W/"abc123"` { + t.Errorf("etag not passed through: got %q", first.ETag) + } + + // OAuth 路径接入缓存后,客户端 If-None-Match 与缓存内容 ETag 比较,不再透传上游: + // 新鲜期内命中缓存且匹配直接 304,零上游请求。 + second, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", `W/"abc123"`) + if err != nil { + t.Fatalf("cached fetch returned error: %v", err) + } + if !second.NotModified { + t.Fatal("matching cached ETag must return NotModified") + } + if second.ETag != `W/"abc123"` { + t.Errorf("NotModified etag: got %q", second.ETag) + } + if got := calls.Load(); got != 1 { + t.Errorf("upstream calls: got %d, want 1", got) } } @@ -1939,7 +2039,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t * t.Parallel() svc := &OpenAIGatewayService{} - manifest := &CodexModelsManifest{ + manifest := &OpenAIModelsResponse{ Body: []byte(`{"models":[{"slug":"grok-4.6","description":"Provider supplied","service_tiers":[{"id":"provider-priority","name":"Provider Fast","description":"Provider supplied tier."}],"model_messages":{"auto_review":{"enabled":true}},"truncation_policy":{"mode":"tokens"},"unknown":{"kept":true}}],"metadata":{"source":"upstream"}}`), } account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1") @@ -1975,7 +2075,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientUsesKnownGPTImageFallback(t * t.Parallel() svc := &OpenAIGatewayService{} - manifest := &CodexModelsManifest{Body: []byte(`{"models":[ + manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[ {"slug":"gpt-5.6-sol"}, {"slug":"company-coding-model"}, {"slug":"gpt-4o","input_modalities":["text"]} @@ -2050,7 +2150,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientFillsMissingProviderFieldsWit ContextWindow: 999_000, }, }}) - manifest := &CodexModelsManifest{Body: []byte(`{"models":[{ + manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{ "slug":"provider-model", "description":"Provider supplied", "context_window":64000, @@ -2129,12 +2229,12 @@ func TestCompleteAPIKeyCodexModelsManifestForClientMarksOnlyOfficialVisionGPTIma t.Parallel() svc := &OpenAIGatewayService{} - manifest := &CodexModelsManifest{Body: []byte(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o"},{"slug":"gpt-3.5-turbo"},{"slug":"gpt-4"}]}`)} + manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{"slug":"gpt-6-astra"},{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o"},{"slug":"gpt-3.5-turbo"},{"slug":"gpt-4"}]}`)} account := newCodexModelsAPIKeyTestAccount("") require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) models := decodeCodexManifestModels(t, manifest.Body) - require.Len(t, models, 4) + require.Len(t, models, 5) bySlug := make(map[string]map[string]any, len(models)) for _, model := range models { @@ -2142,7 +2242,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientMarksOnlyOfficialVisionGPTIma require.True(t, ok) bySlug[slug] = model } - for _, slug := range []string{"gpt-5.6-sol", "gpt-4o"} { + for _, slug := range []string{"gpt-6-astra", "gpt-5.6-sol", "gpt-4o"} { require.Equal(t, []any{"text", "image"}, bySlug[slug]["input_modalities"]) require.Equal(t, true, bySlug[slug]["supports_image_detail_original"]) } @@ -2157,7 +2257,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientFiltersOfficialNonAgentModels t.Parallel() svc := &OpenAIGatewayService{} - manifest := &CodexModelsManifest{Body: []byte(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o-realtime-preview"},{"slug":"gpt-4o-mini-tts"},{"slug":"text-embedding-3-large"},{"slug":"omni-moderation-latest"},{"slug":"o4-mini"},{"slug":"codex-mini-latest"}]}`)} + manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o-realtime-preview"},{"slug":"gpt-4o-mini-tts"},{"slug":"text-embedding-3-large"},{"slug":"omni-moderation-latest"},{"slug":"o4-mini"},{"slug":"codex-mini-latest"}]}`)} account := newCodexModelsAPIKeyTestAccount("") require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account)) @@ -2173,8 +2273,8 @@ func TestAdjustAPIKeyCodexModelsManifest(t *testing.T) { }{ { name: "affected models disable responses lite and preserve unknown fields", - body: `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true,"unknown_model":{"enabled":true}},{"slug":"gpt-5.6-terra","use_responses_lite":true},{"slug":"gpt-5.6-luna","use_responses_lite":true}],"unknown_top":{"version":1}}`, - want: `{"models":[{"slug":"gpt-5.6-sol","unknown_model":{"enabled":true},"use_responses_lite":false},{"slug":"gpt-5.6-terra","use_responses_lite":false},{"slug":"gpt-5.6-luna","use_responses_lite":false}],"unknown_top":{"version":1}}`, + body: `{"models":[{"slug":"gpt-6-astra","use_responses_lite":true},{"slug":"gpt-5.6-sol","use_responses_lite":true,"unknown_model":{"enabled":true}},{"slug":"gpt-5.6-terra","use_responses_lite":true},{"slug":"gpt-5.6-luna","use_responses_lite":true}],"unknown_top":{"version":1}}`, + want: `{"models":[{"slug":"gpt-6-astra","use_responses_lite":false},{"slug":"gpt-5.6-sol","unknown_model":{"enabled":true},"use_responses_lite":false},{"slug":"gpt-5.6-terra","use_responses_lite":false},{"slug":"gpt-5.6-luna","use_responses_lite":false}],"unknown_top":{"version":1}}`, }, { name: "unaffected model unchanged", @@ -2190,7 +2290,7 @@ func TestAdjustAPIKeyCodexModelsManifest(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := adjustAPIKeyCodexModelsManifest([]byte(tt.body)) + got, err := adjustAPIKeyCodexModelsManifest([]byte(tt.body), nil) require.NoError(t, err) require.Equal(t, tt.want, string(got)) }) @@ -2475,13 +2575,13 @@ func TestFetchCodexModelsManifestAPIKeySharedRefreshSurvivesCallerCancellation(t } secondResult := make(chan struct { - manifest *CodexModelsManifest + manifest *OpenAIModelsResponse err error }, 1) go func() { manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") secondResult <- struct { - manifest *CodexModelsManifest + manifest *OpenAIModelsResponse err error }{manifest: manifest, err: err} }() @@ -2609,7 +2709,7 @@ func TestFetchCodexModelsManifestAPIKeyCacheSurvivesClientMutation(t *testing.T) &Group{ ID: 81, Platform: PlatformOpenAI, - ModelsListConfig: GroupModelsListConfig{ + ModelAllowlist: GroupModelAllowlist{ Enabled: true, Models: []string{"model-a"}, }, @@ -2636,7 +2736,7 @@ func TestFetchCodexModelsManifestAPIKeyCacheSurvivesClientMutation(t *testing.T) &Group{ ID: 82, Platform: PlatformOpenAI, - ModelsListConfig: GroupModelsListConfig{ + ModelAllowlist: GroupModelAllowlist{ Enabled: true, Models: []string{"model-b"}, }, @@ -2746,22 +2846,22 @@ func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing. t.Fatalf("body-size bounded cache calls: got %d, want 5", got) } - for i := int64(10); i < 75; i++ { + for i := int64(10); i < 523; i++ { account := newCodexModelsAPIKeyTestAccount("https://bounded.example") account.ID = i fetch(account) } last := newCodexModelsAPIKeyTestAccount("https://bounded.example") - last.ID = 74 + last.ID = 522 fetch(last) - if got := calls.Load(); got != 70 { - t.Fatalf("most recent cache entry was not retained: calls=%d, want 70", got) + if got := calls.Load(); got != 518 { + t.Fatalf("most recent cache entry was not retained: calls=%d, want 518", got) } first := newCodexModelsAPIKeyTestAccount("https://bounded.example") first.ID = 10 fetch(first) - if got := calls.Load(); got != 71 { - t.Errorf("oldest cache entry was not evicted: calls=%d, want 71", got) + if got := calls.Load(); got != 519 { + t.Errorf("oldest cache entry was not evicted: calls=%d, want 519", got) } } @@ -2791,21 +2891,21 @@ func TestFetchCodexModelsManifestAPIKeyServesStaleWhileRefreshing(t *testing.T) t.Fatalf("initial fetch returned error: %v", err) } - s.codexModelsManifestCache.mu.Lock() - for key, entry := range s.codexModelsManifestCache.entries { + s.openAIModelsCache.mu.Lock() + for key, entry := range s.openAIModelsCache.entries { entry.expiresAt = time.Now().Add(-time.Second) - s.codexModelsManifestCache.entries[key] = entry + s.openAIModelsCache.entries[key] = entry } - s.codexModelsManifestCache.mu.Unlock() + s.openAIModelsCache.mu.Unlock() resultCh := make(chan struct { - manifest *CodexModelsManifest + manifest *OpenAIModelsResponse err error }, 1) go func() { manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") resultCh <- struct { - manifest *CodexModelsManifest + manifest *OpenAIModelsResponse err error }{manifest: manifest, err: err} }() @@ -2816,7 +2916,7 @@ func TestFetchCodexModelsManifestAPIKeyServesStaleWhileRefreshing(t *testing.T) } var staleResult struct { - manifest *CodexModelsManifest + manifest *OpenAIModelsResponse err error } select { @@ -2884,12 +2984,12 @@ func TestFetchCodexModelsManifestAPIKeyRevalidatesStaleETag(t *testing.T) { if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err != nil { t.Fatalf("initial fetch returned error: %v", err) } - s.codexModelsManifestCache.mu.Lock() - for key, entry := range s.codexModelsManifestCache.entries { + s.openAIModelsCache.mu.Lock() + for key, entry := range s.openAIModelsCache.entries { entry.expiresAt = time.Now().Add(-time.Second) - s.codexModelsManifestCache.entries[key] = entry + s.openAIModelsCache.entries[key] = entry } - s.codexModelsManifestCache.mu.Unlock() + s.openAIModelsCache.mu.Unlock() manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "") if err != nil { @@ -2906,12 +3006,12 @@ func TestFetchCodexModelsManifestAPIKeyRevalidatesStaleETag(t *testing.T) { deadline := time.Now().Add(time.Second) for { - s.codexModelsManifestCache.mu.Lock() + s.openAIModelsCache.mu.Lock() fresh := false - for _, entry := range s.codexModelsManifestCache.entries { + for _, entry := range s.openAIModelsCache.entries { fresh = time.Now().Before(entry.expiresAt) } - s.codexModelsManifestCache.mu.Unlock() + s.openAIModelsCache.mu.Unlock() if fresh { break } @@ -3255,3 +3355,271 @@ func TestFetchCodexModelsManifestAPIKeyUsesOfficialOpenAIModelsEndpoint(t *testi }) } } + +// --- OAuth manifest 账号级缓存(统一缓存策略) --- + +// newCodexModelsOAuthCacheServer 返回一个记录调用次数、返回给定 manifest 的 +// ChatGPT Codex models 测试上游。 +func newCodexModelsOAuthCacheServer(t *testing.T, body string) (*httptest.Server, *atomic.Int32) { + t.Helper() + calls := &atomic.Int32{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + t.Cleanup(func() { chatgptCodexModelsURL = original }) + return server, calls +} + +func expireCodexModelsManifestCache(s *OpenAIGatewayService, age time.Duration) { + s.openAIModelsCache.mu.Lock() + for key, entry := range s.openAIModelsCache.entries { + entry.expiresAt = time.Now().Add(-age) + entry.staleUntil = time.Now().Add(openAIModelsCacheStaleTTL - age) + s.openAIModelsCache.entries[key] = entry + } + s.openAIModelsCache.mu.Unlock() +} + +func TestFetchCodexModelsManifestOAuthFreshWindowZeroUpstreamRequests(t *testing.T) { + _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"gpt-5.5"}]}`) + s := &OpenAIGatewayService{} + account := newCodexModelsTestAccount() + + for i := 0; i < 10; i++ { + manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.NoError(t, err) + require.False(t, manifest.NotModified) + require.JSONEq(t, `{"models":[{"slug":"gpt-5.5"}]}`, string(manifest.Body)) + } + require.EqualValues(t, 1, calls.Load(), "新鲜期内重复请求不得打到 chatgpt.com") +} + +func TestFetchCodexModelsManifestOAuthStaleServesOldValueAndRefreshesInBackground(t *testing.T) { + calls := &atomic.Int32{} + refreshStarted := make(chan struct{}) + var refreshOnce sync.Once + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if calls.Add(1) == 2 { + refreshOnce.Do(func() { close(refreshStarted) }) + <-release + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":[{"slug":"new"}]}`)) + })) + t.Cleanup(server.Close) + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + t.Cleanup(func() { chatgptCodexModelsURL = original }) + + s := &OpenAIGatewayService{} + account := newCodexModelsTestAccount() + first, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.NoError(t, err) + require.Contains(t, string(first.Body), `"new"`) + + expireCodexModelsManifestCache(s, 2*time.Minute) + + resultCh := make(chan *OpenAIModelsResponse, 1) + errCh := make(chan error, 1) + go func() { + manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + resultCh <- manifest + errCh <- fetchErr + }() + select { + case <-refreshStarted: + case <-time.After(time.Second): + t.Fatal("乐观期后台刷新未触发") + } + + // 乐观期:立即返回旧值,不同步等待刷新。 + select { + case manifest := <-resultCh: + require.NoError(t, <-errCh) + require.Contains(t, string(manifest.Body), `"new"`) + case <-time.After(100 * time.Millisecond): + t.Fatal("乐观期必须立即返回缓存旧值") + } + require.EqualValues(t, 2, calls.Load()) + + close(release) + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + if fetchErr == nil && manifest != nil && !manifest.NotModified { + require.Contains(t, string(manifest.Body), `"new"`) + } + s.openAIModelsCache.mu.Lock() + fresh := len(s.openAIModelsCache.entries) > 0 + s.openAIModelsCache.mu.Unlock() + if fresh { + break + } + time.Sleep(10 * time.Millisecond) + } + require.EqualValues(t, 2, calls.Load(), "后台刷新必须单飞") +} + +func TestFetchCodexModelsManifestOAuthOverdueSynchronousRefreshAndFailure(t *testing.T) { + t.Run("overdue refresh waits and re-caches", func(t *testing.T) { + calls := &atomic.Int32{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if calls.Add(1) == 2 { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":[{"slug":"refreshed"}]}`)) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":[{"slug":"stale"}]}`)) + })) + t.Cleanup(server.Close) + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + t.Cleanup(func() { chatgptCodexModelsURL = original }) + + s := &OpenAIGatewayService{} + account := newCodexModelsTestAccount() + _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.NoError(t, err) + + // 超过 5 分钟:缓存条目被丢弃,同步等待上游刷新。 + expireCodexModelsManifestCache(s, 6*time.Minute) + + manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.NoError(t, err) + require.Contains(t, string(manifest.Body), `"refreshed"`) + require.EqualValues(t, 2, calls.Load()) + + // 新内容写回缓存并重新开始新鲜期。 + again, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.NoError(t, err) + require.Contains(t, string(again.Body), `"refreshed"`) + require.EqualValues(t, 2, calls.Load()) + }) + + t.Run("overdue upstream failure returns error not stale cache", func(t *testing.T) { + fail := &atomic.Bool{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if fail.Load() { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"detail":"boom"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":[{"slug":"stale"}]}`)) + })) + t.Cleanup(server.Close) + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + t.Cleanup(func() { chatgptCodexModelsURL = original }) + + s := &OpenAIGatewayService{} + account := newCodexModelsTestAccount() + _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.NoError(t, err) + + fail.Store(true) + expireCodexModelsManifestCache(s, 6*time.Minute) + + _, err = s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.Error(t, err, "超期后上游失败必须返回错误而非旧缓存") + require.Equal(t, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", infraerrors.Reason(err)) + }) +} + +func TestFetchCodexModelsManifestOAuthTokenChangeCacheMiss(t *testing.T) { + _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[]}`) + s := &OpenAIGatewayService{} + account := newCodexModelsTestAccount() + + _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.NoError(t, err) + require.EqualValues(t, 1, calls.Load()) + + // 令牌刷新后缓存键变化,下一次拉取视为未命中并同步请求上游。 + account.Credentials["access_token"] = "rotated-access-token" + manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + require.NoError(t, err) + require.Contains(t, string(manifest.Body), `"models"`) + require.EqualValues(t, 2, calls.Load()) +} + +func TestFetchCodexModelsManifestOAuthSharedAcrossGroupsWithIndependentFiltering(t *testing.T) { + started := make(chan struct{}) + var startedOnce sync.Once + release := make(chan struct{}) + calls := &atomic.Int32{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + startedOnce.Do(func() { close(started) }) + <-release + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"models":[{"slug":"model-a"},{"slug":"model-b"}]}`)) + })) + t.Cleanup(server.Close) + original := chatgptCodexModelsURL + chatgptCodexModelsURL = server.URL + t.Cleanup(func() { chatgptCodexModelsURL = original }) + + s := &OpenAIGatewayService{ + accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{ + 91: {}, + 92: {}, + }, + }, + } + account := newCodexModelsTestAccount() + groupA := &Group{ID: 91, Platform: PlatformOpenAI, ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"model-a"}}} + groupB := &Group{ID: 92, Platform: PlatformOpenAI, ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"model-b"}}} + + begin := make(chan struct{}) + type result struct { + slugs []string + err error + } + results := make(chan result, 2) + for _, group := range []*Group{groupA, groupB} { + go func(g *Group) { + <-begin + manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") + if err == nil { + err = s.MergeGroupConfiguredCodexModels(context.Background(), g, manifest, "") + } + slugs := []string{} + if err == nil { + slugs = codexManifestModelSlugs(t, manifest.Body) + } + results <- result{slugs: slugs, err: err} + }(group) + } + close(begin) + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("upstream request did not start") + } + close(release) + + got := map[int64][]string{91: nil, 92: nil} + for i := 0; i < 2; i++ { + r := <-results + require.NoError(t, r.err) + if len(r.slugs) == 1 && r.slugs[0] == "model-a" { + got[91] = r.slugs + } else if len(r.slugs) == 1 && r.slugs[0] == "model-b" { + got[92] = r.slugs + } else { + t.Fatalf("unexpected filtered slugs: %v", r.slugs) + } + } + require.Equal(t, []string{"model-a"}, got[91]) + require.Equal(t, []string{"model-b"}, got[92]) + require.EqualValues(t, 1, calls.Load(), "同一账号两个分组同时请求时只发一次上游请求") +} diff --git a/backend/internal/service/openai_codex_tool_names.go b/backend/internal/service/openai_codex_tool_names.go index dfa8deacc0..849a45e417 100644 --- a/backend/internal/service/openai_codex_tool_names.go +++ b/backend/internal/service/openai_codex_tool_names.go @@ -115,6 +115,9 @@ func collectOpenAIResponsesToolNameFields(reqBody map[string]any) []codexToolNam } } if choice, ok := reqBody["tool_choice"].(map[string]any); ok { + if strings.EqualFold(strings.TrimSpace(firstNonEmptyString(choice["type"])), "allowed_tools") { + collectTools(choice["tools"]) + } if strings.EqualFold(strings.TrimSpace(firstNonEmptyString(choice["type"])), "function") { appendName(choice, "name") if function, ok := choice["function"].(map[string]any); ok { diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index fbd14ba967..a67d626a23 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -11,6 +11,7 @@ import ( ) var codexModelMap = map[string]string{ + "gpt-6-astra": "gpt-6-astra", "gpt-5.6-sol": "gpt-5.6-sol", "gpt-5.6-terra": "gpt-5.6-terra", "gpt-5.6-luna": "gpt-5.6-luna", @@ -356,6 +357,12 @@ func normalizeCodexToolChoice(reqBody map[string]any) bool { if choiceType == "" { return false } + if choiceType == "allowed_tools" { + // This is a selection policy, not a declared tool type. Preserve it for + // upstream validation (including references to input.additional_tools); + // falling back to auto would silently discard the caller's restriction. + return false + } modified := false if choiceType == "function" { name := strings.TrimSpace(firstNonEmptyString(choiceMap["name"])) @@ -1391,8 +1398,7 @@ func extractPromptLikeInstructionsFromInput(reqBody map[string]any) string { // defaultCodexSynthInstructions 返回合成路径在 instructions 为空时应填入的默认提示词。 // -// 按 model 选择真实 Codex CLI 的 base instructions(codex 系→GPT-5-Codex, -// gpt-5.2→GPT-5.2,gpt-5.1/gpt-5→GPT-5.1),使合成请求在提示词层面贴近真实 Codex 行为; +// 按 model 选择真实 Codex CLI 的 base instructions,使合成请求在提示词层面贴近真实 Codex 行为; // 若内嵌 prompt 意外为空,回退到最小占位符以保证字段非空。 func defaultCodexSynthInstructions(model string) string { if instructions := strings.TrimSpace(openai.CodexBaseInstructionsForModel(model)); instructions != "" { diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index 96e7715d5d..6a682ce73e 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "fmt" "strings" "testing" @@ -477,6 +478,74 @@ func TestApplyCodexOAuthTransform_StringifiesNonStringMessageContentText(t *test require.Equal(t, `["a","b"]`, part["text"]) } +func TestApplyCodexOAuthTransform_PreservesAllowedTools(t *testing.T) { + for _, placement := range []string{"top_level", "additional_tools"} { + for _, mode := range []string{"auto", "required"} { + t.Run(placement+"/"+mode, func(t *testing.T) { + decision := map[string]any{"type": "function", "name": "ProbeAccept"} + choice := map[string]any{ + "type": "allowed_tools", "mode": mode, + "tools": []any{map[string]any{"type": "function", "name": "ProbeAccept"}}, + } + tools := []any{ + map[string]any{"type": "function", "name": "ProbeBase"}, + map[string]any{"type": "web_search"}, + map[string]any{"type": "image_generation"}, + } + input := []any{map[string]any{"type": "message", "role": "user", "content": "probe"}} + if placement == "top_level" { + tools = append(tools, decision) + } else { + input = append(input, map[string]any{ + "type": "additional_tools", "role": "developer", "tools": []any{decision}, + }) + } + reqBody := map[string]any{"tools": tools, "input": input, "tool_choice": choice} + before, err := json.Marshal(reqBody) + require.NoError(t, err) + reqBody["model"] = "gpt-6-astra" + result := applyCodexOAuthTransform(reqBody, true, false) + require.NoError(t, result.Error) + after, err := json.Marshal(map[string]any{ + "tools": reqBody["tools"], "input": reqBody["input"], "tool_choice": reqBody["tool_choice"], + }) + require.NoError(t, err) + require.JSONEq(t, string(before), string(after)) + }) + } + } +} + +func TestNormalizeCodexToolChoice_InvalidAllowedToolsNeverBecomesAuto(t *testing.T) { + for _, choice := range []map[string]any{ + {"type": "allowed_tools"}, + {"type": "allowed_tools", "mode": "invalid", "tools": []any{}}, + {"type": "allowed_tools", "mode": "required", "tools": "invalid"}, + {"type": "allowed_tools", "mode": "required", "tools": []any{map[string]any{"type": "function", "name": "missing"}}}, + } { + reqBody := map[string]any{"tool_choice": choice} + require.False(t, normalizeCodexToolChoice(reqBody)) + // The upstream owns schema validation. A malformed restriction must never + // silently become permission to call every supplied tool. + require.Equal(t, choice, reqBody["tool_choice"]) + } +} + +func TestApplyCodexOAuthTransform_AllowedToolsKeepsReservedNameReferences(t *testing.T) { + declaration := map[string]any{"type": "function", "name": "python"} + reference := map[string]any{"type": "function", "name": "python"} + choice := map[string]any{"type": "allowed_tools", "mode": "required", "tools": []any{reference}} + reqBody := map[string]any{ + "model": "gpt-6-astra", "tools": []any{declaration}, "tool_choice": choice, + } + result := applyCodexOAuthTransform(reqBody, true, false) + require.NoError(t, result.Error) + require.Equal(t, choice, reqBody["tool_choice"]) + require.Equal(t, codexPythonToolAlias, declaration["name"]) + require.Equal(t, codexPythonToolAlias, reference["name"]) + require.Equal(t, "python", result.ToolNameReverse[codexPythonToolAlias]) +} + func TestApplyCodexOAuthTransform_DowngradesUnknownToolChoice(t *testing.T) { reqBody := map[string]any{ "model": "gpt-5.4", @@ -1361,6 +1430,10 @@ func TestApplyCodexOAuthTransform_EmptyInput(t *testing.T) { func TestNormalizeCodexModel_Gpt53(t *testing.T) { cases := map[string]string{ + "gpt-6-astra": "gpt-6-astra", + "openai/gpt-6-astra": "gpt-6-astra", + "gpt-6": "gpt-6-astra", + "openai/gpt-6": "gpt-6-astra", "gpt-5.4": "gpt-5.4", "gpt5.5": "gpt-5.5", "openai/gpt5.5": "gpt-5.5", @@ -1493,6 +1566,20 @@ func TestApplyCodexOAuthTransform_GPT55SuppliesModelSpecificInstructions(t *test require.True(t, result.Modified) } +func TestApplyCodexOAuthTransform_GPT6AstraSuppliesModelSpecificInstructions(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-6-astra", + } + + result := applyCodexOAuthTransform(reqBody, true, false) + + instructions, ok := reqBody["instructions"].(string) + require.True(t, ok) + require.True(t, strings.HasPrefix(strings.TrimSpace(instructions), "You are Codex, an agent based on GPT-6.")) + require.NotContains(t, instructions, "You are Codex, a coding agent based on GPT-5.") + require.True(t, result.Modified) +} + func TestApplyCodexOAuthTransform_NonCodexCLI_PreservesExistingInstructions(t *testing.T) { // 非 Codex CLI 场景:已有 instructions 时保留客户端的值,不再覆盖 diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index b52a616f85..945615bf57 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -494,6 +494,66 @@ func TestForwardAsAnthropic_AutoDerivesPromptCacheKeyWhenMessagesDispatchHasNoSe require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, cacheKey)), upstream.lastReq.Header.Get("session_id")) } +func TestForwardAsAnthropic_GPT6AstraPromptCacheIdentityStableAcrossAppendedTurns(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + for _, mappedModel := range []string{"gpt-6-astra", "gpt-6"} { + mappedModel := mappedModel + t.Run(mappedModel, func(t *testing.T) { + t.Parallel() + + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + openAICompatSSECompletedResponse("resp_gpt6_first", mappedModel), + openAICompatSSECompletedResponse("resp_gpt6_second", mappedModel), + }} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 6615, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", + }, + } + + bodies := [][]byte{ + []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"system":"You are helpful.","messages":[{"role":"user","content":"Inspect the repository"}],"stream":false}`), + []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"system":"You are helpful.","messages":[{"role":"user","content":"Inspect the repository"},{"role":"assistant","content":"Done."},{"role":"user","content":"Run the tests"}],"stream":false}`), + } + for _, body := range bodies { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", mappedModel) + require.NoError(t, err) + require.NotNil(t, result) + } + + require.Len(t, upstream.bodies, 2) + require.Len(t, upstream.requests, 2) + require.Equal(t, mappedModel, gjson.GetBytes(upstream.bodies[0], "model").String()) + require.Equal(t, mappedModel, gjson.GetBytes(upstream.bodies[1], "model").String()) + firstCacheKey := gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").String() + secondCacheKey := gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String() + require.NotEmpty(t, firstCacheKey) + require.Equal(t, firstCacheKey, secondCacheKey) + firstSessionID := upstream.requests[0].Header.Get("session_id") + secondSessionID := upstream.requests[1].Header.Get("session_id") + require.NotEmpty(t, firstSessionID) + require.Equal(t, firstSessionID, secondSessionID) + }) + } +} + func TestForwardAsAnthropic_DoesNotAutoDerivePromptCacheKeyForNonCodexModel(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) @@ -753,7 +813,7 @@ func TestForwardAsAnthropic_PreviousResponseIDKeepsMultiToolCallContext(t *testi require.Equal(t, "continue", gjson.GetBytes(upstream.lastBody, "input.5.content.0.text").String()) } -func TestForwardAsAnthropic_ReplaysWithoutContinuationWhenPreviousResponseMissing(t *testing.T) { +func TestForwardAsAnthropic_ReplaysFullToolHistoryWhenPreviousResponseUnavailable(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) @@ -775,12 +835,12 @@ func TestForwardAsAnthropic_ReplaysWithoutContinuationWhenPreviousResponseMissin } svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "stable-cache-key", "resp_missing") - secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) + secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"lookup","input":{"q":"first"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"found"},{"type":"text","text":"second"}]}],"tools":[{"name":"lookup","input_schema":{"type":"object"}}],"stream":false}`) upstream.responses = []*http.Response{ { StatusCode: http.StatusBadRequest, Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_prev_missing"}}, - Body: io.NopCloser(strings.NewReader(`{"error":{"code":"previous_response_not_found","message":"previous response not found"}}`)), + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"previous_response_id is not available for this user","type":"invalid_request_error"}}`)), }, openAICompatSSECompletedResponse("resp_replayed", "gpt-5.3-codex"), } @@ -797,11 +857,54 @@ func TestForwardAsAnthropic_ReplaysWithoutContinuationWhenPreviousResponseMissin require.Len(t, upstream.requests, 2) require.Equal(t, "resp_missing", gjson.GetBytes(upstream.bodies[0], "previous_response_id").String()) require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) - require.Equal(t, int64(4), gjson.GetBytes(upstream.bodies[1], "input.#").Int()) + require.Equal(t, int64(5), gjson.GetBytes(upstream.bodies[1], "input.#").Int()) require.Equal(t, "developer", gjson.GetBytes(upstream.bodies[1], "input.0.role").String()) require.Contains(t, gjson.GetBytes(upstream.bodies[1], "input.0.content.0.text").String(), "") require.Equal(t, "first", gjson.GetBytes(upstream.bodies[1], "input.1.content.0.text").String()) - require.Equal(t, "second", gjson.GetBytes(upstream.bodies[1], "input.3.content.0.text").String()) + require.Equal(t, "function_call", gjson.GetBytes(upstream.bodies[1], "input.2.type").String()) + require.Equal(t, "call_1", gjson.GetBytes(upstream.bodies[1], "input.2.call_id").String()) + require.Equal(t, "function_call_output", gjson.GetBytes(upstream.bodies[1], "input.3.type").String()) + require.Equal(t, "call_1", gjson.GetBytes(upstream.bodies[1], "input.3.call_id").String()) + require.Equal(t, "found", gjson.GetBytes(upstream.bodies[1], "input.3.output").String()) + require.Equal(t, "second", gjson.GetBytes(upstream.bodies[1], "input.4.content.0.text").String()) +} + +func TestOpenAICompatPreviousResponseUnavailableRecognitionIsStrict(t *testing.T) { + t.Parallel() + body := []byte(`{"error":{"message":"previous_response_id is not available for this user","type":"invalid_request_error"}}`) + require.True(t, isOpenAICompatPreviousResponseNotFound(http.StatusBadRequest, "", body)) + require.False(t, isOpenAICompatPreviousResponseNotFound(http.StatusForbidden, "", body)) + require.False(t, isOpenAICompatPreviousResponseNotFound(http.StatusBadRequest, "permission is not available for this user", nil)) + require.False(t, isOpenAICompatPreviousResponseNotFound(http.StatusBadRequest, "previous_response_id is not available for this project", nil)) +} + +func TestForwardAsAnthropic_PreviousResponseUnavailableRetryFailureDoesNotLoop(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + unavailable := func() *http.Response { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"previous_response_id is not available for this user","type":"invalid_request_error"}}`)), + } + } + upstream := &httpUpstreamRecorder{responses: []*http.Response{unavailable(), unavailable()}} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://api.openai.com/v1"}} + svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "stable-cache-key", "resp_missing") + body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + + _, _ = svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.3-codex") + require.Len(t, upstream.requests, 2) + require.True(t, gjson.GetBytes(upstream.bodies[0], "previous_response_id").Exists()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) } func TestForwardAsAnthropic_DisablesAPIKeyContinuationWhenUpstreamRequiresWebSocketV2(t *testing.T) { @@ -2168,3 +2271,43 @@ func TestForwardAsAnthropic_UpstreamRequestIgnoresClientCancel(t *testing.T) { require.NotNil(t, upstream.lastReq) require.NoError(t, upstream.lastReq.Context().Err()) } + +func TestForwardAsAnthropic_AstraContinuationRestoresHistoryAndDisablesUnsupportedSession(t *testing.T) { + for _, message := range []string{ + "previous_response_id is not available for this user", + "previous_response_id requires an OpenAI API-key account for HTTP requests", + } { + t.Run(message, func(t *testing.T) { + encodedError, err := json.Marshal(map[string]any{"error": map[string]string{"message": message}}) + require.NoError(t, err) + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusBadRequest, Body: io.NopCloser(bytes.NewReader(encodedError))}, + openAICompatSSECompletedResponse("resp_replayed", "gpt-6-astra"), + openAICompatSSECompletedResponse("resp_later", "gpt-6-astra"), + }} + svc := &OpenAIGatewayService{httpUpstream: upstream, cfg: &config.Config{}} + account := rawGPT56ResponsesAPIKeyAccount("gpt-6-astra", "gpt-6-astra") + svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "astra-session", "resp_old") + body := []byte(`{"model":"gpt-6-astra","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}]}`) + for i := 0; i < 2; i++ { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "astra-session", "gpt-6-astra") + require.NoError(t, err) + require.NotNil(t, result) + } + require.Len(t, upstream.bodies, 3) + require.Equal(t, "resp_old", gjson.GetBytes(upstream.bodies[0], "previous_response_id").String()) + for _, sent := range upstream.bodies[1:] { + require.False(t, gjson.GetBytes(sent, "previous_response_id").Exists()) + require.Equal(t, "astra-session", gjson.GetBytes(sent, "prompt_cache_key").String()) + require.Equal(t, int64(4), gjson.GetBytes(sent, "input.#").Int()) + require.Contains(t, gjson.GetBytes(sent, "input.0.content.0.text").String(), "") + require.Equal(t, "first", gjson.GetBytes(sent, "input.1.content.0.text").String()) + require.Equal(t, "second", gjson.GetBytes(sent, "input.3.content.0.text").String()) + } + }) + } + require.False(t, isOpenAICompatPreviousResponseUnsupported(http.StatusUnauthorized, "previous_response_id is not available for this user", nil)) + require.False(t, isOpenAICompatPreviousResponseUnsupported(http.StatusBadRequest, "The model is not available for this user", nil)) +} diff --git a/backend/internal/service/openai_compat_prompt_cache_key.go b/backend/internal/service/openai_compat_prompt_cache_key.go index 7c759315be..3d3595d3c2 100644 --- a/backend/internal/service/openai_compat_prompt_cache_key.go +++ b/backend/internal/service/openai_compat_prompt_cache_key.go @@ -13,6 +13,12 @@ const compatPromptCacheKeyPrefix = "compat_cc_" func shouldAutoInjectPromptCacheKeyForCompat(model string) bool { trimmed := strings.TrimSpace(strings.ToLower(model)) + canonical := canonicalizeOpenAIModelAliasSpelling(trimmed) + // GPT-6 is the public alias for Astra. Keep this deliberately scoped to + // Astra so other GPT-6 families do not inherit Messages compatibility state. + if canonical == "gpt-6" || canonical == "gpt-6-astra" { + return true + } // 仅对 Responses 兼容路径支持的 GPT-5 族开启自动注入,避免 normalizeCodexModel // 的默认兜底把任意模型(如 gpt-4o、claude-*)误判为 gpt-5.4。 if !strings.Contains(trimmed, "gpt-5") && !strings.Contains(trimmed, "codex") { @@ -21,7 +27,6 @@ func shouldAutoInjectPromptCacheKeyForCompat(model string) bool { normalized := strings.TrimSpace(strings.ToLower(normalizeCodexModel(trimmed))) return strings.HasPrefix(normalized, "gpt-5") || strings.Contains(normalized, "codex") } - func deriveCompatPromptCacheKey(req *apicompat.ChatCompletionsRequest, mappedModel string) string { if req == nil { return "" diff --git a/backend/internal/service/openai_compat_prompt_cache_key_test.go b/backend/internal/service/openai_compat_prompt_cache_key_test.go index ce1c68a2b7..d486857662 100644 --- a/backend/internal/service/openai_compat_prompt_cache_key_test.go +++ b/backend/internal/service/openai_compat_prompt_cache_key_test.go @@ -26,6 +26,34 @@ func TestShouldAutoInjectPromptCacheKeyForCompat(t *testing.T) { require.False(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-4o")) } +func TestShouldAutoInjectPromptCacheKeyForCompat_GPT6AstraForms(t *testing.T) { + for _, model := range []string{ + "gpt-6", + "gpt-6-astra", + "openai/gpt-6", + "openai/gpt-6-astra", + "OPENAI/GPT-6_ASTRA", + "provider/gpt-6-astra", + } { + require.True(t, shouldAutoInjectPromptCacheKeyForCompat(model), model) + } + + for _, model := range []string{ + "gpt-6-terra", + "gpt-6.1", + "gpt-6-astra-preview", + "gpt-6-astra-unrelated-name", + "gpt-6-astra-2026-09-01", + "gpt-6-astra-2026-09-01-extra", + "gpt-6-astra-v", + "gpt-6-astra-v1.beta", + "claude-sonnet-4-5", + "gpt-4o", + } { + require.False(t, shouldAutoInjectPromptCacheKeyForCompat(model), model) + } +} + func TestDeriveCompatPromptCacheKey_StableAcrossLaterTurns(t *testing.T) { base := &apicompat.ChatCompletionsRequest{ Model: "gpt-5.4", diff --git a/backend/internal/service/openai_cyber_policy.go b/backend/internal/service/openai_cyber_policy.go index 524bade30c..9e51e72483 100644 --- a/backend/internal/service/openai_cyber_policy.go +++ b/backend/internal/service/openai_cyber_policy.go @@ -86,3 +86,22 @@ func detectOpenAICyberPolicy(payload []byte) (bool, string, string) { } return true, "cyber_policy", strings.TrimSpace(msg) } + +func markOpenAICyberPolicyEvent(c *gin.Context, payload []byte, upstreamStatus int, usage *OpenAIUsage) bool { + hit, code, message := detectOpenAICyberPolicy(payload) + if !hit { + return false + } + mark := CyberPolicyMark{ + Code: code, + Message: message, + Body: truncateString(string(payload), 4096), + UpstreamStatus: upstreamStatus, + } + if usage != nil { + mark.UpstreamInTok = usage.InputTokens + mark.UpstreamOutTok = usage.OutputTokens + } + MarkOpsCyberPolicy(c, mark) + return true +} diff --git a/backend/internal/service/openai_embeddings.go b/backend/internal/service/openai_embeddings.go index 1e2f9c8b60..c4b8f79bda 100644 --- a/backend/internal/service/openai_embeddings.go +++ b/backend/internal/service/openai_embeddings.go @@ -118,7 +118,7 @@ func (s *OpenAIGatewayService) ForwardEmbeddings( upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) { + if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMsg, respBody) { upstreamDetail := "" if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes @@ -164,13 +164,14 @@ func (s *OpenAIGatewayService) ForwardEmbeddings( writeOpenAIEmbeddingsUpstreamResponse(c, resp, respBody, s.responseHeaderFilter) return &OpenAIForwardResult{ - RequestID: firstNonEmptyString(resp.Header.Get("x-request-id"), resp.Header.Get("request-id")), - Usage: extractOpenAIEmbeddingsUsage(respBody), - Model: originalModel, - BillingModel: billingModel, - UpstreamModel: upstreamModel, - Stream: false, - Duration: time.Since(startTime), + RequestID: firstNonEmptyString(resp.Header.Get("x-request-id"), resp.Header.Get("request-id")), + UpstreamHeaders: resp.Header, + Usage: extractOpenAIEmbeddingsUsage(respBody), + Model: originalModel, + BillingModel: billingModel, + UpstreamModel: upstreamModel, + Stream: false, + Duration: time.Since(startTime), }, nil } diff --git a/backend/internal/service/openai_encrypted_content_lineage.go b/backend/internal/service/openai_encrypted_content_lineage.go new file mode 100644 index 0000000000..dbc360351d --- /dev/null +++ b/backend/internal/service/openai_encrypted_content_lineage.go @@ -0,0 +1,353 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +// invalid_encrypted_content 失效密文 lineage。 +// +// 上游判定某轮请求中的加密 reasoning/compaction 项不可解后,同一失效密文会随 +// 客户端维护的会话历史在后续每一轮重新出现,重复触发"整包被拒→剥离→重试/ +// 重连"。这里按会话记录已被上游拒绝过的 encrypted_content 摘要 +// (OpenAIWSStateStore,带 TTL 与容量自保护);后续请求进场时仅剥离摘要命中 +// 的项,新生成的密文摘要不同,不会被误删。 + +const openAIWSFallbackReasonInvalidEncryptedContent = "invalid_encrypted_content" + +// openAIWSIngressSessionHashContextKey 在 gin context 中携带 ingress 会话哈希, +// 供 HTTP bridge turn 内的 lineage 记录复用同一会话键。 +const openAIWSIngressSessionHashContextKey = "openai_ws_ingress_session_hash" + +func openAIEncryptedContentDigest(encrypted string) string { + sum := sha256.Sum256([]byte(encrypted)) + return hex.EncodeToString(sum[:]) +} + +// openAIEncryptedLineageItemType 限定 lineage 覆盖的项类型,与剥离端 +// sanitizeEncryptedReasoningInputItem 能处理的类型对称;其他类型即使携带 +// encrypted_content 也不收摘要,避免记入永远剥不掉的摘要后每轮空转解码。 +func openAIEncryptedLineageItemType(itemType string) bool { + switch strings.TrimSpace(itemType) { + case "reasoning", "compaction", "compaction_summary": + return true + default: + return false + } +} + +// collectOpenAIEncryptedContentDigestsRaw 收集 input 中 lineage 覆盖类型所携 +// 密文的摘要。payload 须遵守 replay 所有权不变式(零拷贝视图解析)。 +func collectOpenAIEncryptedContentDigestsRaw(payload []byte) []string { + if len(payload) == 0 { + return nil + } + input := gjson.Get(openAIWSPayloadStringView(payload), "input") + if !input.Exists() { + return nil + } + var digests []string + appendItem := func(item gjson.Result) { + if !openAIEncryptedLineageItemType(item.Get("type").String()) { + return + } + encrypted := item.Get("encrypted_content") + if encrypted.Type == gjson.String && encrypted.String() != "" { + digests = append(digests, openAIEncryptedContentDigest(encrypted.String())) + } + } + if input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + appendItem(item) + return true + }) + return digests + } + if input.IsObject() { + appendItem(input) + } + return digests +} + +// stripOpenAIInvalidEncryptedContentItems 剥离 input 中摘要命中 invalid 的密文 +// 项,剥离语义与 trimOpenAIEncryptedReasoningItems 对齐:reasoning 仅剥 +// encrypted_content(连带 content:null 清理与空骨架删除),compaction/ +// compaction_summary 整项删除。返回被改写的项数。 +func stripOpenAIInvalidEncryptedContentItems(reqBody map[string]any, invalid map[string]struct{}) int { + if len(reqBody) == 0 || len(invalid) == 0 { + return 0 + } + inputValue, has := reqBody["input"] + if !has { + return 0 + } + stripped := 0 + stripItem := func(item any) (next any, changed bool, keep bool) { + inputItem, ok := item.(map[string]any) + if !ok { + return item, false, true + } + encrypted, ok := inputItem["encrypted_content"].(string) + if !ok || encrypted == "" { + return item, false, true + } + if _, hit := invalid[openAIEncryptedContentDigest(encrypted)]; !hit { + return item, false, true + } + return sanitizeEncryptedReasoningInputItem(item) + } + switch input := inputValue.(type) { + case []any: + filtered := input[:0] + for _, item := range input { + nextItem, changed, keep := stripItem(item) + if changed { + stripped++ + } + if !keep { + continue + } + filtered = append(filtered, nextItem) + } + if stripped == 0 { + return 0 + } + if len(filtered) == 0 { + delete(reqBody, "input") + return stripped + } + reqBody["input"] = filtered + return stripped + case map[string]any: + nextItem, changed, keep := stripItem(input) + if !changed { + return 0 + } + if !keep { + delete(reqBody, "input") + return 1 + } + nextMap, ok := nextItem.(map[string]any) + if !ok { + return 0 + } + reqBody["input"] = nextMap + return 1 + default: + return 0 + } +} + +// openAIRawPayloadHasInvalidEncryptedContent 探测 raw payload 是否携带命中 +// invalid 摘要的密文项;零命中的常态路径不做任何改写。payload 须遵守 replay +// 所有权不变式(零拷贝视图解析)。 +func openAIRawPayloadHasInvalidEncryptedContent(payload []byte, invalid map[string]struct{}) bool { + if len(payload) == 0 || len(invalid) == 0 { + return false + } + input := gjson.Get(openAIWSPayloadStringView(payload), "input") + if !input.Exists() { + return false + } + hit := false + checkItem := func(item gjson.Result) bool { + encrypted := item.Get("encrypted_content") + if encrypted.Type != gjson.String || encrypted.String() == "" { + return false + } + _, matched := invalid[openAIEncryptedContentDigest(encrypted.String())] + return matched + } + if input.IsArray() { + input.ForEach(func(_, item gjson.Result) bool { + if checkItem(item) { + hit = true + return false + } + return true + }) + return hit + } + if input.IsObject() { + return checkItem(input) + } + return false +} + +// stripOpenAIInvalidEncryptedContentFromReplayItems 对 replay 历史序列做同款 +// 剥离。有改动时返回新头数组,命中项以重写后的新正文整体替换(遵守 replay +// 所有权不变式,原正文不被修改);未命中时原样返回。 +func stripOpenAIInvalidEncryptedContentFromReplayItems(items []json.RawMessage, invalid map[string]struct{}) ([]json.RawMessage, int) { + if len(items) == 0 || len(invalid) == 0 { + return items, 0 + } + hit := false + for _, item := range items { + encrypted := gjson.Get(openAIWSPayloadStringView(item), "encrypted_content") + if encrypted.Type != gjson.String || encrypted.String() == "" { + continue + } + if _, matched := invalid[openAIEncryptedContentDigest(encrypted.String())]; matched { + hit = true + break + } + } + if !hit { + return items, 0 + } + stripped := 0 + next := make([]json.RawMessage, 0, len(items)) + for _, item := range items { + var decoded map[string]any + if err := decodeOpenAIJSONUseNumber(item, &decoded); err != nil { + next = append(next, item) + continue + } + encrypted, ok := decoded["encrypted_content"].(string) + if !ok || encrypted == "" { + next = append(next, item) + continue + } + if _, matched := invalid[openAIEncryptedContentDigest(encrypted)]; !matched { + next = append(next, item) + continue + } + nextItem, changed, keep := sanitizeEncryptedReasoningInputItem(decoded) + if !changed { + next = append(next, item) + continue + } + stripped++ + if !keep { + continue + } + rebuilt, err := marshalOpenAIUpstreamJSON(nextItem) + if err != nil { + next = append(next, item) + stripped-- + continue + } + next = append(next, json.RawMessage(rebuilt)) + } + if stripped == 0 { + return items, 0 + } + return next, stripped +} + +// stripOpenAIInvalidEncryptedContentRaw 是 raw JSON 版剥离:先零成本探测,命中 +// 才解码改写重编码。返回改写后的 payload 与剥离项数;未命中时原样返回。 +func stripOpenAIInvalidEncryptedContentRaw(payload []byte, invalid map[string]struct{}) ([]byte, int, error) { + if !openAIRawPayloadHasInvalidEncryptedContent(payload, invalid) { + return payload, 0, nil + } + var decoded map[string]any + if err := decodeOpenAIJSONUseNumber(payload, &decoded); err != nil { + return payload, 0, err + } + stripped := stripOpenAIInvalidEncryptedContentItems(decoded, invalid) + if stripped == 0 { + return payload, 0, nil + } + rebuilt, err := marshalOpenAIUpstreamJSON(decoded) + if err != nil { + return payload, 0, err + } + return rebuilt, stripped, nil +} + +// markOpenAIWSInvalidEncryptedContentLineage 把本次被上游拒绝的密文摘要写入 +// 会话 lineage。digests 须在剥离前收集。 +func (s *OpenAIGatewayService) markOpenAIWSInvalidEncryptedContentLineage(groupID int64, sessionHash string, digests []string) { + if s == nil || len(digests) == 0 || strings.TrimSpace(sessionHash) == "" { + return + } + stateStore := s.getOpenAIWSStateStore() + if stateStore == nil { + return + } + stateStore.MarkSessionInvalidEncryptedContent(groupID, sessionHash, digests, s.openAIWSSessionStickyTTL()) +} + +// sessionInvalidEncryptedContentDigests 返回会话已知失效密文摘要;全局无记录 +// 时(常态)零成本返回 nil。 +func (s *OpenAIGatewayService) sessionInvalidEncryptedContentDigests(groupID int64, sessionHash string) map[string]struct{} { + if s == nil || strings.TrimSpace(sessionHash) == "" { + return nil + } + stateStore := s.getOpenAIWSStateStore() + if stateStore == nil || !stateStore.HasAnySessionInvalidEncryptedContent() { + return nil + } + return stateStore.GetSessionInvalidEncryptedContentDigests(groupID, sessionHash) +} + +// openAIWSLineageSessionHashFromContext 取 lineage 会话键:优先 ingress 循环 +// 写入的会话哈希(与读取侧同键),否则按请求体派生。 +func (s *OpenAIGatewayService) openAIWSLineageSessionHashFromContext(c *gin.Context, body []byte) string { + if c != nil { + if fromCtx := strings.TrimSpace(c.GetString(openAIWSIngressSessionHashContextKey)); fromCtx != "" { + return fromCtx + } + } + return s.GenerateSessionHash(c, body) +} + +// markOpenAIWSInvalidEncryptedContentLineageFromPayload 在上游以 +// invalid_encrypted_content 拒绝 payload 时记录其密文摘要并输出观测日志。 +func (s *OpenAIGatewayService) markOpenAIWSInvalidEncryptedContentLineageFromPayload( + c *gin.Context, + payload []byte, + logKey string, + accountID int64, + turn int, +) { + digests := collectOpenAIEncryptedContentDigestsRaw(payload) + if len(digests) == 0 { + return + } + s.markOpenAIWSInvalidEncryptedContentLineage( + getOpenAIGroupIDFromContext(c), + s.openAIWSLineageSessionHashFromContext(c, payload), + digests, + ) + logOpenAIWSModeInfo("%s account_id=%d turn=%d digests=%d", logKey, accountID, turn, len(digests)) +} + +// stripSessionInvalidEncryptedContentLogged 对 payload 执行会话失效密文剥离并 +// 输出观测日志(logKey / logKey+"_skip"),返回(可能已替换的)payload 与剥离 +// 项数;未命中或剥离失败时原样返回。 +func (s *OpenAIGatewayService) stripSessionInvalidEncryptedContentLogged( + payload []byte, + invalid map[string]struct{}, + logKey string, + accountID int64, + turn int, +) ([]byte, int) { + strippedPayload, strippedCount, stripErr := stripOpenAIInvalidEncryptedContentRaw(payload, invalid) + if stripErr != nil { + logOpenAIWSModeInfo( + "%s_skip account_id=%d turn=%d reason=strip_error cause=%s", + logKey, + accountID, + turn, + truncateOpenAIWSLogValue(stripErr.Error(), openAIWSLogValueMaxLen), + ) + return payload, 0 + } + if strippedCount > 0 { + logOpenAIWSModeInfo( + "%s account_id=%d turn=%d stripped_items=%d", + logKey, + accountID, + turn, + strippedCount, + ) + } + return strippedPayload, strippedCount +} diff --git a/backend/internal/service/openai_encrypted_content_lineage_test.go b/backend/internal/service/openai_encrypted_content_lineage_test.go new file mode 100644 index 0000000000..1c97ece00b --- /dev/null +++ b/backend/internal/service/openai_encrypted_content_lineage_test.go @@ -0,0 +1,216 @@ +package service + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestCollectOpenAIEncryptedContentDigestsRaw(t *testing.T) { + t.Parallel() + + digests := collectOpenAIEncryptedContentDigestsRaw([]byte(`{"input":[ + {"type":"reasoning","encrypted_content":"cipher-a"}, + {"type":"compaction","encrypted_content":"cipher-b"}, + {"type":"input_text","text":"hello"}, + {"type":"reasoning"}, + {"type":"message","encrypted_content":"cipher-alien"} + ]}`)) + // 剥离端处理不了的类型(message)不收摘要,否则该摘要永远命中却剥不掉。 + require.Equal(t, []string{ + openAIEncryptedContentDigest("cipher-a"), + openAIEncryptedContentDigest("cipher-b"), + }, digests) + + require.Nil(t, collectOpenAIEncryptedContentDigestsRaw([]byte(`{"model":"gpt-5"}`))) +} + +func TestStripOpenAIInvalidEncryptedContentItems(t *testing.T) { + t.Parallel() + + invalid := map[string]struct{}{ + openAIEncryptedContentDigest("stale-cipher"): {}, + } + + t.Run("only_strips_matching_items", func(t *testing.T) { + t.Parallel() + reqBody := map[string]any{ + "input": []any{ + map[string]any{"type": "reasoning", "id": "rs_1", "encrypted_content": "stale-cipher", "summary": []any{}}, + map[string]any{"type": "reasoning", "id": "rs_2", "encrypted_content": "fresh-cipher"}, + map[string]any{"type": "input_text", "text": "hello"}, + }, + } + stripped := stripOpenAIInvalidEncryptedContentItems(reqBody, invalid) + require.Equal(t, 1, stripped) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 3) + first, ok := input[0].(map[string]any) + require.True(t, ok) + _, hasEncrypted := first["encrypted_content"] + require.False(t, hasEncrypted, "命中项应剥离 encrypted_content") + require.Equal(t, "rs_1", first["id"], "reasoning 骨架应保留") + second, ok := input[1].(map[string]any) + require.True(t, ok) + require.Equal(t, "fresh-cipher", second["encrypted_content"], "新密文不得误删") + }) + + t.Run("compaction_removed_entirely", func(t *testing.T) { + t.Parallel() + reqBody := map[string]any{ + "input": []any{ + map[string]any{"type": "compaction", "encrypted_content": "stale-cipher"}, + map[string]any{"type": "input_text", "text": "hello"}, + }, + } + stripped := stripOpenAIInvalidEncryptedContentItems(reqBody, invalid) + require.Equal(t, 1, stripped) + input, ok := reqBody["input"].([]any) + require.True(t, ok) + require.Len(t, input, 1) + survivor, ok := input[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "input_text", survivor["type"]) + }) + + t.Run("no_hit_no_change", func(t *testing.T) { + t.Parallel() + reqBody := map[string]any{ + "input": []any{ + map[string]any{"type": "reasoning", "encrypted_content": "fresh-cipher"}, + }, + } + require.Zero(t, stripOpenAIInvalidEncryptedContentItems(reqBody, invalid)) + }) +} + +func TestStripOpenAIInvalidEncryptedContentFromReplayItems(t *testing.T) { + t.Parallel() + + invalid := map[string]struct{}{ + openAIEncryptedContentDigest("stale-cipher"): {}, + } + + t.Run("rewrites_hit_items_and_shares_the_rest", func(t *testing.T) { + t.Parallel() + original := json.RawMessage(`{"type":"reasoning","id":"rs_1","encrypted_content":"stale-cipher"}`) + clean := json.RawMessage(`{"type":"input_text","text":"hello"}`) + compaction := json.RawMessage(`{"type":"compaction","encrypted_content":"stale-cipher"}`) + items := []json.RawMessage{original, clean, compaction} + + next, stripped := stripOpenAIInvalidEncryptedContentFromReplayItems(items, invalid) + require.Equal(t, 2, stripped) + require.Len(t, next, 2, "compaction 命中项应整项删除") + var rewritten map[string]any + require.NoError(t, json.Unmarshal(next[0], &rewritten)) + require.Equal(t, "rs_1", rewritten["id"]) + _, hasEncrypted := rewritten["encrypted_content"] + require.False(t, hasEncrypted) + // 未命中项正文共享;原命中项正文不被修改(整体替换语义)。 + require.Same(t, &clean[0], &next[1][0]) + require.Contains(t, string(original), "stale-cipher") + }) + + t.Run("miss_returns_original_header", func(t *testing.T) { + t.Parallel() + items := []json.RawMessage{json.RawMessage(`{"type":"reasoning","encrypted_content":"fresh-cipher"}`)} + next, stripped := stripOpenAIInvalidEncryptedContentFromReplayItems(items, invalid) + require.Zero(t, stripped) + require.Same(t, &items[0][0], &next[0][0]) + }) +} + +func TestStripOpenAIInvalidEncryptedContentRaw(t *testing.T) { + t.Parallel() + + invalid := map[string]struct{}{ + openAIEncryptedContentDigest("stale-cipher"): {}, + } + payload := []byte(`{"model":"gpt-5","input":[ + {"type":"reasoning","id":"rs_1","encrypted_content":"stale-cipher"}, + {"type":"reasoning","id":"rs_2","encrypted_content":"fresh-cipher"}, + {"type":"input_text","text":"hello"} + ],"stream":true}`) + + stripped, count, err := stripOpenAIInvalidEncryptedContentRaw(payload, invalid) + require.NoError(t, err) + require.Equal(t, 1, count) + var decoded map[string]any + require.NoError(t, json.Unmarshal(stripped, &decoded)) + require.Equal(t, "gpt-5", decoded["model"]) + input, ok := decoded["input"].([]any) + require.True(t, ok) + require.Len(t, input, 3) + first, ok := input[0].(map[string]any) + require.True(t, ok) + _, hasEncrypted := first["encrypted_content"] + require.False(t, hasEncrypted) + second, ok := input[1].(map[string]any) + require.True(t, ok) + require.Equal(t, "fresh-cipher", second["encrypted_content"]) + + t.Run("miss_fast_path_returns_original", func(t *testing.T) { + t.Parallel() + cleanPayload := []byte(`{"input":[{"type":"reasoning","encrypted_content":"fresh-cipher"}]}`) + unchanged, count, err := stripOpenAIInvalidEncryptedContentRaw(cleanPayload, invalid) + require.NoError(t, err) + require.Zero(t, count) + require.Same(t, &cleanPayload[0], &unchanged[0], "未命中时应原样返回,不重编码") + }) +} + +func TestOpenAIWSStateStoreInvalidEncryptedContentLineage(t *testing.T) { + t.Parallel() + + t.Run("mark_get_roundtrip_with_merge", func(t *testing.T) { + t.Parallel() + store := NewOpenAIWSStateStore(nil) + require.False(t, store.HasAnySessionInvalidEncryptedContent()) + require.Nil(t, store.GetSessionInvalidEncryptedContentDigests(1, "session-a")) + + store.MarkSessionInvalidEncryptedContent(1, "session-a", []string{"d1", "d2"}, time.Minute) + store.MarkSessionInvalidEncryptedContent(1, "session-a", []string{"d2", "d3"}, time.Minute) + require.True(t, store.HasAnySessionInvalidEncryptedContent()) + + digests := store.GetSessionInvalidEncryptedContentDigests(1, "session-a") + require.Len(t, digests, 3) + for _, digest := range []string{"d1", "d2", "d3"} { + require.Contains(t, digests, digest) + } + // 组隔离:另一组同名会话不可见。 + require.Nil(t, store.GetSessionInvalidEncryptedContentDigests(2, "session-a")) + // 返回值是拷贝,调用方修改不得影响存储。 + digests["d4"] = struct{}{} + require.Len(t, store.GetSessionInvalidEncryptedContentDigests(1, "session-a"), 3) + }) + + t.Run("expired_binding_not_returned", func(t *testing.T) { + t.Parallel() + raw := NewOpenAIWSStateStore(nil) + raw.MarkSessionInvalidEncryptedContent(1, "session-b", []string{"d1"}, time.Minute) + store, ok := raw.(*defaultOpenAIWSStateStore) + require.True(t, ok) + store.sessionInvalidEncryptedMu.Lock() + binding := store.sessionInvalidEncrypted["1:session-b"] + binding.expiresAt = time.Now().Add(-time.Second) + store.sessionInvalidEncrypted["1:session-b"] = binding + store.sessionInvalidEncryptedMu.Unlock() + require.Nil(t, store.GetSessionInvalidEncryptedContentDigests(1, "session-b")) + }) + + t.Run("per_session_capacity_degrades_gracefully", func(t *testing.T) { + t.Parallel() + store := NewOpenAIWSStateStore(nil) + oversized := make([]string, 0, openAIWSInvalidEncryptedDigestsPerSession+10) + for i := range openAIWSInvalidEncryptedDigestsPerSession + 10 { + oversized = append(oversized, openAIEncryptedContentDigest(fmt.Sprintf("cipher-%d", i))) + } + store.MarkSessionInvalidEncryptedContent(1, "session-c", oversized, time.Minute) + digests := store.GetSessionInvalidEncryptedContentDigests(1, "session-c") + require.Len(t, digests, openAIWSInvalidEncryptedDigestsPerSession) + }) +} diff --git a/backend/internal/service/openai_fast_policy_test.go b/backend/internal/service/openai_fast_policy_test.go index 23486ba2f8..a29e7ffd21 100644 --- a/backend/internal/service/openai_fast_policy_test.go +++ b/backend/internal/service/openai_fast_policy_test.go @@ -100,6 +100,9 @@ func TestEvaluateOpenAIFastPolicy_DefaultPassesKnownTiers(t *testing.T) { action, _ = svc.evaluateOpenAIFastPolicy(context.Background(), account, "gpt-5.5", OpenAIFastTierFlex) require.Equal(t, BetaPolicyActionPass, action) + action, _ = svc.evaluateOpenAIFastPolicy(context.Background(), account, "gpt-5.6-sol", OpenAIFastTierUltrafast) + require.Equal(t, BetaPolicyActionPass, action) + // empty tier → pass action, _ = svc.evaluateOpenAIFastPolicy(context.Background(), account, "gpt-5.5", "") require.Equal(t, BetaPolicyActionPass, action) @@ -248,6 +251,16 @@ func TestApplyOpenAIFastPolicyToBody_UserScopedRuleOverridesGlobalRule(t *testin require.NotContains(t, string(updated), `"service_tier"`) } +func TestApplyOpenAIFastPolicyToBody_PriorityFilterLeavesUltrafast(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, openAIFastFilterPriorityPolicy()) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + body := []byte(`{"model":"gpt-5.6-sol","service_tier":"ultrafast"}`) + + updated, err := svc.applyOpenAIFastPolicyToBody(context.Background(), account, "gpt-5.6-sol", body) + require.NoError(t, err) + require.Equal(t, OpenAIFastTierUltrafast, gjson.GetBytes(updated, "service_tier").String()) +} + func TestApplyOpenAIFastPolicyToBody_ForcePriorityRewritesKnownTier(t *testing.T) { settings := &OpenAIFastPolicySettings{ Rules: []OpenAIFastPolicyRule{{ @@ -259,7 +272,7 @@ func TestApplyOpenAIFastPolicyToBody_ForcePriorityRewritesKnownTier(t *testing.T svc := newOpenAIGatewayServiceWithSettings(t, settings) account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} - for _, tier := range []string{"flex", "auto", "default", "scale", "fast", "priority"} { + for _, tier := range []string{"flex", "auto", "default", "scale", "fast", "priority", "ultrafast"} { body := []byte(`{"model":"gpt-5.5","service_tier":"` + tier + `"}`) updated, err := svc.applyOpenAIFastPolicyToBody(context.Background(), account, "gpt-5.5", body) require.NoError(t, err) @@ -480,19 +493,28 @@ func TestSetOpenAIFastPolicySettings_Validation(t *testing.T) { // Valid settings persisted err = svc.SetOpenAIFastPolicySettings(context.Background(), &OpenAIFastPolicySettings{ - Rules: []OpenAIFastPolicyRule{{ - ServiceTier: OpenAIFastTierPriority, - Action: OpenAIFastPolicyActionForcePriority, - Scope: BetaPolicyScopeAll, - UserIDs: []int64{42, 43}, - }}, + Rules: []OpenAIFastPolicyRule{ + { + ServiceTier: OpenAIFastTierPriority, + Action: OpenAIFastPolicyActionForcePriority, + Scope: BetaPolicyScopeAll, + UserIDs: []int64{42, 43}, + }, + { + ServiceTier: OpenAIFastTierUltrafast, + Action: BetaPolicyActionPass, + Scope: BetaPolicyScopeAll, + }, + }, }) require.NoError(t, err) got, err := svc.GetOpenAIFastPolicySettings(context.Background()) require.NoError(t, err) - require.Len(t, got.Rules, 1) + require.Len(t, got.Rules, 2) require.Equal(t, OpenAIFastTierPriority, got.Rules[0].ServiceTier) require.Equal(t, OpenAIFastPolicyActionForcePriority, got.Rules[0].Action) require.Equal(t, []int64{42, 43}, got.Rules[0].UserIDs) + require.Equal(t, OpenAIFastTierUltrafast, got.Rules[1].ServiceTier) + require.Equal(t, BetaPolicyActionPass, got.Rules[1].Action) } diff --git a/backend/internal/service/openai_fast_policy_ws_test.go b/backend/internal/service/openai_fast_policy_ws_test.go index 607d32a22a..c154f32abf 100644 --- a/backend/internal/service/openai_fast_policy_ws_test.go +++ b/backend/internal/service/openai_fast_policy_ws_test.go @@ -112,7 +112,7 @@ func TestWSResponseCreate_ForcePriorityRewritesKnownTier(t *testing.T) { svc := newOpenAIGatewayServiceWithSettings(t, settings) account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} - for _, tier := range []string{"flex", "auto", "default", "scale", "fast", "priority"} { + for _, tier := range []string{"flex", "auto", "default", "scale", "fast", "priority", "ultrafast"} { frame := []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":"` + tier + `"}`) updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", frame) require.NoError(t, err) diff --git a/backend/internal/service/openai_fast_service_tier_test.go b/backend/internal/service/openai_fast_service_tier_test.go index 69ea1372ee..96a4f8ddfa 100644 --- a/backend/internal/service/openai_fast_service_tier_test.go +++ b/backend/internal/service/openai_fast_service_tier_test.go @@ -43,7 +43,7 @@ func TestValidateOpenAIServiceTierField(t *testing.T) { }) t.Run("official tiers pass through", func(t *testing.T) { - for _, tier := range []string{"flex", "auto", "default", "scale"} { + for _, tier := range []string{"flex", "auto", "default", "scale", "ultrafast"} { norm, err := ValidateOpenAIServiceTierField([]byte(`{"model":"gpt-5.5","service_tier":"` + tier + `"}`)) require.NoError(t, err, "tier %q must be accepted", tier) require.Equal(t, tier, norm) diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 325081d9da..68640507df 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -88,7 +88,7 @@ func (s *OpenAIGatewayService) failoverOpenAIUpstreamHTTPError( upstreamMsg string, upstreamModel string, ) *UpstreamFailoverError { - shouldFailover := s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) + shouldFailover := s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMsg, respBody) tempUnscheduled := false if c != nil && account != nil && account.Platform != PlatformGrok && !shouldFailover && !IsResponseCommitted(c) && s.rateLimitService != nil { tempUnscheduled = s.rateLimitService.CheckErrorPolicy(ctx, account, resp.StatusCode, respBody, upstreamModel) == ErrorPolicyTempUnscheduled @@ -223,6 +223,7 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( // 账号级请求头覆写:放在所有内置默认头(含 Grok CLI 身份头)之后应用, // 使配置值获得除共享传输层强制头之外的最高优先级。 account.ApplyHeaderOverrides(upstreamReq.Header) + applyOpenCodeSessionHeader(c, account, targetURL, upstreamReq.Header) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index fd0edd7cfd..403752baf5 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -331,6 +331,11 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( // 6. Build upstream request upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) + cancelUpstream := func() {} + if clientStream { + upstreamCtx, cancelUpstream = context.WithCancel(upstreamCtx) + } + defer cancelUpstream() upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, responsesBody, token, true, promptCacheKey, false) releaseUpstreamCtx() if err != nil { @@ -355,7 +360,10 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } - defer func() { _ = resp.Body.Close() }() + defer func() { + cancelUpstream() + _ = resp.Body.Close() + }() // 8. Handle error response with failover if resp.StatusCode >= 400 { @@ -577,6 +585,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( result := &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -698,6 +707,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( resultWithUsage := func() *OpenAIForwardResult { out := &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index d835556030..c0a209eeee 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -281,6 +281,7 @@ func (s *OpenAIGatewayService) handleCCBufferedFromNativeAnthropic( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(&usage), Model: originalModel, BillingModel: billingModel, @@ -336,6 +337,7 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( resultWithUsage := func() *OpenAIForwardResult { return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(&usage), Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_chat_completions_cancellation_test.go b/backend/internal/service/openai_gateway_chat_completions_cancellation_test.go new file mode 100644 index 0000000000..4115dc65b4 --- /dev/null +++ b/backend/internal/service/openai_gateway_chat_completions_cancellation_test.go @@ -0,0 +1,116 @@ +//go:build unit + +package service + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type contextBoundBlockingReadCloser struct { + data []byte + offset int + ctx context.Context + forceClose chan struct{} + closeOnce sync.Once +} + +func newContextBoundBlockingReadCloser(data []byte) *contextBoundBlockingReadCloser { + return &contextBoundBlockingReadCloser{ + data: data, + forceClose: make(chan struct{}), + } +} + +func (r *contextBoundBlockingReadCloser) Read(p []byte) (int, error) { + if r.offset < len(r.data) { + n := copy(p, r.data[r.offset:]) + r.offset += n + return n, nil + } + select { + case <-r.ctx.Done(): + return 0, r.ctx.Err() + case <-r.forceClose: + return 0, io.EOF + } +} + +func (r *contextBoundBlockingReadCloser) Close() error { + select { + case <-r.ctx.Done(): + case <-r.forceClose: + } + return nil +} + +func (r *contextBoundBlockingReadCloser) forceUnblock() { + r.closeOnce.Do(func() { close(r.forceClose) }) +} + +type contextBoundHTTPUpstream struct { + body *contextBoundBlockingReadCloser +} + +func (u *contextBoundHTTPUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + u.body.ctx = req.Context() + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: u.body, + }, nil +} + +func (u *contextBoundHTTPUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.Do(req, proxyURL, accountID, accountConcurrency) +} + +func TestForwardAsChatCompletions_CancelsUpstreamBeforeClosingBody(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(string(body))) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := []byte("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_1\",\"object\":\"response\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"id\":\"msg_1\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"ok\"}]}],\"usage\":{\"input_tokens\":17,\"output_tokens\":8,\"total_tokens\":25}}}\n\n") + stream := newContextBoundBlockingReadCloser(upstreamBody) + t.Cleanup(stream.forceUnblock) + + cfg := rawChatCompletionsTestConfig() + cfg.Gateway.StreamKeepaliveInterval = 1 + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &contextBoundHTTPUpstream{body: stream}, + } + + type forwardResult struct { + result *OpenAIForwardResult + err error + } + resultCh := make(chan forwardResult, 1) + go func() { + result, err := svc.ForwardAsChatCompletions(context.Background(), c, rawChatCompletionsTestAccount(), body, "", "gpt-5.1") + resultCh <- forwardResult{result: result, err: err} + }() + + select { + case got := <-resultCh: + require.NoError(t, got.err) + require.NotNil(t, got.result) + require.Equal(t, 17, got.result.Usage.InputTokens) + case <-time.After(time.Second): + t.Fatal("ForwardAsChatCompletions did not cancel upstream before closing the body") + } +} diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index dc9dcc5a62..dd77e700d2 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -157,8 +157,13 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( if err != nil { return nil, fmt.Errorf("normalize Grok chat reasoning effort: %w", err) } + upstreamBody, err = sanitizeGrokUnsupportedFields(upstreamBody) + if err != nil { + return nil, fmt.Errorf("sanitize Grok unsupported fields: %w", err) + } } upstreamBody = applyOllamaCloudRawChatCompletionsRequest(account, upstreamBody) + upstreamBody = clampOllamaCloudUpstreamMaxTokens(account, upstreamBody) logger.L().Debug("openai chat_completions raw: forwarding without protocol conversion", zap.Int64("account_id", account.ID), @@ -358,6 +363,7 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( resultWithUsage := func() *OpenAIForwardResult { return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -526,6 +532,7 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index 7225e11cf5..35f6de0ed1 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -148,6 +148,11 @@ func TestNormalizeResponsesBodyServiceTier(t *testing.T) { require.Equal(t, "scale", tier) require.Equal(t, "scale", gjson.GetBytes(body, "service_tier").String()) + body, tier, err = normalizeResponsesBodyServiceTier([]byte(`{"model":"gpt-5.6-sol","service_tier":"ultrafast"}`)) + require.NoError(t, err) + require.Equal(t, "ultrafast", tier) + require.Equal(t, "ultrafast", gjson.GetBytes(body, "service_tier").String()) + // 真未知值才会被删除。 body, tier, err = normalizeResponsesBodyServiceTier([]byte(`{"model":"gpt-5.1","service_tier":"turbo"}`)) require.NoError(t, err) diff --git a/backend/internal/service/openai_gateway_cn_fixes_test.go b/backend/internal/service/openai_gateway_cn_fixes_test.go index 576f8bfd42..370ccce8ab 100644 --- a/backend/internal/service/openai_gateway_cn_fixes_test.go +++ b/backend/internal/service/openai_gateway_cn_fixes_test.go @@ -24,7 +24,7 @@ import ( ) func TestResolveMessagesDispatchModel_CNProvidersNoDispatchMapping(t *testing.T) { - for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { g := &Group{Platform: platform} require.Empty(t, g.ResolveMessagesDispatchModel("claude-sonnet-4-5"), "CN 分组(%s)不得返回调度级映射模型(openai 默认值会发给 CN 上游)", platform) @@ -104,7 +104,7 @@ func TestResponsesStreamingFromNativeAnthropic_ClientDisconnectDrainsUsage(t *te } func TestHandle403_CNProviderHTMLBodySkipsAccountPenalty(t *testing.T) { - for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { repo := &rateLimitAccountRepoStub{} service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil) account := &Account{ID: 401, Platform: platform, Type: AccountTypeAPIKey} diff --git a/backend/internal/service/openai_gateway_count_tokens_test.go b/backend/internal/service/openai_gateway_count_tokens_test.go index 6aa8825c9e..37bfa91e00 100644 --- a/backend/internal/service/openai_gateway_count_tokens_test.go +++ b/backend/internal/service/openai_gateway_count_tokens_test.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" + "net" "net/http" "net/http/httptest" "os" @@ -350,9 +352,13 @@ func TestEstimateOpenAIInputTokens_CompareWithOpenAIAPI(t *testing.T) { actual, err := callOpenAIInputTokensAPIForTest(client, apiKey, prepared.Request) if err != nil { - // Live-API comparison only; invalid/expired local keys should skip, not fail CI. - if strings.Contains(err.Error(), "status=401") || strings.Contains(err.Error(), "invalid_api_key") { - t.Skipf("OPENAI_API_KEY rejected by OpenAI: %v", err) + // This is an optional live-API comparison. Credential and transient + // network failures must not make the deterministic unit suite fail. + var netErr net.Error + if strings.Contains(err.Error(), "status=401") || + strings.Contains(err.Error(), "invalid_api_key") || + errors.As(err, &netErr) { + t.Skipf("OpenAI live comparison unavailable: %v", err) } require.NoError(t, err) } diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 119d446ccf..6837cb8236 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -358,12 +358,6 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco return nil, errors.New("image generation disabled for group") } - instructions := gjson.GetBytes(body, "instructions") - instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" - if instructionsEmpty && account.UsesOpenAICodexProtocol() && !compatMessagesBridge && !nativeCNResponses { - markPatchSet("instructions", defaultCodexSynthInstructions(reqModel)) - } - isCompactRequest := compactPath requestedModel := reqModel billingModel, upstreamModel := resolveOpenAIForwardMappedModels(account, requestedModel, isCompactRequest) @@ -372,6 +366,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco upstreamModel = compactModel } } + instructions := gjson.GetBytes(body, "instructions") + instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" + if instructionsEmpty && account.UsesOpenAICodexProtocol() && !compatMessagesBridge && !nativeCNResponses { + markPatchSet("instructions", defaultCodexSynthInstructions(upstreamModel)) + } if billingModel != requestedModel { logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", requestedModel, billingModel, account.Name, isCodexCLI) } @@ -611,6 +610,14 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } } + // Ollama Cloud(实际 Responses 上游为 ollama.com)输出上限 clamp:对 Codex 与 + // 非 Codex 客户端一律执行(真实 Codex 客户端同样会带超限 max_output_tokens 被 + // ollama.com 以 400 拒绝)。在 `!isCodexCLI` 归一化块之后独立调用:非 Codex 时 + // 位于平台字段归一化之后,不跳过原有平台 switch(patch 按追加顺序应用,set 在 + // 先前的 delete/set 之后生效);Codex 请求不做归一化,直接按 body 现值判定。 + if clampedCap, ok := ollamaCloudResponsesMaxOutputTokensClamp(account, upstreamModel, body); ok { + markPatchSet("max_output_tokens", clampedCap) + } if wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 && !account.IsOpenAIApiKey() && gjson.GetBytes(body, "previous_response_id").Exists() { markPatchDelete("previous_response_id") @@ -712,6 +719,26 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco requestView = newOpenAIRequestView(body) reqBody = nil } + // 剥离本会话已被上游判定失效的加密项(invalid_encrypted_content lineage), + // 阻断同一失效密文随客户端历史在每一轮重复触发"被拒→剥离→重试/重连"。 + // lineage 会话键统一按进场形态的 body 派生:后续重试可能改写 body, + // 延迟计算会与下一请求的进场键漂移。 + lineageGroupID := getOpenAIGroupIDFromContext(c) + lineageEntryBody := body + lineageSessionHash := "" + if stateStore := s.getOpenAIWSStateStore(); stateStore != nil && stateStore.HasAnySessionInvalidEncryptedContent() { + lineageSessionHash = s.GenerateSessionHash(c, body) + if invalidDigests := stateStore.GetSessionInvalidEncryptedContentDigests(lineageGroupID, lineageSessionHash); len(invalidDigests) > 0 { + strippedBody, strippedCount := s.stripSessionInvalidEncryptedContentLogged( + body, invalidDigests, "invalid_encrypted_lineage_strip", account.ID, 0, + ) + if strippedCount > 0 { + body = strippedBody + requestView = newOpenAIRequestView(body) + reqBody = nil + } + } + } imageBillingModel := "" imageSizeTier := "" imageInputSize := "" @@ -799,6 +826,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if wsInvalidEncryptedContentRecoveryTried { return false } + // 写入 lineage 后,同一失效密文在后续 turn 进场时被预剥离,不再重复 + // 触发上游拒绝与重连。摘要取自进场形态的 body(密文项只可能来自 + // 客户端进场请求,重复摘要幂等)。 + invalidDigests := collectOpenAIEncryptedContentDigestsRaw(lineageEntryBody) removedReasoningItems := trimOpenAIEncryptedReasoningItems(wsReqBody) if !removedReasoningItems { logOpenAIWSModeInfo( @@ -808,6 +839,12 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco ) return false } + if len(invalidDigests) > 0 { + if lineageSessionHash == "" { + lineageSessionHash = s.GenerateSessionHash(c, lineageEntryBody) + } + s.markOpenAIWSInvalidEncryptedContentLineage(lineageGroupID, lineageSessionHash, invalidDigests) + } previousResponseID := openAIWSPayloadString(wsReqBody, "previous_response_id") hasFunctionCallOutput := HasFunctionCallOutput(wsReqBody) if previousResponseID != "" && !hasFunctionCallOutput { @@ -1060,11 +1097,18 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if decodeErr != nil { return nil, decodeErr } + invalidDigests := collectOpenAIEncryptedContentDigestsRaw(lineageEntryBody) if trimOpenAIEncryptedReasoningItems(decoded) { body, err = marshalOpenAIUpstreamJSON(decoded) if err != nil { return nil, fmt.Errorf("serialize invalid_encrypted_content retry body: %w", err) } + if len(invalidDigests) > 0 { + if lineageSessionHash == "" { + lineageSessionHash = s.GenerateSessionHash(c, lineageEntryBody) + } + s.markOpenAIWSInvalidEncryptedContentLineage(lineageGroupID, lineageSessionHash, invalidDigests) + } httpInvalidEncryptedContentRetryTried = true rejectedFieldRetryState.remember(body) logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Retrying non-WSv2 request once after invalid_encrypted_content (account: %s)", account.Name) @@ -1099,7 +1143,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco ) continue } - if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) { + if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMsg, respBody) { upstreamDetail := "" if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes @@ -1174,7 +1218,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco _ = resp.Body.Close() } compactResp, compactBody := openAICompactFallbackErrorResponse(resp, signal) - if s.shouldFailoverOpenAIUpstreamResponse(compactResp.StatusCode, signal.message, compactBody) { + if s.shouldFailoverOpenAIUpstreamResponse(account, compactResp.StatusCode, signal.message, compactBody) { appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), ProxyName: opsUpstreamProxyName(account), @@ -1244,6 +1288,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco forwardResult := &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, ResponseID: responseID, Usage: *usage, Model: originalModel, @@ -1440,6 +1485,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) + applyOpenCodeSessionHeader(c, account, targetURL, req.Header) // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后, // 保证不被覆盖丢失)。 applyOpenAICodexBetaFeatures(c, account, req.Header) diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go index 95c649ad97..5b9990b52c 100644 --- a/backend/internal/service/openai_gateway_grok.go +++ b/backend/internal/service/openai_gateway_grok.go @@ -250,6 +250,7 @@ func (s *OpenAIGatewayService) forwardGrokResponses( reasoningEffort := extractOpenAIReasoningEffortFromBody(patchedBody, originalModel) result := &OpenAIForwardResult{ RequestID: firstNonEmpty(resp.Header.Get("x-request-id"), resp.Header.Get("xai-request-id")), + UpstreamHeaders: resp.Header, ResponseID: responseID, Usage: *usage, Model: originalModel, @@ -762,11 +763,14 @@ func grokSupportsReasoningEffort(model string) bool { } } -var grokResponsesUnsupportedRecursiveFields = map[string]struct{}{ +// grokUnsupportedRecursiveFields 定义 Grok 平台(Responses 和 Chat Completions)不支持的字段 +var grokUnsupportedRecursiveFields = map[string]struct{}{ "external_web_access": {}, } -func sanitizeGrokResponsesUnsupportedFields(body []byte) ([]byte, error) { +// sanitizeGrokUnsupportedFields 递归移除 Grok 平台不支持的字段 +// 适用于 Responses API 和 Chat Completions API +func sanitizeGrokUnsupportedFields(body []byte) ([]byte, error) { if !bytes.Contains(body, []byte(`"external_web_access"`)) { return body, nil } @@ -775,12 +779,15 @@ func sanitizeGrokResponsesUnsupportedFields(body []byte) ([]byte, error) { if err := decodeOpenAIJSONUseNumber(body, &payload); err != nil { return nil, err } - if !deleteJSONFields(payload, grokResponsesUnsupportedRecursiveFields) { + if !deleteJSONFields(payload, grokUnsupportedRecursiveFields) { return body, nil } return marshalOpenAIUpstreamJSON(payload) } +// sanitizeGrokResponsesUnsupportedFields 保留旧函数名作为别名,向后兼容 +var sanitizeGrokResponsesUnsupportedFields = sanitizeGrokUnsupportedFields + func deleteJSONFields(value any, fields map[string]struct{}) bool { switch typed := value.(type) { case map[string]any: diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index b63d4455e9..7f3144752d 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -272,10 +272,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if account.Platform == PlatformOpenAI { policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, responsesBody) if policyErr != nil { - var overLimit *ReasoningEffortOverLimitError - if errors.As(policyErr, &overLimit) { + if IsReasoningEffortPolicyDenied(policyErr) { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) - writeAnthropicError(c, http.StatusForbidden, "forbidden_error", overLimit.Error()) + writeAnthropicError(c, http.StatusForbidden, "forbidden_error", policyErr.Error()) } return nil, policyErr } @@ -651,6 +650,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( result := &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, ResponseID: finalResponse.ID, Usage: usage, Model: originalModel, @@ -957,6 +957,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( resultWithUsage := func() *OpenAIForwardResult { out := &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, ResponseID: responseID, Usage: usage, Model: originalModel, diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index f7addc90b0..6624c30f5a 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -57,6 +57,9 @@ func (s *OpenAIGatewayService) forwardAnthropicViaNativeAnthropicEndpoint( } body = rewritten } + if normalized, changed := NormalizeGLM53AnthropicThinking(body, upstreamModel); changed { + body = normalized + } // 记录客户端请求的推理强度:优先 Claude 协议的 output_config.effort; // 缺失且 thinking 已启用时,按国产 passback-required 模型兜底为 high @@ -155,6 +158,11 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest( body = sanitized } + // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与 nativeAnthropicTargetURL + // 的 base 取值同源(GetAnthropicProtocolBaseURL,adaptive 时是 Anthropic 协议 + // 地址而非 CC/Responses 地址),详见 helper 注释。 + body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetAnthropicProtocolBaseURL(), body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) if err != nil { return nil, nil, err @@ -174,12 +182,13 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest( } // 覆盖入站鉴权残留,注入上游认证(默认 x-api-key;可经 extra - // anthropic_apikey_auth_scheme 切换 Authorization: Bearer)。 + // anthropic_apikey_auth_scheme 切换 Authorization: Bearer;Ollama Cloud + // 上游按实际 base_url 强制 Bearer,与 nativeAnthropicTargetURL 同源)。 req.Header.Del("authorization") req.Header.Del("x-api-key") req.Header.Del("x-goog-api-key") req.Header.Del("cookie") - setAnthropicAPIKeyAuthHeader(req.Header, account, apiKey) + setAnthropicAPIKeyAuthHeader(req.Header, account, apiKey, account.GetAnthropicProtocolBaseURL()) if getHeaderRaw(req.Header, "content-type") == "" { setHeaderRaw(req.Header, "content-type", "application/json") @@ -244,6 +253,7 @@ func (s *OpenAIGatewayService) handleNativeAnthropicBufferedResponse( return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(usage), Model: originalModel, BillingModel: billingModel, @@ -517,6 +527,7 @@ func (s *OpenAIGatewayService) nativeAnthropicStreamResult( } return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(usage), Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native_test.go b/backend/internal/service/openai_gateway_messages_anthropic_native_test.go index 2f68982094..bea8ab1cef 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native_test.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native_test.go @@ -4,6 +4,7 @@ package service import ( "context" + "fmt" "io" "net/http" "strings" @@ -11,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) // 国产供应商原生 Anthropic 直通路径(api_protocol=anthropic)的 reasoning_effort 记录。 @@ -35,6 +37,13 @@ func nativeAnthropicTestAccount() *Account { } } +func nativeAnthropicGLMTestAccount() *Account { + account := nativeAnthropicTestAccount() + account.Name = "zhipu-native" + account.Platform = PlatformZhipu + return account +} + func nativeAnthropicBufferedResponse() *http.Response { return &http.Response{ StatusCode: http.StatusOK, @@ -137,3 +146,70 @@ func TestNativeAnthropicPassthroughNoEffortStaysNil(t *testing.T) { require.NotNil(t, result) require.Nil(t, result.ReasoningEffort) } + +func TestNativeAnthropicPassthroughNormalizesGLM53Thinking(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + stream bool + preference string + wantEffort string + }{ + {name: "disabled buffered", preference: `"thinking":{"type":"disabled"},`, wantEffort: "low"}, + {name: "off buffered", preference: `"thinking":{"type":"off"},`, wantEffort: "low"}, + {name: "none buffered", preference: `"thinking":{"type":"none"},`, wantEffort: "low"}, + {name: "enabled buffered", preference: `"thinking":{"type":"enabled"},`, wantEffort: "high"}, + {name: "enabled streaming", stream: true, preference: `"thinking":{"type":"enabled"},`, wantEffort: "high"}, + {name: "adaptive buffered", preference: `"thinking":{"type":"adaptive"},`, wantEffort: "high"}, + {name: "adaptive streaming", stream: true, preference: `"thinking":{"type":"adaptive"},`, wantEffort: "high"}, + {name: "minimal buffered", preference: `"output_config":{"effort":"minimal"},`, wantEffort: "low"}, + {name: "low buffered", preference: `"output_config":{"effort":"low"},`, wantEffort: "low"}, + {name: "medium streaming", stream: true, preference: `"output_config":{"effort":"medium"},`, wantEffort: "high"}, + {name: "high buffered", preference: `"output_config":{"effort":"high"},`, wantEffort: "high"}, + {name: "xhigh buffered", preference: `"output_config":{"effort":"xhigh"},`, wantEffort: "max"}, + {name: "max buffered", preference: `"output_config":{"effort":"max"},`, wantEffort: "max"}, + {name: "ultra buffered", preference: `"output_config":{"effort":"ultra"},`, wantEffort: "max"}, + {name: "output effort wins over thinking", preference: `"thinking":{"type":"adaptive"},"output_config":{"effort":"low"},`, wantEffort: "low"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"glm-5.3","max_tokens":32,"stream":%t,%s"messages":[{"role":"user","content":"hi"}]}`, tt.stream, tt.preference)) + response := nativeAnthropicBufferedResponse() + if tt.stream { + response = nativeAnthropicStreamResponse() + } + upstream := &httpUpstreamRecorder{resp: response} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + _, err := svc.ForwardAsAnthropic(context.Background(), + adaptiveProtocolTestContext("/v1/messages", body), nativeAnthropicGLMTestAccount(), body, "", "") + require.NoError(t, err) + require.Equal(t, "enabled", gjson.GetBytes(upstream.lastBody, "thinking.type").String()) + require.Equal(t, tt.wantEffort, gjson.GetBytes(upstream.lastBody, "output_config.effort").String()) + }) + } +} + +func TestNativeAnthropicPassthroughLeavesOtherThinkingUntouched(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + body string + }{ + {name: "glm 5.3 unspecified", body: `{"model":"glm-5.3","max_tokens":32,"stream":false,"messages":[]}`}, + {name: "glm 5.2 disabled", body: `{"model":"glm-5.2","max_tokens":32,"stream":false,"thinking":{"type":"disabled"},"messages":[]}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := []byte(tt.body) + upstream := &httpUpstreamRecorder{resp: nativeAnthropicBufferedResponse()} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + _, err := svc.ForwardAsAnthropic(context.Background(), + adaptiveProtocolTestContext("/v1/messages", body), nativeAnthropicGLMTestAccount(), body, "", "") + require.NoError(t, err) + require.JSONEq(t, tt.body, string(upstream.lastBody)) + }) + } +} diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index c4fcdd772f..928e3fa982 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -3,7 +3,6 @@ package service import ( "context" "encoding/json" - "errors" "fmt" "net/http" "strings" @@ -83,10 +82,9 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( if account.Platform == PlatformOpenAI { policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, chatBody) if policyErr != nil { - var overLimit *ReasoningEffortOverLimitError - if errors.As(policyErr, &overLimit) { + if IsReasoningEffortPolicyDenied(policyErr) { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) - writeAnthropicError(c, http.StatusForbidden, "forbidden_error", overLimit.Error()) + writeAnthropicError(c, http.StatusForbidden, "forbidden_error", policyErr.Error()) } return nil, policyErr } @@ -163,6 +161,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsAnthropic( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -224,6 +223,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( // (mirrors forwardResponsesViaRawChatCompletions). return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -260,6 +260,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go b/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go index f78a375860..4fd1c317b6 100644 --- a/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go +++ b/backend/internal/service/openai_gateway_ollama_cloud_cc_reasoning.go @@ -47,12 +47,13 @@ func accountHasOllamaCloudUsageExtra(account *Account) bool { return false } +// applyOllamaCloudRawChatCompletionsRequest 只做 Ollama Cloud reasoning 归一化; +// max_tokens clamp 已解耦到独立钩子 clampOllamaCloudUpstreamMaxTokens,由出站方依次调用。 func applyOllamaCloudRawChatCompletionsRequest(account *Account, body []byte) []byte { if !isOllamaCloudRawChatCompletionsAccount(account) || len(body) == 0 { return body } - body = normalizeOllamaCloudChatCompletionsRequest(body) - return clampOllamaCloudMaxTokens(account, body) + return normalizeOllamaCloudChatCompletionsRequest(body) } func applyOllamaCloudRawChatCompletionsResponse(account *Account, body []byte) []byte { diff --git a/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go index 32a508481a..4c69a32b23 100644 --- a/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go +++ b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens.go @@ -18,9 +18,60 @@ const OllamaCloudMaxTokensCapExtraKey = "ollama_max_tokens_cap" // (约 65535),max_tokens 超过该值会被上游直接 400 拒绝;该上限与模型无关,不做模型过滤。 const ollamaCloudDefaultMaxTokensCap = 65535 -// 本文件的 clampOllamaCloudMaxTokens 被 -// applyOllamaCloudRawChatCompletionsRequest(openai_gateway_ollama_cloud_cc_reasoning.go) -// 调用,账号检测(isOllamaCloudRawChatCompletionsAccount)由调用方完成,此处不再重复判断。 +// clampOllamaCloudUpstreamMaxTokens 是 raw CC 出站(forwardAsRawChatCompletions 与 +// /v1/responses 降级 forwardResponsesViaRawChatCompletions 共用)的独立 token 钩子, +// 与 reasoning 钩子解耦:只看本次请求实际选用的 CC 上游 base_url(GetOpenAIBaseURL, +// 与 openAIChatCompletionsTargetURL 的取值一致)是否 ollama.com,不读 usage extra。 +// DeepSeek 系模型(用模型映射后的真实出站 model,body 中 model 已改写)任意平台均 +// clamp;非 DeepSeek 模型仅保留既有 openai 平台 Ollama 账号(force_chat_completions +// 判定)的 clamp,不扩展到其它平台。 +func clampOllamaCloudUpstreamMaxTokens(account *Account, body []byte) []byte { + if account == nil || len(body) == 0 { + return body + } + if !isOllamaCloudBaseURL(account.GetOpenAIBaseURL()) { + return body + } + if !isDeepSeekModel(gjson.GetBytes(body, "model").String()) && !isOllamaCloudRawChatCompletionsAccount(account) { + return body + } + return clampOllamaCloudMaxTokens(account, body) +} + +// ollamaCloudResponsesUpstreamBaseURL 返回原生 /v1/responses 本次实际选用的上游 +// base_url,取值与 buildUpstreamRequest 一致:adaptive 原生 CN 账号用 api_base_urls +// 的 responses 地址,其余用 GetOpenAIBaseURL。 +func ollamaCloudResponsesUpstreamBaseURL(account *Account) string { + if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() { + return account.GetCNProtocolBaseURL(APIProtocolResponses) + } + return account.GetOpenAIBaseURL() +} + +// ollamaCloudResponsesMaxOutputTokensClamp 是原生 /v1/responses 路径的 clamp:在 +// 平台字段归一化之后独立调用,不改写原有的 max_output_tokens 平台 switch。判定: +// 实际 Responses 上游(ollamaCloudResponsesUpstreamBaseURL)为 ollama.com 的 +// APIKey 账号 + 映射后的出站模型是 DeepSeek 系;openai 平台 max_tokens 刚被归一化 +// patch 到 max_output_tokens 时取 max_tokens 为生效值。未命中返回 ok=false。 +// 实测:ollama.com/v1/responses max_output_tokens=256000 被上游以 "max_tokens +// (256000) exceeds model's maximum output tokens (65536)" 400 拒绝(DeepSeek 模型)。 +func ollamaCloudResponsesMaxOutputTokensClamp(account *Account, upstreamModel string, body []byte) (int64, bool) { + if account == nil || account.Type != AccountTypeAPIKey || !isDeepSeekModel(upstreamModel) { + return 0, false + } + if !isOllamaCloudBaseURL(ollamaCloudResponsesUpstreamBaseURL(account)) { + return 0, false + } + value := gjson.GetBytes(body, "max_output_tokens") + if !value.Exists() && account.Platform == PlatformOpenAI { + value = gjson.GetBytes(body, "max_tokens") + } + cap := ollamaCloudMaxTokensCap(account) + if cap <= 0 || !value.Exists() || value.Type != gjson.Number || value.Int() <= cap { + return 0, false + } + return cap, true +} // ollamaCloudMaxTokensCap 返回账号配置的 max_tokens 上限。账号为 nil 或 extra 中 // 无该键时返回默认值;键值为数值类型(float64/int64/int/json.Number)时返回其整数 diff --git a/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go index 6bbc2d7e5f..f818c37973 100644 --- a/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go +++ b/backend/internal/service/openai_gateway_ollama_cloud_max_tokens_test.go @@ -3,11 +3,15 @@ package service import ( + "context" "encoding/json" + "errors" "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) // ollamaMaxTokensCapTestAccount 构造带自定义 cap 的 Ollama Cloud usage 账号。 @@ -129,17 +133,19 @@ func TestOllamaCloudMaxTokensCap(t *testing.T) { } } -// TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens 验证 max_tokens clamp -// 已接入组合钩子 applyOllamaCloudRawChatCompletionsRequest,并遵循该钩子的账号判定门槛 -// (isOllamaCloudRawChatCompletionsAccount:platform openai + type apikey + -// force_chat_completions + ollama.com 或 Ollama usage extra)。 +// TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens 验证 reasoning 钩子 +// 与 token clamp 已解耦:reasoning 钩子只做 reasoning 归一化,clamp 由独立 token +// 钩子 clampOllamaCloudUpstreamMaxTokens 在出站时接续执行(两条 raw CC 出站路径 +// 均依次调用两者,端到端行为不变)。 func TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens(t *testing.T) { body := []byte(`{"model":"deepseek-chat","max_tokens":100000}`) - // Ollama Cloud 账号(ollama.com + force_chat_completions)→ clamp 到 65535。 + // Ollama Cloud 账号:reasoning 钩子不再 clamp,字节级原样。 ollama := ollamaCloudRawChatCompletionsTestAccount() + require.Equal(t, string(body), string(applyOllamaCloudRawChatCompletionsRequest(ollama, body))) + // 独立 token 钩子接续 clamp 到既有默认 cap。 require.JSONEq(t, `{"model":"deepseek-chat","max_tokens":65535}`, - string(applyOllamaCloudRawChatCompletionsRequest(ollama, body))) + string(clampOllamaCloudUpstreamMaxTokens(ollama, body))) // 官方 DeepSeek(api.deepseek.com + force_chat_completions)→ 字节级不变。 official := rawChatCompletionsTestAccount() @@ -148,13 +154,400 @@ func TestApplyOllamaCloudRawChatCompletionsRequestClampsMaxTokens(t *testing.T) openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions), } require.Equal(t, body, applyOllamaCloudRawChatCompletionsRequest(official, body)) + require.Equal(t, string(body), string(clampOllamaCloudUpstreamMaxTokens(official, body))) - // ollama.com 但无 force_chat_completions(Extra 缺键)→ 不通过钩子判定门槛,字节级不变。 + // ollama.com 但无 force_chat_completions:reasoning 钩子不生效;独立钩子按 + // DeepSeek 系模型判定仍 clamp(DeepSeek 覆盖不依赖 responses_mode extra)。 noForce := ollamaCloudRawChatCompletionsTestAccount() noForce.Extra = nil require.Equal(t, body, applyOllamaCloudRawChatCompletionsRequest(noForce, body)) + require.JSONEq(t, `{"model":"deepseek-chat","max_tokens":65535}`, + string(clampOllamaCloudUpstreamMaxTokens(noForce, body))) // 空 body → 原样返回。 require.Equal(t, []byte(nil), applyOllamaCloudRawChatCompletionsRequest(ollama, nil)) require.Equal(t, []byte{}, applyOllamaCloudRawChatCompletionsRequest(ollama, []byte{})) } + +// ollamaUpstreamTestAccount 构造挂在实际 ollama.com 上游的 APIKey 账号。平台标签 +// 不参与 clamp 判定,这里用不同平台仅为了覆盖生产里的挂载方式;base_url 与生产 +// 实测一致(https://ollama.com/v1)。 +func ollamaUpstreamTestAccount(platform string, id int64) *Account { + return &Account{ + ID: id, + Name: "ollama-cloud", + Platform: platform, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://ollama.com/v1", + }, + Extra: map[string]any{}, + } +} + +// officialDeepSeekTestAccount 构造官方 DeepSeek 账号,extra 里带残留的 Ollama +// usage 快照键:usage extra 不得把非 Ollama host 识别为 Ollama 上游。 +func officialDeepSeekTestAccount(id int64) *Account { + return &Account{ + ID: id, + Name: "official-deepseek", + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://api.deepseek.com", + }, + Extra: map[string]any{ + OllamaCloudUsageSnapshotExtraKey: map[string]any{"status": "ok"}, + }, + } +} + +func TestClampOllamaCloudUpstreamMaxTokens(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + account *Account + body string + want string + }{ + { + // 生产问题场景:deepseek 平台 Ollama 账号,无 openai_responses_mode extra, + // raw CC 直转时 max_tokens 超限被上游 400。 + name: "deepseek platform ollama.com without responses_mode is clamped", + account: ollamaUpstreamTestAccount(PlatformDeepseek, 301), + body: `{"model":"deepseek-v4-flash","max_tokens":256000}`, + want: `{"model":"deepseek-v4-flash","max_tokens":65535}`, + }, + { + name: "max_completion_tokens is clamped too", + account: ollamaUpstreamTestAccount(PlatformDeepseek, 301), + body: `{"model":"deepseek-v4-flash","max_completion_tokens":256000}`, + want: `{"model":"deepseek-v4-flash","max_completion_tokens":65535}`, + }, + { + // 新增范围只限 DeepSeek 系模型:kimi/zhipu 平台挂 ollama.com 跑非 + // DeepSeek 模型的请求保持不变。 + name: "kimi platform non-deepseek model untouched", + account: ollamaUpstreamTestAccount(PlatformKimi, 302), + body: `{"model":"k3-256k","max_tokens":256000}`, + want: `{"model":"k3-256k","max_tokens":256000}`, + }, + { + name: "zhipu platform non-deepseek model untouched", + account: ollamaUpstreamTestAccount(PlatformZhipu, 302), + body: `{"model":"glm-4.7","max_tokens":256000}`, + want: `{"model":"glm-4.7","max_tokens":256000}`, + }, + { + // 既有 openai 平台 Ollama 账号对 DeepSeek 模型的 clamp 幂等保持。 + name: "openai platform ollama.com deepseek model stays clamped", + account: ollamaUpstreamTestAccount(PlatformOpenAI, 303), + body: `{"model":"deepseek-v4-flash","max_tokens":70000}`, + want: `{"model":"deepseek-v4-flash","max_tokens":65535}`, + }, + { + // 既有 openai 平台 Ollama(真实 ollama.com + force_chat_completions)对 + // 非 DeepSeek 模型的 clamp 保留。 + name: "openai platform ollama.com non-deepseek model keeps legacy clamp", + account: ollamaCloudRawChatCompletionsTestAccount(), + body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + want: `{"model":"gpt-oss:120b-cloud","max_tokens":65535}`, + }, + { + // 非 DeepSeek 模型不扩展到其它平台。 + name: "openai platform ollama.com non-deepseek model without force_cc untouched", + account: func() *Account { + account := ollamaUpstreamTestAccount(PlatformOpenAI, 303) + account.Extra = nil + return account + }(), + body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + want: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + }, + { + // 残留 usage extra 不得把非 Ollama host 变成 Ollama(旧 reasoning 钩子内的 + // clamp 会命中该账号,解耦后已封堵)。 + name: "openai platform non-ollama host with leftover usage extra untouched", + account: func() *Account { + account := ollamaCloudRawChatCompletionsTestAccount() + account.Credentials["base_url"] = "https://example.invalid/v1" + account.Extra[OllamaCloudUsageSnapshotExtraKey] = map[string]any{"status": "ok"} + return account + }(), + body: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + want: `{"model":"gpt-oss:120b-cloud","max_tokens":70000}`, + }, + { + name: "extra cap zero disables clamping", + account: func() *Account { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 304) + account.Extra[OllamaCloudMaxTokensCapExtraKey] = 0 + return account + }(), + body: `{"model":"deepseek-v4-flash","max_tokens":256000}`, + want: `{"model":"deepseek-v4-flash","max_tokens":256000}`, + }, + { + name: "value at cap is untouched", + account: ollamaUpstreamTestAccount(PlatformDeepseek, 304), + body: `{"model":"deepseek-v4-flash","max_tokens":65535}`, + want: `{"model":"deepseek-v4-flash","max_tokens":65535}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + require.JSONEq(t, test.want, string(clampOllamaCloudUpstreamMaxTokens(test.account, []byte(test.body)))) + }) + } + + // 非 Ollama 实际上游(api.deepseek.com)即使残留 usage extra 也不 clamp。 + official := officialDeepSeekTestAccount(305) + body := []byte(`{"model":"deepseek-v4-flash","max_tokens":256000,"max_completion_tokens":256000}`) + require.Equal(t, string(body), string(clampOllamaCloudUpstreamMaxTokens(official, body))) + + // 空 body / nil 账号原样返回。 + require.Equal(t, []byte(nil), clampOllamaCloudUpstreamMaxTokens(official, nil)) +} + +// TestForwardAsRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens 端到端验证 raw +// CC 出站钩子:判定用模型映射后的真实出站 model,官方 DeepSeek 字节级不变。 +func TestForwardAsRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens(t *testing.T) { + gin.SetMode(gin.TestMode) + + account := ollamaUpstreamTestAccount(PlatformDeepseek, 311) + account.Credentials["api_protocol"] = APIProtocolChatCompletions + account.Credentials["model_mapping"] = map[string]any{"deepseek-chat": "deepseek-v4-flash"} + + body := []byte(`{"model":"deepseek-chat","max_tokens":256000,"messages":[{"role":"user","content":"hi"}],"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + _, err := svc.forwardAsRawChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "") + require.Error(t, err) + require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_tokens").Int()) + + // 官方 DeepSeek(api.deepseek.com)+ 残留 usage extra:字节级不变。 + official := officialDeepSeekTestAccount(312) + official.Credentials["api_protocol"] = APIProtocolChatCompletions + officialBody := []byte(`{"model":"deepseek-v4-flash","max_tokens":256000,"messages":[{"role":"user","content":"hi"}],"stream":false}`) + officialUpstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + officialSvc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: officialUpstream} + + _, err = officialSvc.forwardAsRawChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", officialBody), official, officialBody, "") + require.Error(t, err) + require.Equal(t, string(officialBody), string(officialUpstream.lastBody)) +} + +// TestForwardResponsesViaRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens 验证 +// /v1/responses 降级到 raw CC 的路径确实经过同一个独立 token 钩子(Responses 请求的 +// max_output_tokens 经 apicompat 转成 CC 的 max_completion_tokens)。 +func TestForwardResponsesViaRawChatCompletions_DeepseekOllamaCloudClampsMaxTokens(t *testing.T) { + gin.SetMode(gin.TestMode) + + account := ollamaUpstreamTestAccount(PlatformDeepseek, 321) + account.Credentials["api_protocol"] = APIProtocolChatCompletions + + body := []byte(`{"model":"deepseek-v4-flash","input":"Reply with exactly OK and nothing else.","max_output_tokens":256000,"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + + _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) + require.Error(t, err) + require.Equal(t, "https://ollama.com/v1/chat/completions", upstream.lastReq.URL.String()) + require.Equal(t, "deepseek-v4-flash", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_completion_tokens").Int()) +} + +// TestForwardResponsesClampsOllamaCloudMaxOutputTokens 覆盖原生 /v1/responses 路径 +// (openai force_responses 与 deepseek api_protocol=responses)的 max_output_tokens +// clamp,实测依据:POST ollama.com/v1/responses max_output_tokens=256000 被上游以 +// "max_tokens (256000) exceeds model's maximum output tokens (65536)" 400 拒绝。 +func TestForwardResponsesClampsOllamaCloudMaxOutputTokens(t *testing.T) { + gin.SetMode(gin.TestMode) + + responsesBody := []byte(`{"model":"deepseek-v4-flash","input":"Reply with exactly OK and nothing else.","max_output_tokens":256000,"stream":false}`) + + run := func(account *Account, body []byte) (*httpUpstreamRecorder, error) { + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) + return upstream, err + } + + t.Run("deepseek platform native responses is clamped", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 331) + account.Credentials["api_protocol"] = APIProtocolResponses + upstream, err := run(account, responsesBody) + require.Error(t, err) + require.Equal(t, "https://ollama.com/v1/responses", upstream.lastReq.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + + t.Run("openai platform force_responses is clamped", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformOpenAI, 332) + account.Extra[openai_compat.ExtraKeyResponsesMode] = string(openai_compat.ResponsesSupportModeForceResponses) + upstream, err := run(account, responsesBody) + require.Error(t, err) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + + t.Run("official deepseek with leftover usage extra is untouched", func(t *testing.T) { + account := officialDeepSeekTestAccount(333) + account.Credentials["api_protocol"] = APIProtocolResponses + upstream, err := run(account, responsesBody) + require.Error(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + + t.Run("at cap is untouched", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 334) + account.Credentials["api_protocol"] = APIProtocolResponses + atCap := []byte(`{"model":"deepseek-v4-flash","input":"hi","max_output_tokens":65535,"stream":false}`) + upstream, err := run(account, atCap) + require.Error(t, err) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + + t.Run("extra cap zero disables clamping", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 335) + account.Credentials["api_protocol"] = APIProtocolResponses + account.Extra[OllamaCloudMaxTokensCapExtraKey] = 0 + upstream, err := run(account, responsesBody) + require.Error(t, err) + require.Equal(t, int64(256000), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) +} + +// TestForwardResponsesClampUsesResponsesUpstreamBaseURL 验证原生 Responses clamp 按 +// 实际 Responses 上游判定:adaptive 账号取 api_base_urls 的 responses 地址,而非 CC +// 地址(与 buildUpstreamRequest 的 URL 选择一致)。 +func TestForwardResponsesClampUsesResponsesUpstreamBaseURL(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"deepseek-v4-flash","input":"hi","max_output_tokens":256000,"stream":false}`) + run := func(account *Account) (*httpUpstreamRecorder, error) { + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) + return upstream, err + } + + t.Run("ollama CC base but non-ollama responses base is not clamped", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 341) + account.Credentials["api_protocol"] = APIProtocolAdaptive + account.Credentials["api_base_urls"] = map[string]any{ + APIProtocolChatCompletions: "https://ollama.com/v1", + APIProtocolResponses: "https://api.deepseek.com", + } + upstream, err := run(account) + require.Error(t, err) + require.Equal(t, "https://api.deepseek.com/responses", upstream.lastReq.URL.String()) + require.Equal(t, int64(256000), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + + t.Run("non-ollama CC base but ollama responses base is clamped", func(t *testing.T) { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 342) + account.Credentials["api_protocol"] = APIProtocolAdaptive + account.Credentials["api_base_urls"] = map[string]any{ + APIProtocolChatCompletions: "https://api.deepseek.com", + APIProtocolResponses: "https://ollama.com/v1", + } + upstream, err := run(account) + require.Error(t, err) + require.Equal(t, "https://ollama.com/v1/responses", upstream.lastReq.URL.String()) + require.Equal(t, int64(65535), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) +} + +// TestForwardResponsesClampsOllamaCloudMaxOutputTokensForCodexClients 回归:原生 +// /v1/responses 的 Ollama Cloud clamp 必须对真实 Codex 客户端同样生效(clamp 曾位于 +// !isCodexCLI 块内被跳过,ollama.com 对 >65535 的 max_output_tokens 一律 400)。 +func TestForwardResponsesClampsOllamaCloudMaxOutputTokensForCodexClients(t *testing.T) { + gin.SetMode(gin.TestMode) + + responsesBody := []byte(`{"model":"deepseek-v4-flash","input":"Reply with exactly OK and nothing else.","max_output_tokens":256000,"stream":false}`) + + ollamaResponsesAccount := func() *Account { + account := ollamaUpstreamTestAccount(PlatformDeepseek, 336) + account.Credentials["api_protocol"] = APIProtocolResponses + return account + } + officialResponsesAccount := func() *Account { + account := officialDeepSeekTestAccount(337) + account.Credentials["api_protocol"] = APIProtocolResponses + return account + } + codexUA := func(c *gin.Context) { + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.55.0 (Ubuntu 24.04; x86_64)") + } + codexOriginator := func(c *gin.Context) { c.Request.Header.Set("originator", "codex-tui") } + + tests := []struct { + name string + account *Account + setHeaders func(*gin.Context) + forceCodexCLI bool + wantCap int64 + wantURL string + }{ + { + name: "codex UA is clamped", + account: ollamaResponsesAccount(), + setHeaders: codexUA, + wantCap: 65535, + wantURL: "https://ollama.com/v1/responses", + }, + { + name: "codex originator is clamped", + account: ollamaResponsesAccount(), + setHeaders: codexOriginator, + wantCap: 65535, + wantURL: "https://ollama.com/v1/responses", + }, + { + name: "ForceCodexCLI is clamped", + account: ollamaResponsesAccount(), + forceCodexCLI: true, + wantCap: 65535, + wantURL: "https://ollama.com/v1/responses", + }, + { + // 非 Codex 正常路径对照:与既有用例行为一致,不回归。 + name: "non-codex control is clamped", + account: ollamaResponsesAccount(), + wantCap: 65535, + wantURL: "https://ollama.com/v1/responses", + }, + { + // 非 Ollama 上游:同样的 Codex 头不 clamp。 + name: "official deepseek with codex UA is untouched", + account: officialResponsesAccount(), + setHeaders: codexUA, + wantCap: 256000, + wantURL: "https://api.deepseek.com/responses", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + cfg := rawChatCompletionsTestConfig() + cfg.Gateway.ForceCodexCLI = test.forceCodexCLI + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} + c := adaptiveProtocolTestContext("/v1/responses", responsesBody) + if test.setHeaders != nil { + test.setHeaders(c) + } + _, err := svc.Forward(context.Background(), c, test.account, responsesBody) + require.Error(t, err) + require.Equal(t, test.wantURL, upstream.lastReq.URL.String()) + require.Equal(t, test.wantCap, gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int()) + }) + } +} diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index d2b49a2f9a..dfebb47a50 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -519,6 +519,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( forwardResult := &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, ResponseID: responseID, Usage: *usage, Model: reqModel, @@ -720,6 +721,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) + applyOpenCodeSessionHeader(c, account, targetURL, req.Header) // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后, // 保证不被覆盖丢失)。 applyOpenAICodexBetaFeatures(c, account, req.Header) @@ -2077,6 +2079,11 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( s.handleOpenAIStreamTerminalAccountSideEffects(c, account, dataBytes, failedMessage, resp.Header, mappedModel) bareErrorAccountSideEffectsPending = false } + if eventType == "response.failed" { + // The stream cannot be replayed after semantic output. Preserve the + // terminal event, while making the upstream failure queryable. + s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "stream_failed", dataBytes, failedMessage) + } } if !outputStarted { shouldFailover := false diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 1973bba56e..e5dca2d7b7 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -569,6 +569,75 @@ func TestOpenAIGatewayServiceRecordUsage_TimePricingUsesExplicitPricingAt(t *tes require.InDelta(t, baseCost*0.8, usageRepo.lastLog.ActualCost, 1e-12) require.InDelta(t, 0.8, usageRepo.lastLog.RateMultiplier, 1e-12) } + +func TestOpenAIGatewayServiceRecordUsage_DeepSeekAccountStatsUsesRequestPricingAtAndUpstreamModel(t *testing.T) { + for _, model := range []struct { + name string + offPeakCost float64 + }{ + {"deepseek-v4-flash", 1000*2.2e-7 + 500*6.6e-7 + 1000*7e-9}, + {"deepseek-v4-pro", 1000*6.6e-7 + 500*1.98e-6 + 1000*2.2e-8}, + } { + for _, slot := range []struct { + name string + pricingAt time.Time + multiplier float64 + }{ + {"peak", time.Date(2026, time.August, 24, 2, 0, 0, 0, time.UTC), 2}, + {"off_peak", time.Date(2026, time.August, 24, 12, 0, 0, 0, time.UTC), 1}, + } { + t.Run(model.name+"/"+slot.name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + groupID := int64(18) + cache := newEmptyChannelCache() + cache.channelByGroupID[groupID] = &Channel{ID: 1, Status: StatusActive} + cache.groupPlatform[groupID] = PlatformDeepseek + cache.loadedAt = time.Now() + svc.channelService = &ChannelService{} + svc.channelService.cache.Store(cache) + svc.resolver = NewModelPricingResolver(svc.channelService, svc.billingService) + alias := "customer-chat" + inputPrice, outputPrice, cachePrice := 1e-6, 2e-6, 1e-7 + group := &Group{ID: groupID, Platform: PlatformDeepseek, RateMultiplier: 0.8, + ModelPricing: []ChannelModelPricing{{ + Models: []string{alias}, BillingMode: BillingModeToken, + InputPrice: &inputPrice, OutputPrice: &outputPrice, CacheReadPrice: &cachePrice, + }}, + } + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_deepseek_account_stats_" + model.name + "_" + slot.name, + Model: alias, BillingModel: alias, UpstreamModel: model.name, + Usage: OpenAIUsage{InputTokens: 2000, OutputTokens: 500, CacheReadInputTokens: 1000}, + }, + APIKey: &APIKey{ID: 1008, GroupID: &groupID, Group: group}, + User: &User{ID: 2008}, Account: &Account{ID: 3008, Platform: PlatformDeepseek}, + PricingAt: slot.pricingAt, + ChannelUsageFields: ChannelUsageFields{OriginalModel: alias, BillingModelSource: BillingModelSourceRequested}, + }) + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + log := usageRepo.lastLog + require.Equal(t, alias, log.RequestedModel) + require.NotNil(t, log.UpstreamModel) + require.Equal(t, model.name, *log.UpstreamModel) + require.WithinDuration(t, time.Now(), log.CreatedAt, time.Minute) + require.False(t, log.CreatedAt.Equal(slot.pricingAt), "request pricing time must differ from record creation") + customerTotal := 1000*inputPrice + 500*outputPrice + 1000*cachePrice + require.InDelta(t, customerTotal, log.TotalCost, 1e-12) + require.InDelta(t, customerTotal*0.8, log.ActualCost, 1e-12) + require.Equal(t, 1, userRepo.deductCalls) + require.InDelta(t, customerTotal*0.8, userRepo.lastAmount, 1e-12) + require.NotNil(t, log.AccountStatsCost) + require.InDelta(t, model.offPeakCost*slot.multiplier, *log.AccountStatsCost, 1e-12, + "account cost must use the upstream model and historical PricingAt") + }) + } + } +} + func TestOpenAIGatewayServiceRecordUsage_IncludesEndpointMetadata(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} @@ -1466,9 +1535,8 @@ func TestNormalizeOpenAIServiceTier(t *testing.T) { t.Run("openai official tiers preserved", func(t *testing.T) { // OpenAI 官方文档定义的合法 tier 值都应被透传保留,避免因白名单过窄 - // 静默剥离客户端显式发送的合法字段。Codex 客户端只发 priority/flex, - // 所以扩大白名单对 Codex 流量零影响(见 codex-rs/core/src/client.rs)。 - for _, tier := range []string{"priority", "flex", "auto", "default", "scale"} { + // 静默剥离客户端显式发送的合法字段。Codex 会发 priority/flex/ultrafast。 + for _, tier := range []string{"priority", "flex", "auto", "default", "scale", "ultrafast"} { got := normalizeOpenAIServiceTier(tier) require.NotNil(t, got, "tier %q should not be normalized to nil", tier) require.Equal(t, tier, *got) @@ -1487,6 +1555,7 @@ func TestExtractOpenAIServiceTier(t *testing.T) { require.Equal(t, "auto", *extractOpenAIServiceTier(map[string]any{"service_tier": "auto"})) require.Equal(t, "default", *extractOpenAIServiceTier(map[string]any{"service_tier": "default"})) require.Equal(t, "scale", *extractOpenAIServiceTier(map[string]any{"service_tier": "scale"})) + require.Equal(t, "ultrafast", *extractOpenAIServiceTier(map[string]any{"service_tier": "ultrafast"})) require.Nil(t, extractOpenAIServiceTier(map[string]any{"service_tier": 1})) require.Nil(t, extractOpenAIServiceTier(nil)) } @@ -1497,6 +1566,7 @@ func TestExtractOpenAIServiceTierFromBody(t *testing.T) { require.Equal(t, "auto", *extractOpenAIServiceTierFromBody([]byte(`{"service_tier":"auto"}`))) require.Equal(t, "default", *extractOpenAIServiceTierFromBody([]byte(`{"service_tier":"default"}`))) require.Equal(t, "scale", *extractOpenAIServiceTierFromBody([]byte(`{"service_tier":"scale"}`))) + require.Equal(t, "ultrafast", *extractOpenAIServiceTierFromBody([]byte(`{"service_tier":"ultrafast"}`))) require.Nil(t, extractOpenAIServiceTierFromBody([]byte(`{"service_tier":"turbo"}`))) require.Nil(t, extractOpenAIServiceTierFromBody(nil)) } diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 46f69b54d6..af4825762b 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -18,24 +18,28 @@ import ( ) func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, error) { - if s.cfg != nil && !s.cfg.Security.URLAllowlist.Enabled { - normalized, err := urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) - if err != nil { - return "", fmt.Errorf("invalid base_url: %w", err) - } - return normalized, nil - } - normalized, err := urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{ - AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts, - RequireAllowlist: true, - AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts, - }) + normalized, err := s.validateOutboundURL(raw) if err != nil { return "", fmt.Errorf("invalid base_url: %w", err) } return normalized, nil } +// validateOutboundURL 按 security.url_allowlist 策略校验网关主动连接的出站 URL。 +func (s *OpenAIGatewayService) validateOutboundURL(raw string) (string, error) { + if s.cfg == nil { + return urlvalidator.ValidateURLFormat(raw, false) + } + if !s.cfg.Security.URLAllowlist.Enabled { + return urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP) + } + return urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{ + AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts, + RequireAllowlist: true, + AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts, + }) +} + // buildOpenAIResponsesURL 组装 OpenAI Responses 端点。 // - base 以 /v1 结尾:追加 /responses // - base 以其他版本段结尾(如 /v4):追加 /responses @@ -59,6 +63,9 @@ func shouldPreserveOpenAIResponsesNoneReasoningEffort(account *Account) bool { if account == nil { return false } + if account.IsOpenAIPassthroughEnabled() { + return true + } if account.IsOpenAIOAuthLike() { return true } @@ -1016,6 +1023,10 @@ func normalizeOpenAIResponsesReasoningMode(body []byte) ([]byte, bool, error) { if len(body) == 0 { return body, false, nil } + // Astra 的 reasoning.mode 与 reasoning.effort 是独立参数,不做兼容替换;非 Astra 维持旧 strip-mode/pro->max 行为。 + if isOpenAIGPT6AstraModel(gjson.GetBytes(body, "model").String()) { + return body, false, nil + } mode := gjson.GetBytes(body, "reasoning.mode") if !mode.Exists() || mode.Type != gjson.String { return body, false, nil @@ -1435,12 +1446,12 @@ func normalizeOpenAIServiceTier(raw string) *string { if value == "fast" { value = "priority" } - // 放过 OpenAI 官方文档定义的所有合法 tier 值:priority/flex/auto/default/scale。 - // 对 Codex 客户端零影响(Codex 只发 priority 或 flex,见 codex-rs/core/src/client.rs), - // 但能让直连 OpenAI SDK 的用户透传 auto/default/scale 以便抓包/调试。 - // 真未知值仍返回 nil,由 normalizeResponsesBodyServiceTier 从 body 中删除。 + // 放过 OpenAI 官方文档定义的合法 tier 值,以及 Codex/API 新增的 ultrafast。 + // Codex 客户端会发 priority、flex 或 ultrafast;直连 OpenAI SDK 的用户还会 + // 透传 auto/default/scale。真未知值仍返回 nil,由 + // normalizeResponsesBodyServiceTier 从 body 中删除。 switch value { - case "priority", "flex", "auto", "default", "scale": + case "priority", "flex", "auto", "default", "scale", OpenAIFastTierUltrafast: return &value default: return nil @@ -1457,7 +1468,7 @@ type ErrInvalidOpenAIServiceTier struct { } func (e *ErrInvalidOpenAIServiceTier) Error() string { - return fmt.Sprintf("invalid service_tier %q: must be one of auto, default, fast, flex, priority, scale", e.Value) + return fmt.Sprintf("invalid service_tier %q: must be one of auto, default, fast, flex, priority, scale, ultrafast", e.Value) } const invalidOpenAIServiceTierValueMaxLen = 64 @@ -1475,7 +1486,7 @@ func boundInvalidOpenAIServiceTierValue(raw string) string { // - absent / null → valid, returns "" (field omitted keeps current behavior) // - "fast" → normalized to "priority" (the two are equivalent; the canonical // value is what reaches the OpenAI upstream) -// - "priority" / "flex" / "auto" / "default" / "scale" → valid, returned as-is +// - "priority" / "flex" / "auto" / "default" / "scale" / "ultrafast" → valid, returned as-is // - an explicitly present non-string value, an empty string, or any other // unknown value → *ErrInvalidOpenAIServiceTier (handler maps to HTTP 400), // matching OpenAI's enum validation semantics @@ -2162,7 +2173,7 @@ func normalizeOpenAIReasoningEffortForModel(raw, model string) string { // supportsOpenAIReasoningEffortMax reports model families whose upstream scale // has a distinct max level. Other models keep the legacy max -> xhigh behavior. func supportsOpenAIReasoningEffortMax(model string) bool { - if isOpenAIGPT56Model(model) { + if isOpenAIGPT6AstraModel(model) || isOpenAIGPT56Model(model) { return true } diff --git a/backend/internal/service/openai_gateway_request_body_reasoning_test.go b/backend/internal/service/openai_gateway_request_body_reasoning_test.go index 4805eeeb24..013aaa2531 100644 --- a/backend/internal/service/openai_gateway_request_body_reasoning_test.go +++ b/backend/internal/service/openai_gateway_request_body_reasoning_test.go @@ -331,6 +331,23 @@ func TestFilterOpenAIResponsesNoneReasoningEffortForAccount(t *testing.T) { } } +func TestFilterOpenAIResponsesNoneReasoningEffortForAccount_APIKeyAutomaticPassthroughPreservesRequest(t *testing.T) { + body := []byte(`{"model":"qwen3.8-27b","input":"hi","max_output_tokens":20,"reasoning":{"effort":"none"},"presence_penalty":1.5}`) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://compat.example/v1", + }, + Extra: map[string]any{"openai_passthrough": true}, + } + + got, err := filterOpenAIResponsesNoneReasoningEffortForAccount(account, body) + + require.NoError(t, err) + require.JSONEq(t, string(body), string(got)) +} + // Lite 工具迁移到 input[].additional_tools 后,仍应按有工具请求处理。 func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAdditionalTools(t *testing.T) { liteBody := []byte(`{"input":[{"type":"message","role":"user","content":"hi"},{"type":"additional_tools","tools":[{"type":"function","name":"spawn_agent"}]}],"parallel_tool_calls":false}`) diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 4ee9dca2b1..d5b35e8585 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -561,6 +561,12 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. s.handleOpenAIStreamTerminalAccountSideEffects(c, account, dataBytes, failedMessage, resp.Header, mappedModel) bareErrorAccountSideEffectsPending = false } + if eventType == "response.failed" { + // Once semantic output is committed, failover replay is unsafe. Keep + // the terminal event on the existing stream, but retain the upstream + // request ID and payload for operations diagnostics. + s.recordOpenAIStreamUpstreamError(c, account, false, upstreamRequestID, "stream_failed", dataBytes, failedMessage) + } } if !outputStarted { shouldFailover := false diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index 6a405d65b2..274f0f09eb 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -287,6 +287,7 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(&usage), Model: originalModel, BillingModel: billingModel, @@ -340,6 +341,7 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( resultWithUsage := func() *OpenAIForwardResult { return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: claudeUsageToOpenAIUsage(&usage), Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index e5ba05d5f9..67d3e2b4e7 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -87,6 +87,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } return nil, err } + // /v1/responses 降级到 raw CC 的出站与 forwardAsRawChatCompletions 共用同一个 + // 独立 Ollama Cloud token 钩子;chatReq.Model 已是模型映射后的 upstreamModel。 + chatBody = clampOllamaCloudUpstreamMaxTokens(account, chatBody) // Keep the final outbound tier for usage-time reconciliation. A policy // filter that removes the field therefore leaves this nil. serviceTier := extractOpenAIServiceTierFromBody(chatBody) @@ -154,6 +157,7 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -225,6 +229,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( if scan.Err != nil { return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: scan.Usage, Model: originalModel, BillingModel: billingModel, @@ -240,6 +245,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( if err := state.ValidateToolCallArguments(); err != nil { return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: scan.Usage, Model: originalModel, BillingModel: billingModel, @@ -271,6 +277,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( return &OpenAIForwardResult{ RequestID: requestID, + UpstreamHeaders: resp.Header, Usage: scan.Usage, Model: originalModel, BillingModel: billingModel, diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index a90bad3e25..a980195486 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -292,7 +292,7 @@ func (s *OpenAIGatewayService) SelectAccountForTokenCount( // handler 调度入口仍需导出,保持导出名。) func NormalizeOpenAICompatiblePlatform(platform string) string { switch platform { - case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek: + case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: return platform default: return PlatformOpenAI diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 25436fdf4c..df7e66eb87 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -233,8 +233,10 @@ type OpenAIUsage struct { type OpenAIForwardResult struct { RequestID string ResponseID string - Usage OpenAIUsage - Model string // 原始模型(用于响应和日志显示) + // UpstreamHeaders 是直接上游的响应头,用于按账户配置解析上游请求标识。 + UpstreamHeaders http.Header + Usage OpenAIUsage + Model string // 原始模型(用于响应和日志显示) // BillingModel is the model used for cost calculation. // When non-empty, CalculateCost uses this instead of Model. // This is set by the Anthropic Messages conversion path where @@ -481,7 +483,7 @@ type OpenAIGatewayService struct { openaiWSRetryMetrics openAIWSRetryMetrics responseHeaderFilter *responseheaders.CompiledHeaderFilter codexSnapshotThrottle *accountWriteThrottle - codexModelsManifestCache codexModelsManifestCache + openAIModelsCache openAIModelsCache openaiCompatSessionResponses sync.Map openaiCompatAnthropicDigestSessions sync.Map // openaiCodexTurnStateOrigins: 下游会话 seed → openAICodexTurnStateOrigin, diff --git a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go index 850c5d0ce8..079153b2a6 100644 --- a/backend/internal/service/openai_gateway_service_codex_cli_only_test.go +++ b/backend/internal/service/openai_gateway_service_codex_cli_only_test.go @@ -356,9 +356,9 @@ func TestShouldFailoverOpenAIUpstreamResponseContextWindow502(t *testing.T) { svc := &OpenAIGatewayService{} body := []byte(`{"error":{"message":"Your input exceeds the context window of this model. Please adjust your input and try again.","type":"upstream_error","code":null}}`) - require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadGateway, "", body)) - require.True(t, svc.shouldFailoverOpenAIUpstreamResponse(http.StatusBadGateway, "temporary upstream outage", []byte(`{"error":{"message":"temporary upstream outage"}}`))) - require.True(t, svc.shouldFailoverOpenAIUpstreamResponse( + require.False(t, svc.shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(), http.StatusBadGateway, "", body)) + require.True(t, svc.shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(), http.StatusBadGateway, "temporary upstream outage", []byte(`{"error":{"message":"temporary upstream outage"}}`))) + require.True(t, svc.shouldFailoverOpenAIUpstreamResponse(newOpenAIUpstreamErrorTestAccount(), http.StatusBadGateway, "temporary upstream outage", []byte(`{"error":{"message":"temporary upstream outage"},"echo":"context_length_exceeded"}`), diff --git a/backend/internal/service/openai_gateway_upstream_errors.go b/backend/internal/service/openai_gateway_upstream_errors.go index db2cd5d9c0..b5985a226e 100644 --- a/backend/internal/service/openai_gateway_upstream_errors.go +++ b/backend/internal/service/openai_gateway_upstream_errors.go @@ -250,7 +250,7 @@ func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool } } -func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode int, upstreamMsg string, upstreamBody []byte) bool { +func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(account *Account, statusCode int, upstreamMsg string, upstreamBody []byte) bool { // cyber_policy is request-scoped even when an intermediary wraps the // provider response in a retryable 5xx status. Never punish or rotate the // selected credential for it. @@ -266,12 +266,46 @@ func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(statusCode i if isOpenAIRequestBodyTooLargeError(statusCode, upstreamMsg, upstreamBody) { return true } + // A missing model is account/provider availability, not a malformed client + // request. Keep this unconditional exception inside the OpenAI-compatible + // gateway and require an eligible account so Anthropic/Gemini paths retain + // their existing opt-in 400 behavior. + // A bare forwarding service has no account-selection owner to consume a + // failover sentinel. In that mode (used by direct/single-account callers), + // preserve the deterministic upstream 400 instead of returning an unwritten + // retry signal. Managed gateway instances always have an account repository; + // their handler can exclude this account and actually select another one. + if s != nil && s.accountRepo != nil && account != nil && account.IsOpenAICompatible() && statusCode == http.StatusBadRequest && + isOpenAICompatibleModelNotFound400(upstreamBody) { + return true + } if s.shouldFailoverUpstreamError(statusCode) { return true } return isOpenAITransientProcessingError(statusCode, upstreamMsg, upstreamBody) } +func isOpenAICompatibleModelNotFound400(respBody []byte) bool { + code := strings.TrimSpace(extractUpstreamErrorCode(respBody)) + if code != "" { + return strings.EqualFold(code, "model_not_found") + } + + msg := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(respBody))) + if msg == "" && !gjson.ValidBytes(respBody) { + msg = strings.ToLower(strings.TrimSpace(string(respBody))) + } + return strings.Contains(msg, "unknown provider for model") || + strings.Contains(msg, "model not found") || + strings.Contains(msg, "model is not supported") +} + +// IsOpenAICompatibleModelNotFound400 reports whether an OpenAI-compatible 400 +// is an account-specific missing-model response eligible for failover. +func IsOpenAICompatibleModelNotFound400(respBody []byte) bool { + return isOpenAICompatibleModelNotFound400(respBody) +} + // OpenAIRequestBodyTooLargeClientMessage is the fixed downstream message used // after all account-specific request body limit failovers are exhausted. const OpenAIRequestBodyTooLargeClientMessage = "Request payload is too large" diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index cb998b55fa..4570ed1631 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -371,6 +371,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec APIKeyID: apiKey.ID, AccountID: account.ID, RequestID: requestID, + UpstreamRequestID: usageUpstreamRequestIDPtr(account, result.UpstreamHeaders, result.OpenAIWSMode), Model: result.Model, RequestedModel: requestedModel, UpstreamModel: optionalTrimmedStringPtr(result.UpstreamModel), @@ -471,7 +472,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec if apiKey.GroupID != nil { applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService, account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model, - tokens, cost.TotalCost, + tokens, cost.TotalCost, pricingAt, ) } @@ -610,6 +611,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( pricingAt, tokens, serviceTier, + optionalStringValue(result.ReasoningEffort), longContextBillingGate, ) if err == nil { @@ -703,6 +705,7 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( pricingAt time.Time, tokens UsageTokens, serviceTier string, + reasoningEffort string, longContextBillingGate *bool, ) (*CostBreakdown, error) { if s.resolver != nil && apiKey.Group != nil { @@ -710,17 +713,21 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( return s.billingService.CalculateCostUnified(CostInput{ Ctx: ctx, Model: billingModel, GroupID: &gid, Group: apiKey.Group, Tokens: tokens, RequestCount: 1, RateMultiplier: multiplier, PricingAt: pricingAt, - ServiceTier: serviceTier, Resolver: s.resolver, + ServiceTier: serviceTier, ReasoningEffort: reasoningEffort, Resolver: s.resolver, LongContextBillingEnabled: longContextBillingGate, }) } - return s.billingService.calculateCostWithServiceTierPolicy( + breakdown, err := s.billingService.calculateCostWithServiceTierPolicy( billingModel, tokens, multiplier, serviceTier, longContextBillingGate == nil || *longContextBillingGate, ) + if err == nil { + applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(billingModel, reasoningEffort, nil)) + } + return breakdown, err } func (s *OpenAIGatewayService) calculateOpenAIImageCost( diff --git a/backend/internal/service/openai_gpt56_max_test.go b/backend/internal/service/openai_gpt56_max_test.go index 9dc865295c..0b0c8e6fac 100644 --- a/backend/internal/service/openai_gpt56_max_test.go +++ b/backend/internal/service/openai_gpt56_max_test.go @@ -2,6 +2,8 @@ package service import ( "context" + "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -14,13 +16,55 @@ import ( "github.com/tidwall/gjson" ) -func TestNormalizeOpenAIReasoningEffortForGPT56(t *testing.T) { +func TestAstraForwardPreservesMaxInPayloadAndUsage(t *testing.T) { + for _, requestedModel := range []string{"gpt-6-astra", "astra-public"} { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/stream=%t", requestedModel, stream), func(t *testing.T) { + response := `{"id":"resp_astra","model":"gpt-6-astra","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":2}}` + contentType := "application/json" + if stream { + response = "data: {\"type\":\"response.completed\",\"response\":" + response + "}\n\ndata: [DONE]\n\n" + contentType = "text/event-stream" + } + upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(response))}} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := rawGPT56ResponsesAPIKeyAccount(requestedModel, "gpt-6-astra") + body, err := json.Marshal(map[string]any{"model": requestedModel, "stream": stream, "input": "hello", "reasoning": map[string]string{"effort": "max"}}) + require.NoError(t, err) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(string(body))) + SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "gpt-6-astra", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) + require.NotNil(t, result.ReasoningEffort) + require.Equal(t, "max", *result.ReasoningEffort) + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}} + usageService := newOpenAIRecordUsageServiceWithBillingRepoForTest(usageRepo, billingRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + err = usageService.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: result, Account: account, User: &User{ID: 21}, APIKey: &APIKey{ID: 22, Group: &Group{RateMultiplier: 1}}, + }) + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, "max", *usageRepo.lastLog.ReasoningEffort) + require.Equal(t, "max", *usageRepo.lastLog.RequestedReasoningEffort) + }) + } + } +} + +func TestNormalizeOpenAIReasoningEffortForMaxCapableModels(t *testing.T) { tests := []struct { name string raw string model string want string }{ + {name: "Astra 保留 max", raw: "max", model: "gpt-6-astra", want: "max"}, {name: "Sol 保留 max", raw: "max", model: "gpt-5.6-sol", want: "max"}, {name: "Terra 保留 max", raw: "max", model: "openai/gpt-5.6-terra", want: "max"}, {name: "Luna 后缀保留 max", raw: "max", model: "gpt-5.6-luna-2026-07-09", want: "max"}, diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index bfbc11ac27..cf39931a91 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -650,7 +650,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( resp.Body = io.NopCloser(bytes.NewReader(respBody)) upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) { + if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMsg, respBody) { appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), ProxyName: opsUpstreamProxyName(account), @@ -686,6 +686,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( if streamCount > 0 { return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: streamUsage, Model: requestModel, UpstreamModel: upstreamModel, @@ -707,6 +708,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( firstTokenMs = ttft return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: usage, Model: requestModel, UpstreamModel: upstreamModel, @@ -720,7 +722,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( ImageOutputSizes: imageOutputSizes, }, nil } else { - nonStreamUsage, nonStreamCount, nonStreamSizes, err := s.handleOpenAIImagesNonStreamingResponse(resp, c) + nonStreamUsage, nonStreamCount, nonStreamSizes, err := s.handleOpenAIImagesNonStreamingResponse(upstreamCtx, resp, c, account, parsed) if err != nil { return nil, err } @@ -730,6 +732,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( } return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: usage, Model: requestModel, UpstreamModel: upstreamModel, @@ -891,11 +894,18 @@ func cloneMultipartHeader(src textproto.MIMEHeader) textproto.MIMEHeader { return dst } -func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse(resp *http.Response, c *gin.Context) (OpenAIUsage, int, []string, error) { +func (s *OpenAIGatewayService) handleOpenAIImagesNonStreamingResponse( + ctx context.Context, + resp *http.Response, + c *gin.Context, + account *Account, + parsed *OpenAIImagesRequest, +) (OpenAIUsage, int, []string, error) { body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) if err != nil { return OpenAIUsage{}, 0, nil, err } + body = s.backfillOpenAIImagesB64JSON(ctx, account, parsed, body) responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) contentType := "application/json" if s.cfg != nil && !s.cfg.Security.ResponseHeaders.Enabled { diff --git a/backend/internal/service/openai_images_b64_backfill.go b/backend/internal/service/openai_images_b64_backfill.go new file mode 100644 index 0000000000..3210c0fcbe --- /dev/null +++ b/backend/internal/service/openai_images_b64_backfill.go @@ -0,0 +1,176 @@ +package service + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// AccountExtraImagesURLToB64JSON 是账户 extra 中的开关键。开启后,Images 端点的非流式响应里 +// b64_json 缺失或为空但 url 非空的图片项,由网关下载 url 内容并以 base64 回填到 b64_json。 +const AccountExtraImagesURLToB64JSON = "images_url_to_b64_json" + +// openAIImageURLDownloadTimeout 是单张图片 url 下载的超时上限。 +const openAIImageURLDownloadTimeout = 60 * time.Second + +// ImagesURLToB64JSONEnabled 返回账户是否开启了 url 转 b64_json 回填。 +func ImagesURLToB64JSONEnabled(account *Account) bool { + return account != nil && account.getExtraBool(AccountExtraImagesURLToB64JSON) +} + +// backfillOpenAIImagesB64JSON 对 Images 端点的非流式响应做 url 转 b64_json 回填。 +// +// 只处理 data[i].b64_json 缺失或为空且 url 非空的项;url 字段原样保留。 +// 单项失败仅记日志并保留该项原样,响应整体照常返回。 +// 客户端显式要求 response_format=url 时不做回填。 +func (s *OpenAIGatewayService) backfillOpenAIImagesB64JSON( + ctx context.Context, + account *Account, + parsed *OpenAIImagesRequest, + body []byte, +) []byte { + if !ImagesURLToB64JSONEnabled(account) { + return body + } + if parsed != nil && parsed.ResponseFormat == "url" { + return body + } + if len(body) == 0 || !gjson.ValidBytes(body) { + return body + } + items := gjson.GetBytes(body, "data") + if !items.IsArray() { + return body + } + for index, item := range items.Array() { + if !item.IsObject() { + continue + } + if strings.TrimSpace(item.Get("b64_json").String()) != "" { + continue + } + rawURL := strings.TrimSpace(item.Get("url").String()) + if rawURL == "" { + continue + } + encoded, err := s.fetchOpenAIImageURLBase64(ctx, account, rawURL) + if err != nil { + logger.LegacyPrintf( + "service.openai_gateway", + "[OpenAI] Images b64_json backfill skipped account_id=%d index=%d err=%s", + account.ID, + index, + sanitizeUpstreamErrorMessage(err.Error()), + ) + continue + } + updated, err := sjson.SetBytes(body, fmt.Sprintf("data.%d.b64_json", index), encoded) + if err != nil { + logger.LegacyPrintf( + "service.openai_gateway", + "[OpenAI] Images b64_json backfill skipped account_id=%d index=%d err=%s", + account.ID, + index, + sanitizeUpstreamErrorMessage(err.Error()), + ) + continue + } + body = updated + } + return body +} + +// fetchOpenAIImageURLBase64 取得图片 url 内容的标准 base64 编码。 +// data: 形式的 url 直接取其 base64 载荷;其余 url 先沿用 base_url 的出站 URL 策略校验, +// 再无条件拒绝回环、私网、链路本地等目的地(含重定向的每一跳),经账户代理下载, +// 大小上限与 OAuth 路径的单图下载一致,且前 512 字节须嗅探为 png/jpeg/webp/gif。 +func (s *OpenAIGatewayService) fetchOpenAIImageURLBase64(ctx context.Context, account *Account, rawURL string) (string, error) { + if strings.HasPrefix(strings.ToLower(rawURL), "data:") { + if encoded := normalizeOpenAIImageBase64(rawURL); encoded != "" { + return encoded, nil + } + return "", errors.New("data url payload is not valid base64") + } + if s == nil || s.httpUpstream == nil { + return "", errors.New("http upstream is not configured") + } + downloadURL, err := s.validateOutboundURL(rawURL) + if err != nil { + return "", fmt.Errorf("invalid image url: %w", err) + } + if err := rejectPrivateImageHost(downloadURL); err != nil { + return "", err + } + ctx, cancel := context.WithTimeout(WithHTTPUpstreamPublicHostsOnly(ctx), openAIImageURLDownloadTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return "", fmt.Errorf("build image download request: %w", err) + } + req.Header.Set("Accept", "image/*,*/*;q=0.8") + proxyURL := "" + if account.ProxyID != nil && account.Proxy != nil { + proxyURL = account.Proxy.URL() + } + resp, err := s.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency) + if err != nil { + return "", fmt.Errorf("download image: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "", fmt.Errorf("download image: unexpected status %d", resp.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, openAIImageMaxDownloadBytes+1)) + if err != nil { + return "", fmt.Errorf("read image body: %w", err) + } + if int64(len(data)) > openAIImageMaxDownloadBytes { + return "", fmt.Errorf("downloaded image exceeds %d bytes", openAIImageMaxDownloadBytes) + } + if len(data) == 0 { + return "", errors.New("download image: empty body") + } + if !isBackfillImageContent(data) { + return "", errors.New("download image: content is not an allowed image format") + } + return base64.StdEncoding.EncodeToString(data), nil +} + +// rejectPrivateImageHost 拒绝主机为 localhost 或回环、私网、链路本地、未指定地址字面量的下载 URL。 +// 不受 security.url_allowlist 配置影响;域名的解析结果与重定向的每一跳由上游客户端按 +// WithHTTPUpstreamPublicHostsOnly 标记校验。 +func rejectPrivateImageHost(downloadURL string) error { + parsed, err := url.Parse(downloadURL) + if err != nil { + return fmt.Errorf("invalid image url: %w", err) + } + if host := parsed.Hostname(); urlvalidator.IsBlockedHost(host) { + return fmt.Errorf("image url host is not allowed: %s", host) + } + return nil +} + +// openAIImageBackfillContentTypes 是允许回填的图片格式,以字节嗅探结果为准,响应头不作为依据。 +var openAIImageBackfillContentTypes = map[string]struct{}{ + "image/png": {}, + "image/jpeg": {}, + "image/webp": {}, + "image/gif": {}, +} + +// isBackfillImageContent 报告 data 的前 512 字节是否嗅探为允许回填的图片格式。 +func isBackfillImageContent(data []byte) bool { + _, ok := openAIImageBackfillContentTypes[detectedImageContentType(data)] + return ok +} diff --git a/backend/internal/service/openai_images_b64_backfill_test.go b/backend/internal/service/openai_images_b64_backfill_test.go new file mode 100644 index 0000000000..bbaa3f088b --- /dev/null +++ b/backend/internal/service/openai_images_b64_backfill_test.go @@ -0,0 +1,381 @@ +package service + +import ( + "bytes" + "context" + "encoding/base64" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// 8 字节 PNG 魔数足以让字节嗅探判定为 image/png。 +var b64BackfillPNGBytes = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0} + +func b64BackfillImageResponse(status int, contentType string, payload []byte) *http.Response { + header := http.Header{} + if contentType != "" { + header.Set("Content-Type", contentType) + } + return &http.Response{ + StatusCode: status, + Header: header, + Body: io.NopCloser(bytes.NewReader(payload)), + } +} + +func b64BackfillAccount(enabled bool) *Account { + account := &Account{ + ID: 7, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://relay.example.com/v1", + }, + } + if enabled { + account.Extra = map[string]any{AccountExtraImagesURLToB64JSON: true} + } + return account +} + +func TestImagesURLToB64JSONEnabled(t *testing.T) { + require.False(t, ImagesURLToB64JSONEnabled(nil)) + require.False(t, ImagesURLToB64JSONEnabled(&Account{})) + require.False(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: "true"}})) + require.False(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: false}})) + require.True(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: true}})) +} + +func TestBackfillOpenAIImagesB64JSON(t *testing.T) { + wantB64 := base64.StdEncoding.EncodeToString(b64BackfillPNGBytes) + + tests := []struct { + name string + enabled bool + parsed *OpenAIImagesRequest + body string + upstream *httpUpstreamRecorder + wantB64 []string + wantDownloads int + }{ + { + name: "开关关闭时原样返回", + enabled: false, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{""}, + wantDownloads: 0, + }, + { + name: "缺少 b64_json 且有 url 时下载回填并保留 url", + enabled: true, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png","revised_prompt":"a cat"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{wantB64}, + wantDownloads: 1, + }, + { + name: "b64_json 为空串时同样回填", + enabled: true, + body: `{"created":1,"data":[{"b64_json":"","url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{wantB64}, + wantDownloads: 1, + }, + { + name: "b64_json 已有值时不下载", + enabled: true, + body: `{"created":1,"data":[{"b64_json":"aW1n","url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{"aW1n"}, + wantDownloads: 0, + }, + { + name: "多项混合时只回填缺失项", + enabled: true, + body: `{"created":1,"data":[{"b64_json":"aW1n"},{"url":"https://cdn.example.com/b.png"},{"revised_prompt":"none"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{"aW1n", wantB64, ""}, + wantDownloads: 1, + }, + { + name: "data URL 直接取载荷不下载", + enabled: true, + body: `{"created":1,"data":[{"url":"data:image/png;base64,` + wantB64 + `"}]}`, + upstream: &httpUpstreamRecorder{}, + wantB64: []string{wantB64}, + wantDownloads: 0, + }, + { + name: "客户端显式要求 url 时不回填", + enabled: true, + parsed: &OpenAIImagesRequest{ResponseFormat: "url"}, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{""}, + wantDownloads: 0, + }, + { + name: "下载非 2xx 时保留原样", + enabled: true, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusNotFound, "text/plain", []byte("gone"))}, + wantB64: []string{""}, + wantDownloads: 1, + }, + { + name: "下载内容不是图片时保留原样", + enabled: true, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "text/html", []byte("login required"))}, + wantB64: []string{""}, + wantDownloads: 1, + }, + { + name: "响应头未声明图片类型但字节嗅探为图片时回填", + enabled: true, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "application/octet-stream", b64BackfillPNGBytes)}, + wantB64: []string{wantB64}, + wantDownloads: 1, + }, + { + name: "响应头声明图片但字节不是图片时保留原样", + enabled: true, + body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/svg+xml", []byte(``))}, + wantB64: []string{""}, + wantDownloads: 1, + }, + { + name: "非 http(s) 协议的 url 不下载", + enabled: true, + body: `{"created":1,"data":[{"url":"ftp://cdn.example.com/a.png"}]}`, + upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, + wantB64: []string{""}, + wantDownloads: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: tt.upstream} + got := svc.backfillOpenAIImagesB64JSON(context.Background(), b64BackfillAccount(tt.enabled), tt.parsed, []byte(tt.body)) + require.True(t, gjson.ValidBytes(got)) + items := gjson.GetBytes(got, "data").Array() + require.Len(t, items, len(tt.wantB64)) + for i, want := range tt.wantB64 { + require.Equal(t, want, items[i].Get("b64_json").String(), "data.%d.b64_json", i) + } + require.Len(t, tt.upstream.requests, tt.wantDownloads) + // 除 b64_json 外的字段必须原样保留。 + original := gjson.Parse(tt.body) + original.Get("data").ForEach(func(key, item gjson.Result) bool { + item.ForEach(func(field, value gjson.Result) bool { + if field.String() == "b64_json" { + return true + } + require.Equal(t, value.Raw, gjson.GetBytes(got, "data."+key.String()+"."+field.String()).Raw) + return true + }) + return true + }) + require.Equal(t, original.Get("created").Raw, gjson.GetBytes(got, "created").Raw) + }) + } +} + +func TestBackfillOpenAIImagesB64JSON_DownloadRequestShape(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := b64BackfillAccount(true) + proxyID := int64(3) + account.ProxyID = &proxyID + account.Proxy = &Proxy{Protocol: "http", Host: "127.0.0.1", Port: 7890} + + body := []byte(`{"created":1,"data":[{"url":"https://cdn.example.com/a.png?sig=abc"}]}`) + got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, body) + require.Equal(t, base64.StdEncoding.EncodeToString(b64BackfillPNGBytes), gjson.GetBytes(got, "data.0.b64_json").String()) + + require.Len(t, upstream.requests, 1) + req := upstream.requests[0] + require.Equal(t, http.MethodGet, req.Method) + require.Equal(t, "https://cdn.example.com/a.png?sig=abc", req.URL.String()) + require.Equal(t, "http://127.0.0.1:7890", upstream.lastProxyURL) + _, hasDeadline := req.Context().Deadline() + require.True(t, hasDeadline) + // 目的地与重定向各跳都必须解析到公网地址;重定向本身保持跟随。 + require.True(t, HTTPUpstreamPublicHostsOnly(req.Context())) + require.False(t, HTTPUpstreamRedirectsDisabled(req.Context())) +} + +func TestBackfillOpenAIImagesB64JSON_RejectsPrivateHosts(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)} + cfg := &config.Config{} + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} + account := b64BackfillAccount(true) + + for _, rawURL := range []string{ + "http://localhost:8080/api/v1/admin/accounts", + "http://Foo.LocalHost/a.png", + "http://127.0.0.1:8080/a.png", + "https://127.0.0.1/a.png", + "http://[::1]:8080/a.png", + "http://10.0.0.5/a.png", + "http://172.16.0.9:9000/bucket/a.png", + "http://192.168.1.10:9000/bucket/a.png", + "http://169.254.169.254/latest/meta-data/", + "http://0.0.0.0:8080/a.png", + "http://[fe80::1]/a.png", + "http://[::ffff:127.0.0.1]/a.png", + } { + body := `{"created":1,"data":[{"url":"` + rawURL + `"}]}` + got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(body)) + require.Equal(t, body, string(got), "url=%s", rawURL) + } + require.Empty(t, upstream.requests, "private destinations must never be requested") + + // 同一配置下公网主机照常下载,证明拒绝依据是目的地而非协议。 + got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(`{"created":1,"data":[{"url":"http://cdn.example.com/a.png"}]}`)) + require.Equal(t, base64.StdEncoding.EncodeToString(b64BackfillPNGBytes), gjson.GetBytes(got, "data.0.b64_json").String()) + require.Len(t, upstream.requests, 1) +} + +func TestIsBackfillImageContent(t *testing.T) { + webp := append([]byte("RIFF\x24\x00\x00\x00WEBPVP8 "), make([]byte, 8)...) + tests := []struct { + name string + data []byte + want bool + }{ + {name: "png", data: b64BackfillPNGBytes, want: true}, + {name: "jpeg", data: []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00}, want: true}, + {name: "webp", data: webp, want: true}, + {name: "gif", data: []byte("GIF89a\x01\x00\x01\x00"), want: true}, + {name: "bmp 不在允许列表", data: []byte("BM\x00\x00\x00\x00\x00\x00\x00\x00"), want: false}, + {name: "ico 不在允许列表", data: []byte{0x00, 0x00, 0x01, 0x00, 0x01, 0x00}, want: false}, + {name: "svg 文本", data: []byte(``), want: false}, + {name: "html", data: []byte("login required"), want: false}, + {name: "json", data: []byte(`{"error":"unauthorized"}`), want: false}, + {name: "空", data: nil, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, isBackfillImageContent(tt.data)) + }) + } +} + +func TestBackfillOpenAIImagesB64JSON_NonObjectBodies(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := b64BackfillAccount(true) + + for _, body := range []string{``, `not json`, `{"created":1}`, `{"created":1,"data":{}}`, `{"created":1,"data":[]}`, `{"created":1,"data":["x"]}`} { + got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(body)) + require.Equal(t, body, string(got), "body=%q", body) + } + require.Empty(t, upstream.requests) +} + +func TestOpenAIGatewayServiceForwardImages_APIKeyBackfillsB64JSONFromURL(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024"}`) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("api_key", &APIKey{ID: 42}) + + svc := &OpenAIGatewayService{cfg: &config.Config{}} + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + + upstream := &httpUpstreamRecorder{ + responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + "X-Request-Id": []string{"req_img_url"}, + }, + Body: io.NopCloser(strings.NewReader( + `{"created":1710000000,"data":[{"url":"https://cdn.example.com/cat.png","revised_prompt":"a cat"}],"usage":{"input_tokens":10,"output_tokens":20}}`, + )), + }, + b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes), + }, + } + svc.httpUpstream = upstream + + result, err := svc.ForwardImages(context.Background(), c, b64BackfillAccount(true), body, parsed, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, result.ImageCount) + require.False(t, result.Stream) + + require.Len(t, upstream.requests, 2) + require.Equal(t, http.MethodPost, upstream.requests[0].Method) + require.Equal(t, "https://relay.example.com/v1/images/generations", upstream.requests[0].URL.String()) + require.Equal(t, http.MethodGet, upstream.requests[1].Method) + require.Equal(t, "https://cdn.example.com/cat.png", upstream.requests[1].URL.String()) + require.True(t, HTTPUpstreamPublicHostsOnly(upstream.requests[1].Context())) + + require.Equal(t, http.StatusOK, rec.Code) + wantB64 := base64.StdEncoding.EncodeToString(b64BackfillPNGBytes) + require.Equal(t, wantB64, gjson.Get(rec.Body.String(), "data.0.b64_json").String()) + require.Equal(t, "https://cdn.example.com/cat.png", gjson.Get(rec.Body.String(), "data.0.url").String()) + require.Equal(t, "a cat", gjson.Get(rec.Body.String(), "data.0.revised_prompt").String()) + require.Equal(t, int64(1710000000), gjson.Get(rec.Body.String(), "created").Int()) +} + +func TestOpenAIGatewayServiceForwardImages_APIKeyLeavesURLOnlyResponseWhenDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024"}`) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("api_key", &APIKey{ID: 42}) + + svc := &OpenAIGatewayService{cfg: &config.Config{}} + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + + upstreamBody := `{"created":1710000000,"data":[{"url":"https://cdn.example.com/cat.png"}]}` + upstream := &httpUpstreamRecorder{ + responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }, + b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes), + }, + } + svc.httpUpstream = upstream + + result, err := svc.ForwardImages(context.Background(), c, b64BackfillAccount(false), body, parsed, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, result.ImageCount) + + require.Len(t, upstream.requests, 1) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, upstreamBody, rec.Body.String()) +} diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 8dd850098f..a0729dc97c 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -1856,7 +1856,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( resp.Body = io.NopCloser(bytes.NewReader(respBody)) upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) - if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) { + if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMsg, respBody) { appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ ProxyID: opsUpstreamProxyID(account), ProxyName: opsUpstreamProxyName(account), @@ -1899,6 +1899,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( if imageCount > 0 { return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: usage, Model: requestModel, UpstreamModel: requestModel, @@ -1943,6 +1944,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( } return &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), + UpstreamHeaders: resp.Header, Usage: usage, Model: requestModel, UpstreamModel: requestModel, diff --git a/backend/internal/service/openai_live.go b/backend/internal/service/openai_live.go index 68d3bb725e..29922910bb 100644 --- a/backend/internal/service/openai_live.go +++ b/backend/internal/service/openai_live.go @@ -199,7 +199,7 @@ func (s *OpenAIGatewayService) CreateLiveCall( selection.ReleaseFunc() if createErr != nil { s.releaseLiveLease(account.ID, identity.UserID, identity.APIKeyID, leaseID) - if !s.shouldFailoverLiveCreateError(createErr) { + if !s.shouldFailoverLiveCreateError(account, createErr) { return nil, createErr } excluded[account.ID] = struct{}{} @@ -245,13 +245,13 @@ func (s *OpenAIGatewayService) CreateLiveCall( return nil, ErrLiveUnavailable } -func (s *OpenAIGatewayService) shouldFailoverLiveCreateError(err error) bool { +func (s *OpenAIGatewayService) shouldFailoverLiveCreateError(account *Account, err error) bool { var upstreamErr *UpstreamFailoverError if !errors.As(err, &upstreamErr) { // 凭证读取和网络传输错误都可能只影响当前账号或代理。 return true } - return s.shouldFailoverOpenAIUpstreamResponse( + return s.shouldFailoverOpenAIUpstreamResponse(account, upstreamErr.StatusCode, "", upstreamErr.ResponseBody, diff --git a/backend/internal/service/openai_live_test.go b/backend/internal/service/openai_live_test.go index 81cff13b8c..259a5c5ccf 100644 --- a/backend/internal/service/openai_live_test.go +++ b/backend/internal/service/openai_live_test.go @@ -210,17 +210,18 @@ func TestLiveSidebandNormalCloseEndsCall(t *testing.T) { func TestLiveCreateFailoverUsesExistingOpenAIPolicy(t *testing.T) { service := &OpenAIGatewayService{} - require.False(t, service.shouldFailoverLiveCreateError(&UpstreamFailoverError{ + account := newOpenAIUpstreamErrorTestAccount() + require.False(t, service.shouldFailoverLiveCreateError(account, &UpstreamFailoverError{ StatusCode: http.StatusBadRequest, ResponseBody: []byte(`{"error":{"message":"invalid session"}}`), })) - require.True(t, service.shouldFailoverLiveCreateError(&UpstreamFailoverError{ + require.True(t, service.shouldFailoverLiveCreateError(account, &UpstreamFailoverError{ StatusCode: http.StatusForbidden, })) - require.True(t, service.shouldFailoverLiveCreateError(&UpstreamFailoverError{ + require.True(t, service.shouldFailoverLiveCreateError(account, &UpstreamFailoverError{ StatusCode: http.StatusBadGateway, })) - require.True(t, service.shouldFailoverLiveCreateError(errors.New("transport failed"))) + require.True(t, service.shouldFailoverLiveCreateError(account, errors.New("transport failed"))) } func TestLiveCallIDFromLocation(t *testing.T) { diff --git a/backend/internal/service/openai_messages_continuation.go b/backend/internal/service/openai_messages_continuation.go index b3c900fbc6..65bafbd544 100644 --- a/backend/internal/service/openai_messages_continuation.go +++ b/backend/internal/service/openai_messages_continuation.go @@ -111,6 +111,7 @@ func isOpenAICompatPreviousResponseNotFound(statusCode int, upstreamMsg string, check := func(s string) bool { lower := strings.ToLower(strings.TrimSpace(s)) return strings.Contains(lower, "previous_response_not_found") || + lower == "previous_response_id is not available for this user" || (strings.Contains(lower, "previous response") && strings.Contains(lower, "not found")) || (strings.Contains(lower, "unsupported parameter") && strings.Contains(lower, "previous_response_id")) } @@ -132,7 +133,9 @@ func isOpenAICompatPreviousResponseUnsupported(statusCode int, upstreamMsg strin } return strings.Contains(lower, "unsupported parameter") || strings.Contains(lower, "only supported on responses websocket") || - strings.Contains(lower, "not supported") + strings.Contains(lower, "not supported") || + strings.Contains(lower, "is not available for this user") || + strings.Contains(lower, "requires an openai api-key account for http requests") } if check(upstreamMsg) || check(string(upstreamBody)) { return true diff --git a/backend/internal/service/openai_model_alias.go b/backend/internal/service/openai_model_alias.go index 4ddf8110c3..f34a28abac 100644 --- a/backend/internal/service/openai_model_alias.go +++ b/backend/internal/service/openai_model_alias.go @@ -1,6 +1,10 @@ package service -import "strings" +import ( + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" +) func lastOpenAIModelSegment(model string) string { model = strings.TrimSpace(model) @@ -15,38 +19,7 @@ func lastOpenAIModelSegment(model string) string { } func canonicalizeOpenAIModelAliasSpelling(model string) string { - model = strings.ToLower(lastOpenAIModelSegment(model)) - if model == "" { - return "" - } - - normalized := strings.ReplaceAll(model, "_", "-") - normalized = strings.Join(strings.Fields(normalized), "-") - for strings.Contains(normalized, "--") { - normalized = strings.ReplaceAll(normalized, "--", "-") - } - - if strings.HasPrefix(normalized, "gpt5") { - normalized = "gpt-5" + strings.TrimPrefix(normalized, "gpt5") - } - if !strings.HasPrefix(normalized, "gpt-") && !strings.Contains(normalized, "codex") { - return "" - } - - replacements := []struct { - from string - to string - }{ - {"gpt-5.4mini", "gpt-5.4-mini"}, - {"gpt-5.4nano", "gpt-5.4-nano"}, - {"gpt-5.3-codexspark", "gpt-5.3-codex-spark"}, - {"gpt-5.3codexspark", "gpt-5.3-codex-spark"}, - {"gpt-5.3codex", "gpt-5.3-codex"}, - } - for _, replacement := range replacements { - normalized = strings.ReplaceAll(normalized, replacement.from, replacement.to) - } - return normalized + return openai.CanonicalizeOpenAIModelAliasSpelling(model) } func normalizeKnownOpenAICodexModel(model string) string { @@ -65,6 +38,8 @@ func normalizeKnownOpenAICodexModel(model string) string { } switch { + case normalized == "gpt-6" || normalized == "gpt-6-astra": + return "gpt-6-astra" case strings.Contains(normalized, "gpt-5.6-sol"): return "gpt-5.6-sol" case strings.Contains(normalized, "gpt-5.6-terra"): @@ -124,6 +99,13 @@ func isOpenAIGPT56Model(model string) bool { return false } +// isOpenAIGPT6AstraModel reports GPT-6 Astra and dated/provider-prefixed variants. +// The public "gpt-6" alias routes to Astra; unrelated GPT-6 families stay excluded. +func isOpenAIGPT6AstraModel(model string) bool { + normalized := canonicalizeOpenAIModelAliasSpelling(model) + return normalized == "gpt-6" || normalized == "gpt-6-astra" || strings.HasPrefix(normalized, "gpt-6-astra-") +} + func appendUsageBillingModelCandidate(candidates []string, seen map[string]struct{}, model string) []string { trimmed := strings.TrimSpace(model) if trimmed == "" { diff --git a/backend/internal/service/openai_model_alias_test.go b/backend/internal/service/openai_model_alias_test.go index 4cb27206c9..3cc4557c52 100644 --- a/backend/internal/service/openai_model_alias_test.go +++ b/backend/internal/service/openai_model_alias_test.go @@ -6,6 +6,12 @@ import ( "github.com/stretchr/testify/require" ) +func TestNormalizeKnownOpenAICodexModelGPT6Astra(t *testing.T) { + for _, model := range []string{"gpt-6-astra", "openai/gpt-6-astra", "OPENAI/GPT-6_ASTRA", "gpt-6", "openai/gpt-6"} { + require.Equal(t, "gpt-6-astra", normalizeKnownOpenAICodexModel(model)) + } +} + func TestNormalizeKnownOpenAICodexModel_BareGPT56RoutesToSol(t *testing.T) { tests := map[string]string{ "gpt-5.6": "gpt-5.6-sol", diff --git a/backend/internal/service/openai_models_list.go b/backend/internal/service/openai_models_list.go new file mode 100644 index 0000000000..42740c2df3 --- /dev/null +++ b/backend/internal/service/openai_models_list.go @@ -0,0 +1,374 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// FetchOpenAIModelsList discovers a single account's raw public model catalog. +// API keys use the standard endpoint; OAuth reuses the authenticated, cached +// Codex source. Account mappings and group policy are applied after this cache. +func (s *OpenAIGatewayService) FetchOpenAIModelsList(ctx context.Context, account *Account) (*OpenAIModelsResponse, error) { + if s == nil || account == nil { + return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_MODELS_ACCOUNT_REQUIRED", "OpenAI account is required") + } + credentialAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account) + if err != nil { + return nil, fmt.Errorf("resolve model list credentials: %w", err) + } + if credentialAccount.IsOpenAIOAuth() { + clientVersion := CodexCanonicalClientVersion() + if s.settingService != nil { + clientVersion = s.settingService.GetOpenAICodexClientVersion(ctx) + } + response, err := s.FetchCodexModelsManifest(ctx, account, clientVersion, "") + if err != nil { + return nil, err + } + body, err := standardOpenAIModelsBody(response.Body, true) + if err != nil { + return nil, invalidOpenAIModelsList(err) + } + return &OpenAIModelsResponse{Body: body, ETag: codexModelsManifestBodyETag(body)}, nil + } + req, err := buildOpenAIAPIKeyModelsRequest(ctx, credentialAccount, s.validateUpstreamBaseURL) + if err != nil { + return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_MODELS_REQUEST_INVALID", "cannot build upstream model list request: %v", err) + } + request := openAIModelsRequest{ + url: req.URL.String(), headers: req.Header, + proxyURL: upstreamModelsProxyURL(account), accountID: account.ID, + credentialAccountID: credentialAccount.ID, credentialAccount: credentialAccount, + accountConcurrency: account.Concurrency, useAPIKeyUpstream: true, + standardModelsList: true, + } + response, err := s.fetchCachedOpenAIModels(ctx, request, func(fetchCtx context.Context, etag string) (*OpenAIModelsResponse, error) { + response, err := s.fetchOpenAIModelsUpstream(fetchCtx, request, etag) + if err != nil || response.NotModified { + return response, err + } + body, err := standardOpenAIModelsBody(response.Body, false) + if err != nil { + return nil, invalidOpenAIModelsList(err) + } + response.Body = body + response.ETag = codexModelsManifestBodyETag(body) + return response, nil + }, "") + if err != nil { + return nil, err + } + if response.NotModified { + return nil, invalidOpenAIModelsList(fmt.Errorf("upstream returned 304 without a cached catalog")) + } + return response, nil +} + +func invalidOpenAIModelsList(err error) error { + return &codexModelsManifestUpstreamError{ + err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_MODELS_UPSTREAM_INVALID", "invalid upstream model list: %v", err), + retryable: true, + } +} + +// modelCatalogEntries requires a real array. An empty array is authoritative; +// absent fields, null and error envelopes must not become successful empty lists. +func modelCatalogEntries(body []byte, field string) (map[string]json.RawMessage, []json.RawMessage, error) { + var envelope map[string]json.RawMessage + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, nil, err + } + raw := bytes.TrimSpace(envelope[field]) + if len(raw) == 0 || raw[0] != '[' { + return nil, nil, fmt.Errorf("missing or invalid %s array", field) + } + var entries []json.RawMessage + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, nil, err + } + return envelope, entries, nil +} + +func standardOpenAIModelsBody(body []byte, fromManifest bool) ([]byte, error) { + field, idField := "data", "id" + if fromManifest { + field, idField = "models", "slug" + } + _, entries, err := modelCatalogEntries(body, field) + if err != nil { + return nil, err + } + models := make([]json.RawMessage, 0, len(entries)) + seen := make(map[string]struct{}, len(entries)) + for _, raw := range entries { + var entry map[string]json.RawMessage + if err := json.Unmarshal(raw, &entry); err != nil || entry == nil { + return nil, fmt.Errorf("model entry must be an object") + } + var id string + if err := json.Unmarshal(entry[idField], &id); err != nil || strings.TrimSpace(id) == "" { + return nil, fmt.Errorf("model entry has no valid %s", idField) + } + id = strings.TrimSpace(id) + if _, ok := seen[id]; ok || strings.Contains(id, "*") { + continue + } + seen[id] = struct{}{} + if fromManifest { + entry = make(map[string]json.RawMessage) + } + entry["id"], _ = json.Marshal(id) + entry["object"] = json.RawMessage(`"model"`) + if len(entry["created"]) == 0 || string(entry["created"]) == "null" { + entry["created"] = json.RawMessage(`0`) + } + if len(entry["owned_by"]) == 0 || string(entry["owned_by"]) == "null" { + entry["owned_by"] = json.RawMessage(`"openai"`) + } + encoded, err := json.Marshal(entry) + if err != nil { + return nil, err + } + models = append(models, encoded) + } + return json.Marshal(struct { + Object string `json:"object"` + Data []json.RawMessage `json:"data"` + }{Object: "list", Data: models}) +} + +// projectAccountModelsBody applies the same public-name mapping to both model +// representations while retaining the source entry's metadata. It never changes +// the shared response and never synthesizes models absent from this account. +func projectAccountModelsBody(body []byte, account *Account, group *Group, codex bool) ([]byte, error) { + if account.IsOpenAIPassthroughEnabled() || len(account.GetModelMapping()) == 0 { + return body, nil + } + field, idField := "data", "id" + if codex { + field, idField = "models", "slug" + } + envelope, entries, err := modelCatalogEntries(body, field) + if err != nil { + return nil, err + } + byID := make(map[string]json.RawMessage, len(entries)) + candidates := make([]string, 0, len(entries)) + for _, raw := range entries { + var entry map[string]json.RawMessage + if json.Unmarshal(raw, &entry) != nil { + continue + } + var id string + if json.Unmarshal(entry[idField], &id) != nil { + continue + } + id = strings.TrimSpace(id) + if id == "" || strings.Contains(id, "*") { + continue + } + // Apply the allowlist to public names after mapping, not upstream targets. + if codex { + if isCodexDedicatedMediaModel(id) { + continue + } + if strings.HasPrefix(id, codexAutoModelPrefix) && len(FilterCodexModelIDsForGroup([]string{id}, group)) == 0 { + continue + } + } + if _, ok := byID[id]; !ok { + byID[id] = raw + candidates = append(candidates, id) + } + } + aliases := make([]string, 0, len(account.GetModelMapping())) + for alias := range account.GetModelMapping() { + aliases = append(aliases, alias) + } + sort.Strings(aliases) + candidates = append(candidates, aliases...) + if group.ModelAllowlistEnabled() { + candidates = append(candidates, group.ModelAllowlist.Models...) + } + projected := make([]json.RawMessage, 0, len(candidates)) + seen := make(map[string]struct{}, len(candidates)) + for _, id := range candidates { + id = strings.TrimSpace(id) + if id == "" || strings.Contains(id, "*") { + continue + } + if _, ok := seen[id]; ok { + continue + } + target, matched := account.ResolveMappedModel(id) + raw, available := byID[strings.TrimSpace(target)] + if !matched || !available { + continue + } + seen[id] = struct{}{} + var entry map[string]json.RawMessage + if err := json.Unmarshal(raw, &entry); err != nil { + return nil, err + } + entry[idField], _ = json.Marshal(id) + if id != target { + entry["display_name"], _ = json.Marshal(id) + } + encoded, err := json.Marshal(entry) + if err != nil { + return nil, err + } + projected = append(projected, encoded) + } + envelope[field], err = json.Marshal(projected) + if err != nil { + return nil, err + } + return json.Marshal(envelope) +} + +// ApplyPinnedCodexModelsMapping is used by pinned discovery and its scheduler +// fallback. The ordinary (non-pinned) Codex path retains its local catalog policy. +func ApplyPinnedCodexModelsMapping(response *OpenAIModelsResponse, account *Account, group *Group) error { + if group == nil || group.Platform != PlatformOpenAI || !group.CodexModelsManifestConfig.Enabled { + return nil + } + body, err := projectAccountModelsBody(response.Body, account, group, true) + if err != nil { + return err + } + response.Body = body + response.ETag = codexModelsManifestBodyETag(body) + return nil +} + +// FetchPinnedOpenAIModelsList includes explicitly enabled scheduler fallback. +// An authoritative empty catalog is success, including after group filtering. +func (s *OpenAIGatewayService) FetchPinnedOpenAIModelsList(ctx context.Context, group *Group, maxAccountSwitches int, ifNoneMatch string) (*OpenAIModelsResponse, *Account, error) { + fetch := func(ctx context.Context, account *Account) (*OpenAIModelsResponse, error) { + response, err := s.FetchOpenAIModelsList(ctx, account) + if err != nil { + return nil, err + } + response.Body, err = projectAccountModelsBody(response.Body, account, group, false) + return response, err + } + results, err := s.fetchPinnedOpenAIModels(ctx, group, fetch) + if err != nil && ctx.Err() == nil && group != nil && group.CodexModelsManifestConfig.FallbackToScheduler { + results, err = s.fetchScheduledOpenAIModels(ctx, group, maxAccountSwitches, fetch) + } + if err != nil { + return nil, nil, err + } + models := make([]json.RawMessage, 0) + modelIDs := make([]string, 0) + byID := make(map[string]json.RawMessage) + for _, result := range results { + _, entries, err := modelCatalogEntries(result.response.Body, "data") + if err != nil { + return nil, nil, err + } + for _, raw := range entries { + var model struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &model); err != nil { + return nil, nil, err + } + if _, exists := byID[model.ID]; !exists { + byID[model.ID] = raw + modelIDs = append(modelIDs, model.ID) + models = append(models, raw) + } + } + } + if group.ModelAllowlistEnabled() { + models = selectModelCatalogEntries(byID, group.ModelAllowlist.FilterForListing(modelIDs)) + } + body, err := json.Marshal(struct { + Object string `json:"object"` + Data []json.RawMessage `json:"data"` + }{Object: "list", Data: models}) + if err != nil { + return nil, nil, err + } + response := &OpenAIModelsResponse{Body: body, ETag: codexModelsManifestBodyETag(body)} + return openAIModelsResponseForClient(response, ifNoneMatch), results[0].account, nil +} + +func selectModelCatalogEntries(byID map[string]json.RawMessage, selected []string) []json.RawMessage { + models := make([]json.RawMessage, 0, len(selected)) + seen := make(map[string]struct{}, len(selected)) + for _, id := range selected { + id = strings.TrimSpace(id) + if _, ok := seen[id]; ok { + continue + } + if raw, exists := byID[id]; exists { + models = append(models, raw) + seen[id] = struct{}{} + } + } + return models +} + +func orderPinnedCodexModelsBySelection(body []byte, allowlist GroupModelAllowlist) ([]byte, error) { + envelope, entries, err := modelCatalogEntries(body, "models") + if err != nil { + return nil, err + } + byID := make(map[string]json.RawMessage, len(entries)) + modelIDs := make([]string, 0, len(entries)) + for _, raw := range entries { + var entry struct { + Slug string `json:"slug"` + } + if err := json.Unmarshal(raw, &entry); err != nil { + return nil, err + } + byID[entry.Slug] = raw + modelIDs = append(modelIDs, entry.Slug) + } + envelope["models"], err = json.Marshal(selectModelCatalogEntries(byID, allowlist.FilterForListing(modelIDs))) + if err != nil { + return nil, err + } + return json.Marshal(envelope) +} + +func (s *OpenAIGatewayService) fetchScheduledOpenAIModels(ctx context.Context, group *Group, maxSwitches int, fetch func(context.Context, *Account) (*OpenAIModelsResponse, error)) ([]pinnedOpenAIModelsResult, error) { + if maxSwitches <= 0 { + maxSwitches = 3 + } + excluded := make(map[int64]struct{}) + var lastErr error + for attempt := 0; attempt <= maxSwitches; attempt++ { + if err := ctx.Err(); err != nil { + return nil, err + } + account, err := s.SelectAccountForModelWithExclusions(ctx, &group.ID, "", "", excluded) + if err != nil { + if lastErr != nil { + return nil, lastErr + } + return nil, ErrNoPinnedCodexModelsAccounts + } + response, err := fetch(ctx, account) + if err == nil { + return []pinnedOpenAIModelsResult{{account: account, response: response}}, nil + } + lastErr = err + if !IsRetryableCodexModelsManifestError(err) { + return nil, err + } + excluded[account.ID] = struct{}{} + } + return nil, lastErr +} diff --git a/backend/internal/service/openai_models_list_test.go b/backend/internal/service/openai_models_list_test.go new file mode 100644 index 0000000000..c898c06189 --- /dev/null +++ b/backend/internal/service/openai_models_list_test.go @@ -0,0 +1,348 @@ +package service + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func ordinaryModelsUpstreamResponse(body string) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))} +} + +func TestFetchOpenAIModelsListUsesStandardRequestAndIsolatesCodexCache(t *testing.T) { + var calls atomic.Int32 + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, accountID int64, concurrency int) (*http.Response, error) { + calls.Add(1) + assert.EqualValues(t, 2, accountID) + assert.EqualValues(t, 3, concurrency) + assert.Equal(t, "/v1/models", req.URL.Path) + assert.Equal(t, "value", getHeaderRaw(req.Header, "x-account-header")) + if req.URL.Query().Has("client_version") { + return ordinaryModelsUpstreamResponse(`{"models":[{"slug":"codex-only"}]}`), nil + } + assert.Empty(t, req.Header.Get("Originator")) + assert.Empty(t, req.Header.Get("Version")) + return ordinaryModelsUpstreamResponse(`{"object":"list","data":[{"id":"special-upstream-model","owned_by":"provider","created":1234,"custom":true},{"id":"gpt-image-1"},{"id":"text-embedding-3-large"}]}`), nil + }}) + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + account.Credentials["header_overrides"] = map[string]any{"X-Account-Header": "value"} + account.Credentials["header_override_enabled"] = true + response, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Contains(t, string(response.Body), `"id":"gpt-image-1"`) + require.Contains(t, string(response.Body), `"id":"text-embedding-3-large"`) + require.Contains(t, string(response.Body), `"created":1234`) + require.Contains(t, string(response.Body), `"custom":true`) + manifest, err := s.FetchCodexModelsManifest(context.Background(), account, CodexCanonicalClientVersion(), "") + require.NoError(t, err) + require.Contains(t, string(manifest.Body), `"slug":"codex-only"`) + again, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Equal(t, string(response.Body), string(again.Body)) + require.EqualValues(t, 2, calls.Load()) + account.Credentials["api_key"] = "rotated-token" + _, err = s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.EqualValues(t, 3, calls.Load(), "credentials must participate in the cache key") +} + +func TestFetchOpenAIModelsListOAuthSharesManifestCache(t *testing.T) { + _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"special-oauth-model"},{"slug":"gpt-image-1"}]}`) + s := &OpenAIGatewayService{} + account := newCodexModelsTestAccount() + response, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.JSONEq(t, `{"object":"list","data":[{"id":"special-oauth-model","object":"model","owned_by":"openai","created":0},{"id":"gpt-image-1","object":"model","owned_by":"openai","created":0}]}`, string(response.Body)) + manifest, err := s.FetchCodexModelsManifest(context.Background(), account, CodexCanonicalClientVersion(), "") + require.NoError(t, err) + require.Contains(t, string(manifest.Body), `"slug":"special-oauth-model"`) + require.NotContains(t, string(manifest.Body), `"data"`) + require.EqualValues(t, 1, calls.Load()) + _, err = s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.EqualValues(t, 1, calls.Load()) +} + +func TestFetchOpenAIModelsListCacheWindowsAndSingleflight(t *testing.T) { + var calls atomic.Int32 + started := make(chan int32, 3) + release := make(chan struct{}, 3) + t.Cleanup(func() { close(release) }) + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + call := calls.Add(1) + if call > 1 && call < 4 { + started <- call + <-release + } + if call >= 4 { + response := ordinaryModelsUpstreamResponse(`{"error":"unavailable"}`) + response.StatusCode = http.StatusServiceUnavailable + return response, nil + } + return ordinaryModelsUpstreamResponse(`{"data":[{"id":"version-` + string(rune('0'+call)) + `"}]}`), nil + }}) + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + first, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + for range 5 { + _, err = s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + } + require.EqualValues(t, 1, calls.Load()) + expireCodexModelsManifestCache(s, 2*time.Minute) + stale, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Equal(t, string(first.Body), string(stale.Body)) + select { + case call := <-started: + require.EqualValues(t, 2, call) + case <-time.After(2 * time.Second): + t.Fatal("stale response did not start a background refresh") + } + for range 5 { + cached, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Equal(t, string(first.Body), string(cached.Body)) + } + require.EqualValues(t, 2, calls.Load(), "concurrent stale reads must share the refresh") + release <- struct{}{} + require.Eventually(t, func() bool { + s.openAIModelsCache.mu.Lock() + defer s.openAIModelsCache.mu.Unlock() + for _, entry := range s.openAIModelsCache.entries { + if strings.Contains(string(entry.manifest.Body), "version-2") { + return true + } + } + return false + }, 2*time.Second, time.Millisecond) + + expireCodexModelsManifestCache(s, 6*time.Minute) + responses := make(chan *OpenAIModelsResponse, 2) + fetchErrors := make(chan error, 2) + var wait sync.WaitGroup + for range 2 { + wait.Add(1) + go func() { + defer wait.Done() + response, err := s.FetchOpenAIModelsList(context.Background(), account) + responses <- response + fetchErrors <- err + }() + } + select { + case call := <-started: + require.EqualValues(t, 3, call) + case <-time.After(2 * time.Second): + t.Fatal("expired catalog did not start a synchronous refresh") + } + select { + case <-responses: + t.Fatal("expired catalog returned before the upstream refresh completed") + case <-time.After(20 * time.Millisecond): + } + release <- struct{}{} + wait.Wait() + for range 2 { + require.NoError(t, <-fetchErrors) + require.Contains(t, string((<-responses).Body), "version-3") + } + require.EqualValues(t, 3, calls.Load()) + expireCodexModelsManifestCache(s, 6*time.Minute) + response, err := s.FetchOpenAIModelsList(context.Background(), account) + require.Error(t, err) + require.Nil(t, response) + require.Equal(t, http.StatusBadGateway, infraerrors.Code(err)) +} + +func TestFetchOpenAIModelsListRevalidatesUpstreamETag(t *testing.T) { + var calls atomic.Int32 + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + response := ordinaryModelsUpstreamResponse(`{"data":[{"id":"unchanged"}]}`) + response.Header.Set("ETag", `"upstream"`) + if calls.Add(1) > 1 { + assert.Equal(t, `"upstream"`, req.Header.Get("If-None-Match")) + response.StatusCode = http.StatusNotModified + } + return response, nil + }}) + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + first, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + expireCodexModelsManifestCache(s, 2*time.Minute) + _, err = s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Eventually(t, func() bool { + s.openAIModelsCache.mu.Lock() + defer s.openAIModelsCache.mu.Unlock() + for _, entry := range s.openAIModelsCache.entries { + return entry.expiresAt.After(time.Now()) + } + return false + }, 2*time.Second, time.Millisecond) + again, err := s.FetchOpenAIModelsList(context.Background(), account) + require.NoError(t, err) + require.Equal(t, first.ETag, again.ETag) + require.Equal(t, string(first.Body), string(again.Body)) + require.EqualValues(t, 2, calls.Load()) +} + +func TestProjectAccountModelsPassthroughIgnoresStaleMappings(t *testing.T) { + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + account.Extra = map[string]any{"openai_passthrough": true} + account.Credentials["model_mapping"] = map[string]any{"obsolete": "missing"} + body := []byte(`{"object":"list","data":[{"id":"live-model"}]}`) + projected, err := projectAccountModelsBody(body, account, nil, false) + require.NoError(t, err) + require.JSONEq(t, string(body), string(projected)) +} + +func TestFetchOpenAIModelsListEmptyAndMalformedResponses(t *testing.T) { + for _, body := range []string{`{"data":[]}`, `{"data":null}`, `{}`, `{"data":{}}`, `{"data":[{}]}`, `{"data":[null]}`} { + t.Run(body, func(t *testing.T) { + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return ordinaryModelsUpstreamResponse(body), nil + }}) + response, err := s.FetchOpenAIModelsList(context.Background(), newCodexModelsAPIKeyTestAccount("https://models.example/v1")) + if body != `{"data":[]}` { + require.Error(t, err) + require.Equal(t, http.StatusBadGateway, infraerrors.Code(err)) + return + } + require.NoError(t, err) + var result struct { + Data []json.RawMessage `json:"data"` + } + require.NoError(t, json.Unmarshal(response.Body, &result)) + require.NotNil(t, result.Data) + require.Empty(t, result.Data) + }) + } +} + +func TestPinnedOpenAIModelsListMixedAccountsShareColdCacheAcrossGroups(t *testing.T) { + _, oauthCalls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"shared-model"},{"slug":"oauth-special"}]}`) + var apiCalls atomic.Int32 + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + apiCalls.Add(1) + return ordinaryModelsUpstreamResponse(`{"data":[{"id":"shared-model","owned_by":"api-provider"},{"id":"api-special"}]}`), nil + }}) + apiAccount := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + oauthAccount := newCodexModelsTestAccount() + for _, account := range []*Account{apiAccount, oauthAccount} { + account.Status, account.Schedulable = StatusActive, true + } + accounts := []Account{*apiAccount, *oauthAccount} + s.accountRepo = splitCodexModelsAccountRepo{all: map[int64][]Account{10: accounts, 11: accounts}} + groups := []*Group{ + {ID: 10, Platform: PlatformOpenAI, CodexModelsManifestConfig: GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2, 1}}, + ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"oauth-special", "shared-model"}}}, + {ID: 11, Platform: PlatformOpenAI, CodexModelsManifestConfig: GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2, 1}}}, + } + type result struct { + response *OpenAIModelsResponse + account *Account + err error + } + results := make([]result, len(groups)) + var wait sync.WaitGroup + for i, group := range groups { + wait.Add(1) + go func() { + defer wait.Done() + results[i].response, results[i].account, results[i].err = s.FetchPinnedOpenAIModelsList(context.Background(), group, 3, "") + }() + } + wait.Wait() + for i, result := range results { + require.NoError(t, result.err) + require.EqualValues(t, 2, result.account.ID) + var catalog struct { + Data []struct { + ID string `json:"id"` + Owner string `json:"owned_by"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(result.response.Body, &catalog)) + ids := make([]string, 0, len(catalog.Data)) + for _, model := range catalog.Data { + ids = append(ids, model.ID) + if model.ID == "shared-model" { + require.Equal(t, "api-provider", model.Owner) + } + } + if i == 0 { + require.Equal(t, []string{"oauth-special", "shared-model"}, ids) + } else { + require.Equal(t, []string{"shared-model", "api-special", "oauth-special"}, ids) + } + } + require.EqualValues(t, 1, apiCalls.Load()) + require.EqualValues(t, 1, oauthCalls.Load()) +} + +func TestFetchOpenAIModelsListResolvesShadowOAuthCredentials(t *testing.T) { + _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"parent-model"}]}`) + parent := newCodexModelsTestAccount() + shadow := &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeOAuth, ParentAccountID: &parent.ID} + s := &OpenAIGatewayService{accountRepo: newStubCredRepo(parent)} + response, err := s.FetchOpenAIModelsList(context.Background(), shadow) + require.NoError(t, err) + require.Contains(t, string(response.Body), `"id":"parent-model"`) + require.EqualValues(t, 1, calls.Load()) +} + +func TestProjectAccountModelsCannotExposeCodexMediaThroughAlias(t *testing.T) { + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + account.Credentials["model_mapping"] = map[string]any{"image-alias": "gpt-image-1", "auto-alias": "codex-auto-fast"} + body := []byte(`{"models":[{"slug":"gpt-image-1"},{"slug":"codex-auto-fast"}]}`) + projected, err := projectAccountModelsBody(body, account, &Group{}, true) + require.NoError(t, err) + require.JSONEq(t, `{"models":[]}`, string(projected)) +} + +func TestFetchOpenAIModelsListRejectsUnexpectedCold304(t *testing.T) { + s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusNotModified, Header: make(http.Header), Body: http.NoBody}, nil + }}) + response, err := s.FetchOpenAIModelsList(context.Background(), newCodexModelsAPIKeyTestAccount("https://models.example/v1")) + require.Error(t, err) + require.Nil(t, response) + require.Equal(t, http.StatusBadGateway, infraerrors.Code(err)) +} + +func TestOpenAIModelsCacheSeparatesRepresentationsForIdenticalRequests(t *testing.T) { + s := &OpenAIGatewayService{} + request := openAIModelsRequest{url: "https://models.example/v1/models", accountID: 7} + var calls atomic.Int32 + fetch := func(body string) func(context.Context, string) (*OpenAIModelsResponse, error) { + return func(context.Context, string) (*OpenAIModelsResponse, error) { + calls.Add(1) + return &OpenAIModelsResponse{Body: []byte(body)}, nil + } + } + manifestBody := `{"models":[{"slug":"shared"}]}` + listBody := `{"object":"list","data":[{"id":"shared"}]}` + _, err := s.fetchCachedOpenAIModels(context.Background(), request, fetch(manifestBody), "") + require.NoError(t, err) + request.standardModelsList = true + list, err := s.fetchCachedOpenAIModels(context.Background(), request, fetch(listBody), "") + require.NoError(t, err) + require.JSONEq(t, listBody, string(list.Body)) + request.standardModelsList = false + manifest, err := s.fetchCachedOpenAIModels(context.Background(), request, fetch(manifestBody), "") + require.NoError(t, err) + require.JSONEq(t, manifestBody, string(manifest.Body)) + require.EqualValues(t, 2, calls.Load()) +} diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 36171ae72d..d871fcd901 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -936,6 +936,44 @@ func TestOpenAIGatewayService_OAuthPassthrough_CodexMissingInstructionsGetsDefau } } +func TestOpenAIGatewayService_Forward_MissingInstructionsUsesMappedModelTemplate(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_mapped_astra"}}, + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}`, + "", "data: [DONE]", "", + }, "\n"))), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + "model_mapping": map[string]any{"astra-public": "gpt-6-astra"}, + }, + Extra: map[string]any{ + "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeOff, + }, + Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + } + + result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"astra-public","stream":true,"store":true,"input":[{"type":"text","text":"hi"}]}`)) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "gpt-6-astra", gjson.GetBytes(upstream.lastBody, "model").String()) + instructions := gjson.GetBytes(upstream.lastBody, "instructions").String() + require.True(t, strings.HasPrefix(strings.TrimSpace(instructions), "You are Codex, an agent based on GPT-6.")) + require.NotContains(t, instructions, "You are Codex, a coding agent based on GPT-5.") +} + func TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_opencode_session.go b/backend/internal/service/openai_opencode_session.go new file mode 100644 index 0000000000..fbb0b97eb4 --- /dev/null +++ b/backend/internal/service/openai_opencode_session.go @@ -0,0 +1,37 @@ +package service + +import ( + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" +) + +const openCodeSessionHeader = "X-OpenCode-Session" + +// applyOpenCodeSessionHeader forwards the caller-owned conversation identifier +// only to OpenCode's official API origin. The caller applies this after account +// header overrides so a per-conversation value cannot be replaced by a fixed +// account-wide override. +func applyOpenCodeSessionHeader(c *gin.Context, account *Account, targetURL string, headers http.Header) { + if c == nil || c.Request == nil || account == nil || account.Type != AccountTypeAPIKey || headers == nil { + return + } + + parsed, err := url.Parse(targetURL) + if err != nil || !strings.EqualFold(parsed.Scheme, "https") || !strings.EqualFold(parsed.Hostname(), "opencode.ai") { + return + } + + sessionID := strings.TrimSpace(c.GetHeader(openCodeSessionHeader)) + if sessionID == "" { + return + } + for key := range headers { + if strings.EqualFold(key, openCodeSessionHeader) { + delete(headers, key) + } + } + headers.Set(openCodeSessionHeader, sessionID) +} diff --git a/backend/internal/service/openai_opencode_session_test.go b/backend/internal/service/openai_opencode_session_test.go new file mode 100644 index 0000000000..fe34a95958 --- /dev/null +++ b/backend/internal/service/openai_opencode_session_test.go @@ -0,0 +1,223 @@ +package service + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func newOpenCodeSessionTestContext(t *testing.T, value string) *gin.Context { + t.Helper() + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + if value != "" { + c.Request.Header.Set(openCodeSessionHeader, value) + } + return c +} + +func openCodeSessionTestService() *OpenAIGatewayService { + return &OpenAIGatewayService{cfg: &config.Config{ + Security: config.SecurityConfig{ + URLAllowlist: config.URLAllowlistConfig{Enabled: false}, + }, + }} +} + +func openCodeSessionTestAccount(baseURL string) *Account { + return &Account{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": baseURL, + credKeyHeaderOverrideEnabled: true, + credKeyHeaderOverrides: map[string]any{"x-opencode-session": "fixed-account-value"}, + }, + } +} + +func requireSingleOpenCodeSessionHeader(t *testing.T, headers http.Header, want string) { + t.Helper() + count := 0 + for key, values := range headers { + if strings.EqualFold(key, openCodeSessionHeader) { + count += len(values) + require.Equal(t, []string{want}, values) + } + } + require.Equal(t, 1, count) +} + +func TestApplyOpenCodeSessionHeaderTrustBoundary(t *testing.T) { + tests := []struct { + name string + account *Account + targetURL string + incoming string + want string + }{ + { + name: "official origin", + account: openCodeSessionTestAccount("https://opencode.ai/zen/v1"), + targetURL: "https://opencode.ai/zen/v1/chat/completions", + incoming: " conversation-123 ", + want: "conversation-123", + }, + { + name: "lookalike origin", + account: openCodeSessionTestAccount("https://opencode.ai.evil.example/v1"), + targetURL: "https://opencode.ai.evil.example/v1/responses", + incoming: "conversation-123", + }, + { + name: "subdomain is not implicitly trusted", + account: openCodeSessionTestAccount("https://api.opencode.ai/v1"), + targetURL: "https://api.opencode.ai/v1/responses", + incoming: "conversation-123", + }, + { + name: "insecure official origin", + account: openCodeSessionTestAccount("http://opencode.ai/zen/v1"), + targetURL: "http://opencode.ai/zen/v1/responses", + incoming: "conversation-123", + }, + { + name: "missing caller value", + account: openCodeSessionTestAccount("https://opencode.ai/zen/v1"), + targetURL: "https://opencode.ai/zen/v1/responses", + }, + { + name: "oauth account", + account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + targetURL: "https://opencode.ai/zen/v1/responses", + incoming: "conversation-123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + headers := make(http.Header) + applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, tt.incoming), tt.account, tt.targetURL, headers) + require.Equal(t, tt.want, headers.Get(openCodeSessionHeader)) + }) + } +} + +func TestOpenCodeSessionForwardedByResponsesBuildersAfterAccountOverride(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := openCodeSessionTestService() + account := openCodeSessionTestAccount("https://opencode.ai/zen/v1") + body := []byte(`{"model":"gpt-5","input":"hello"}`) + + tests := []struct { + name string + build func(*gin.Context) (*http.Request, error) + }{ + { + name: "normal responses", + build: func(c *gin.Context) (*http.Request, error) { + return svc.buildUpstreamRequest(context.Background(), c, account, body, "token", false, "", false) + }, + }, + { + name: "passthrough responses", + build: func(c *gin.Context) (*http.Request, error) { + return svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "token") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := newOpenCodeSessionTestContext(t, "conversation-456") + req, err := tt.build(c) + require.NoError(t, err) + requireSingleOpenCodeSessionHeader(t, req.Header, "conversation-456") + }) + } +} + +func TestOpenCodeSessionMissingCallerValueKeepsExistingOverrideBehavior(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := openCodeSessionTestService() + account := openCodeSessionTestAccount("https://opencode.ai/zen/v1") + c := newOpenCodeSessionTestContext(t, "") + + req, err := svc.buildUpstreamRequest( + context.Background(), c, account, + []byte(`{"model":"gpt-5","input":"hello"}`), "token", false, "", false, + ) + require.NoError(t, err) + require.Equal(t, "fixed-account-value", getHeaderRaw(req.Header, "x-opencode-session")) +} + +type openCodeSessionHTTPUpstream struct { + request *http.Request +} + +func (u *openCodeSessionHTTPUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + u.request = req + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{}`)), + }, nil +} + +func (u *openCodeSessionHTTPUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.Do(req, proxyURL, accountID, concurrency) +} + +func TestOpenCodeSessionForwardedByRawChatCompletionsAfterAccountOverride(t *testing.T) { + gin.SetMode(gin.TestMode) + upstream := &openCodeSessionHTTPUpstream{} + svc := openCodeSessionTestService() + svc.httpUpstream = upstream + account := openCodeSessionTestAccount("https://opencode.ai/zen/v1") + c := newOpenCodeSessionTestContext(t, "conversation-789") + + resp, err := svc.sendCCUpstreamRequest( + context.Background(), c, account, + "https://opencode.ai/zen/v1/chat/completions", []byte(`{"model":"gpt-5"}`), + false, "token", "", "", + ) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.NotNil(t, upstream.request) + requireSingleOpenCodeSessionHeader(t, upstream.request.Header, "conversation-789") +} + +func TestOpenCodeSessionIsNotForwardedToOtherUpstreams(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := openCodeSessionTestService() + body := []byte(`{"model":"gpt-5","input":"hello"}`) + + for _, baseURL := range []string{ + "https://api.openai.com/v1", + "https://opencode.ai.evil.example/v1", + "https://api.opencode.ai/v1", + } { + t.Run(baseURL, func(t *testing.T) { + account := &Account{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": baseURL}, + } + c := newOpenCodeSessionTestContext(t, "private-conversation") + req, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "token", false, "", false) + require.NoError(t, err) + require.Empty(t, req.Header.Get(openCodeSessionHeader)) + }) + } +} diff --git a/backend/internal/service/openai_passthrough_normalization_test.go b/backend/internal/service/openai_passthrough_normalization_test.go index f86ecc6927..a05bee25ad 100644 --- a/backend/internal/service/openai_passthrough_normalization_test.go +++ b/backend/internal/service/openai_passthrough_normalization_test.go @@ -138,6 +138,69 @@ func TestNormalizeOpenAIResponsesReasoningMode(t *testing.T) { } } +func TestNormalizeOpenAIResponsesReasoningMode_AstraPreservesBody(t *testing.T) { + // GPT-6 Astra 保留官方 reasoning.mode 与 reasoning.effort 各自原样: + // 不删 mode、缺失 effort 也不补 max。非 Astra 的旧兼容行为不受影响。 + tests := []struct { + name string + body string + }{ + {name: "pro + max preserved", body: `{"model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"max"}}`}, + {name: "standard + max preserved", body: `{"model":"gpt-6-astra","reasoning":{"mode":"standard","effort":"max"}}`}, + {name: "missing mode + max preserved", body: `{"model":"gpt-6-astra","reasoning":{"effort":"max"}}`}, + {name: "pro + explicit high preserved", body: `{"model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"high"}}`}, + {name: "pro without effort does not inject max", body: `{"model":"gpt-6-astra","reasoning":{"mode":"pro"}}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body)) + require.NoError(t, err) + require.False(t, changed) + require.JSONEq(t, tt.body, string(normalized)) + }) + } +} + +func TestNormalizeOpenAIResponsesReasoningMode_NonAstraKeepsLegacyBehavior(t *testing.T) { + // 确保 guard 只放过 Astra,非 Astra model 字段不影响既有 strip/pro->max 语义。 + tests := []struct { + name string + body string + wantEffort string + }{ + {name: "gpt-5.6-sol pro maps to max", body: `{"model":"gpt-5.6-sol","reasoning":{"mode":"pro"}}`, wantEffort: "max"}, + {name: "gpt-5.4 explicit effort wins", body: `{"model":"gpt-5.4","reasoning":{"mode":"pro","effort":"high"}}`, wantEffort: "high"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body)) + require.NoError(t, err) + require.True(t, changed) + require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists()) + require.Equal(t, tt.wantEffort, gjson.GetBytes(normalized, "reasoning.effort").String()) + }) + } +} + +func TestNormalizeOpenAIPassthroughOAuthBody_AstraPreservesReasoningMode(t *testing.T) { + body := []byte(`{"model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"max"}}`) + + normalized, _, err := normalizeOpenAIPassthroughOAuthBody(body, false) + require.NoError(t, err) + require.Equal(t, "pro", gjson.GetBytes(normalized, "reasoning.mode").String()) + require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String()) +} + +func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_AstraPreservesReasoningMode(t *testing.T) { + body := []byte(`{"type":"response.create","model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"max"}}`) + for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} { + normalized, _, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false) + require.NoError(t, err) + require.Equal(t, "pro", gjson.GetBytes(normalized, "reasoning.mode").String()) + require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String()) + } +} + func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_ReasoningModeAccountScope(t *testing.T) { body := []byte(`{"type":"response.create","reasoning":{"mode":"pro"}}`) for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} { diff --git a/backend/internal/service/openai_reasoning_effort_policy.go b/backend/internal/service/openai_reasoning_effort_policy.go index caa1291120..907025597b 100644 --- a/backend/internal/service/openai_reasoning_effort_policy.go +++ b/backend/internal/service/openai_reasoning_effort_policy.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "strings" @@ -19,9 +20,31 @@ const ( ReasoningEffortOverLimitDowngrade = "downgrade" // ReasoningEffortOverLimitDeny rejects the request when the ceiling is exceeded. ReasoningEffortOverLimitDeny = "deny" + // ReasoningEffortMappingDeny is a mapping target that rejects the request + // when the matching source reasoning effort is present. + ReasoningEffortMappingDeny = "deny" ) var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"} +var anthropicReasoningEffortValues = []string{"low", "medium", "high", "xhigh", "max"} + +func normalizeReasoningEffortMappingSource(raw string) string { + if strings.EqualFold(strings.TrimSpace(raw), "none") { + return "none" + } + return NormalizeMaxReasoningEffort(raw) +} + +func normalizeReasoningEffortMappingTarget(raw string) string { + if isReasoningEffortMappingDeny(raw) { + return ReasoningEffortMappingDeny + } + return NormalizeMaxReasoningEffort(raw) +} + +func isReasoningEffortMappingDeny(raw string) bool { + return strings.EqualFold(strings.TrimSpace(raw), ReasoningEffortMappingDeny) +} type openAIReasoningEffortPolicyContextKey struct{} type requestedReasoningEffortContextKey struct{} @@ -57,6 +80,37 @@ func (e *ReasoningEffortOverLimitError) Error() string { return fmt.Sprintf("reasoning effort %q exceeds this group's limit of %q", requested, max) } +// ReasoningEffortMappingDeniedError is returned when a group mapping target is +// set to deny the explicit reasoning effort on the request. +type ReasoningEffortMappingDeniedError struct { + Requested string +} + +func (e *ReasoningEffortMappingDeniedError) Error() string { + if e == nil { + return "reasoning effort is denied by this group's mapping policy" + } + requested := strings.TrimSpace(e.Requested) + if requested == "" { + return "reasoning effort is denied by this group's mapping policy" + } + return fmt.Sprintf("reasoning effort %q is denied by this group's mapping policy", requested) +} + +// IsReasoningEffortPolicyDenied reports whether err is a local reasoning-effort +// policy rejection (ceiling deny or mapping deny). +func IsReasoningEffortPolicyDenied(err error) bool { + if err == nil { + return false + } + var overLimit *ReasoningEffortOverLimitError + if errors.As(err, &overLimit) { + return true + } + var mappingDenied *ReasoningEffortMappingDeniedError + return errors.As(err, &mappingDenied) +} + // NormalizeMaxReasoningEffort validates and canonicalizes a group policy value. // Empty means that the group does not impose a ceiling. func NormalizeMaxReasoningEffort(raw string) string { @@ -83,10 +137,14 @@ func NormalizeMaxReasoningEffort(raw string) string { } func reasoningEffortValuesForPlatform(platform string) []string { - if platform != PlatformOpenAI && platform != PlatformComposite { + switch platform { + case PlatformOpenAI, PlatformComposite: + return openAIReasoningEffortValues + case PlatformAnthropic: + return anthropicReasoningEffortValues + default: return nil } - return openAIReasoningEffortValues } func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error) { @@ -97,7 +155,8 @@ func normalizeMaxReasoningEffortForPlatform(platform, raw string) (string, error allowedValues := reasoningEffortValuesForPlatform(platform) if len(allowedValues) == 0 { return "", fmt.Errorf( - "reasoning effort policy is only supported for platforms %q and %q", + "reasoning effort policy is only supported for platforms %q, %q, and %q", + PlatformAnthropic, PlatformOpenAI, PlatformComposite, ) @@ -145,7 +204,8 @@ func normalizeMaxReasoningEffortOverLimitForPlatform(platform, raw string) (stri } if len(reasoningEffortValuesForPlatform(platform)) == 0 { return "", fmt.Errorf( - "reasoning effort policy is only supported for platforms %q and %q", + "reasoning effort policy is only supported for platforms %q, %q, and %q", + PlatformAnthropic, PlatformOpenAI, PlatformComposite, ) @@ -220,7 +280,7 @@ func selectReasoningEffortMapping(mappings []ReasoningEffortMapping, from, reque } candidates := make([]candidate, 0, len(mappings)) for i, mapping := range mappings { - if NormalizeMaxReasoningEffort(mapping.From) != from { + if normalizeReasoningEffortMappingSource(mapping.From) != from { continue } model := strings.TrimSpace(mapping.Model) @@ -272,7 +332,7 @@ func selectReasoningEffortMapping(mappings []ReasoningEffortMapping, from, reque } // NormalizeReasoningEffortMappings validates group mapping rules against the -// fixed effort values supported by OpenAI routes. Optional model scopes are +// fixed effort values supported by the selected platform. Optional model scopes are // canonicalized to exact/prefix/suffix match; empty type and model keep the // global all-models rule. func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapping) ([]ReasoningEffortMapping, error) { @@ -283,19 +343,36 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp normalized := make([]ReasoningEffortMapping, 0, len(raw)) seen := make(map[string]struct{}, len(raw)) for i, mapping := range raw { - from := NormalizeMaxReasoningEffort(mapping.From) - to := NormalizeMaxReasoningEffort(mapping.To) + from := normalizeReasoningEffortMappingSource(mapping.From) + to := normalizeReasoningEffortMappingTarget(mapping.To) if from == "" || to == "" { return nil, fmt.Errorf("reasoning effort mapping %d contains an empty or unknown value", i+1) } if len(from) > maxReasoningEffortValueLen || len(to) > maxReasoningEffortValueLen { return nil, fmt.Errorf("reasoning effort mapping %d values cannot exceed %d characters", i+1, maxReasoningEffortValueLen) } - if _, err := normalizeMaxReasoningEffortForPlatform(platform, from); err != nil { - return nil, fmt.Errorf("reasoning effort mapping %d source: %w", i+1, err) + if from != "none" { + if _, err := normalizeMaxReasoningEffortForPlatform(platform, from); err != nil { + return nil, fmt.Errorf("reasoning effort mapping %d source: %w", i+1, err) + } + } else if len(reasoningEffortValuesForPlatform(platform)) == 0 { + return nil, fmt.Errorf( + "reasoning effort mapping %d source: reasoning effort policy is only supported for platforms %q and %q", + i+1, PlatformOpenAI, PlatformComposite, + ) } - if _, err := normalizeMaxReasoningEffortForPlatform(platform, to); err != nil { - return nil, fmt.Errorf("reasoning effort mapping %d target: %w", i+1, err) + if to != ReasoningEffortMappingDeny { + if _, err := normalizeMaxReasoningEffortForPlatform(platform, to); err != nil { + return nil, fmt.Errorf("reasoning effort mapping %d target: %w", i+1, err) + } + } else if len(reasoningEffortValuesForPlatform(platform)) == 0 { + return nil, fmt.Errorf( + "reasoning effort mapping %d target: reasoning effort policy is only supported for platforms %q, %q, and %q", + i+1, + PlatformAnthropic, + PlatformOpenAI, + PlatformComposite, + ) } model := strings.TrimSpace(mapping.Model) if len(model) > maxReasoningEffortModelLen { @@ -383,7 +460,7 @@ func ApplyOpenAIReasoningEffortPolicyFromContext(ctx context.Context, body []byt func mapReasoningEffort(raw string, mappings []ReasoningEffortMapping, requestModel string) (string, bool) { value := strings.TrimSpace(raw) - canonical := NormalizeMaxReasoningEffort(value) + canonical := normalizeReasoningEffortMappingSource(value) if canonical == "" { return value, false } @@ -407,7 +484,7 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) { if mappingsErr != nil { mappings = []ReasoningEffortMapping{} } - if overLimit == "" || (group.Platform != PlatformOpenAI && group.Platform != PlatformComposite) { + if overLimit == "" || (group.Platform != PlatformAnthropic && group.Platform != PlatformOpenAI && group.Platform != PlatformComposite) { overLimit = ReasoningEffortOverLimitDowngrade } group.MaxReasoningEffort = maxEffort @@ -415,12 +492,14 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) { group.ReasoningEffortMappings = mappings } -// ApplyOpenAIReasoningEffortPolicy applies one mapping (optionally scoped to +// ApplyReasoningEffortPolicy applies one mapping (optionally scoped to // the request model by exact name, prefix, or suffix) and then either caps // known effort levels or rejects the request when the group is configured to -// deny values above the ceiling. Omitted values remain untouched so upstream -// defaults stay in control. -func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) { +// deny values above the ceiling. A mapping whose target is deny rejects the +// request when that source value is present. Omitted values remain untouched +// so upstream defaults stay in control. It understands both OpenAI and +// Anthropic request field shapes. +func ApplyReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) { maxRank, hasMax := reasoningEffortRank(maxEffort) if len(body) == 0 || (!hasMax && len(mappings) == 0) { return body, false, nil @@ -431,7 +510,7 @@ func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings [] requestModel := strings.TrimSpace(gjson.GetBytes(body, "model").String()) result := body changed := false - for _, path := range []string{"reasoning.effort", "reasoning_effort"} { + for _, path := range []string{"reasoning.effort", "reasoning_effort", "output_config.effort"} { field := gjson.GetBytes(result, path) if !field.Exists() || field.Type != gjson.String { continue @@ -441,7 +520,14 @@ func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings [] continue } - effective, _ := mapReasoningEffort(original, mappings, requestModel) + effective, mapped := mapReasoningEffort(original, mappings, requestModel) + if mapped && isReasoningEffortMappingDeny(effective) { + requested := normalizeReasoningEffortMappingSource(original) + if requested == "" { + requested = original + } + return body, false, &ReasoningEffortMappingDeniedError{Requested: requested} + } if currentRank, recognized := reasoningEffortRank(effective); recognized { effective = NormalizeMaxReasoningEffort(effective) if hasMax && currentRank > maxRank { @@ -478,3 +564,8 @@ func applyOpenAIWSReasoningEffortPolicy(payload []byte, hooks *OpenAIWSIngressHo } return payload, nil } + +// ApplyOpenAIReasoningEffortPolicy is retained for OpenAI forwarding callers. +func ApplyOpenAIReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) { + return ApplyReasoningEffortPolicy(body, maxEffort, mappings, overLimit) +} diff --git a/backend/internal/service/openai_reasoning_effort_policy_test.go b/backend/internal/service/openai_reasoning_effort_policy_test.go index fae609d7c1..62cc394c3d 100644 --- a/backend/internal/service/openai_reasoning_effort_policy_test.go +++ b/backend/internal/service/openai_reasoning_effort_policy_test.go @@ -147,17 +147,55 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) { }) t.Run("rejects mappings for non OpenAI platforms", func(t *testing.T) { - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} { + for _, platform := range []string{PlatformGemini, PlatformAntigravity, PlatformGrok} { _, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: "low", To: "high"}}) - require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") + require.ErrorContains(t, err, "only supported for platforms") } - _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "none", To: "low"}}) + _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "ultra", To: "high"}}) require.ErrorContains(t, err, "empty or unknown") + }) - _, err = NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "ultra", To: "high"}}) + t.Run("allows none only as a source for OpenAI routes", func(t *testing.T) { + for _, platform := range []string{PlatformOpenAI, PlatformComposite} { + got, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: " NONE ", To: "low"}}) + require.NoError(t, err) + require.Equal(t, []ReasoningEffortMapping{{From: "none", To: "low"}}, got) + } + + _, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{{From: "low", To: "none"}}) require.ErrorContains(t, err, "empty or unknown") }) + + t.Run("allows deny as a mapping target", func(t *testing.T) { + for _, platform := range []string{PlatformOpenAI, PlatformComposite, PlatformAnthropic} { + got, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: " XHIGH ", To: " DENY "}}) + require.NoError(t, err) + require.Equal(t, []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}}, got) + } + + got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{ + {From: "none", To: "deny"}, + {From: "max", To: "deny", MatchType: "prefix", Model: "gpt"}, + }) + require.NoError(t, err) + require.Equal(t, []ReasoningEffortMapping{ + {From: "none", To: ReasoningEffortMappingDeny}, + {From: "max", To: ReasoningEffortMappingDeny, MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"}, + }, got) + + _, err = NormalizeReasoningEffortMappings(PlatformGemini, []ReasoningEffortMapping{{From: "high", To: "deny"}}) + require.ErrorContains(t, err, "only supported for platforms") + }) + + t.Run("supports Anthropic values except minimal", func(t *testing.T) { + got, err := NormalizeReasoningEffortMappings(PlatformAnthropic, []ReasoningEffortMapping{{From: " MAX ", To: " x-high "}}) + require.NoError(t, err) + require.Equal(t, []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, got) + + _, err = NormalizeReasoningEffortMappings(PlatformAnthropic, []ReasoningEffortMapping{{From: "minimal", To: "low"}}) + require.ErrorContains(t, err, "not supported for platform \"anthropic\"") + }) } func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) { @@ -168,9 +206,15 @@ func TestNormalizeMaxReasoningEffortForPlatform(t *testing.T) { require.NoError(t, err) require.Equal(t, "max", value) - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok} { + value, err = normalizeMaxReasoningEffortForPlatform(PlatformAnthropic, "xhigh") + require.NoError(t, err) + require.Equal(t, "xhigh", value) + _, err = normalizeMaxReasoningEffortForPlatform(PlatformAnthropic, "minimal") + require.ErrorContains(t, err, "not supported") + + for _, platform := range []string{PlatformGemini, PlatformAntigravity, PlatformGrok} { _, err = normalizeMaxReasoningEffortForPlatform(platform, "low") - require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") + require.ErrorContains(t, err, "only supported for platforms") } _, err = normalizeMaxReasoningEffortForPlatform(PlatformOpenAI, "none") @@ -197,8 +241,9 @@ func TestNormalizeMaxReasoningEffortOverLimitForPlatform(t *testing.T) { require.NoError(t, err) require.Equal(t, ReasoningEffortOverLimitDowngrade, value) - _, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformAnthropic, "deny") - require.ErrorContains(t, err, "only supported for platforms \"openai\" and \"composite\"") + value, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformAnthropic, "deny") + require.NoError(t, err) + require.Equal(t, ReasoningEffortOverLimitDeny, value) _, err = normalizeMaxReasoningEffortOverLimitForPlatform(PlatformOpenAI, "block") require.ErrorContains(t, err, "not supported") @@ -231,18 +276,21 @@ func TestOpenAIReasoningEffortPolicyContext(t *testing.T) { func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { tests := []struct { - name string - body string - max string - overLimit string - mappings []ReasoningEffortMapping - path string - want string - changed bool - deny bool + name string + body string + max string + overLimit string + mappings []ReasoningEffortMapping + path string + want string + changed bool + deny bool + mappingDeny bool }{ {name: "nested caps high", body: `{"reasoning":{"effort":"xhigh"}}`, max: "medium", path: "reasoning.effort", want: "medium", changed: true}, {name: "flat caps high", body: `{"reasoning_effort":"high"}`, max: "low", path: "reasoning_effort", want: "low", changed: true}, + {name: "Anthropic output config caps max", body: `{"output_config":{"effort":"max"}}`, max: "xhigh", path: "output_config.effort", want: "xhigh", changed: true}, + {name: "Anthropic output config maps before cap", body: `{"output_config":{"effort":"max"}}`, max: "high", mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "output_config.effort", want: "high", changed: true}, {name: "does not raise omitted", body: `{"model":"gpt-5"}`, max: "low", path: "reasoning_effort", want: "", changed: false}, {name: "keeps lower value", body: `{"reasoning_effort":"low"}`, max: "high", path: "reasoning_effort", want: "low", changed: false}, {name: "normalizes request alias", body: `{"reasoning_effort":"x-high"}`, max: "xhigh", path: "reasoning_effort", want: "xhigh", changed: true}, @@ -252,6 +300,15 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { {name: "caps both shapes", body: `{"reasoning":{"effort":"high"},"reasoning_effort":"xhigh"}`, max: "low", path: "reasoning.effort", want: "low", changed: true}, {name: "maps before cap", body: `{"reasoning":{"effort":"MAX"}}`, max: "medium", mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "reasoning.effort", want: "medium", changed: true}, {name: "does not chain mappings", body: `{"reasoning_effort":"max"}`, mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}, {From: "xhigh", To: "low"}}, path: "reasoning_effort", want: "xhigh", changed: true}, + {name: "maps nested none", body: `{"reasoning":{"effort":"none"}}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low"}}, path: "reasoning.effort", want: "low", changed: true}, + {name: "maps flat none", body: `{"reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low"}}, path: "reasoning_effort", want: "low", changed: true}, + {name: "none mapping runs before cap", body: `{"reasoning_effort":"none"}`, max: "low", mappings: []ReasoningEffortMapping{{From: "none", To: "high"}}, path: "reasoning_effort", want: "low", changed: true}, + {name: "keeps none byte for byte without mappings", body: `{ "reasoning_effort": " none " }`, max: "low", path: "reasoning_effort", want: " none ", changed: false}, + {name: "keeps none byte for byte when scope does not match", body: `{ "model":"o3", "reasoning_effort":"none" }`, mappings: []ReasoningEffortMapping{{From: "none", To: "low", MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5"}}, path: "reasoning_effort", want: "none", changed: false}, + {name: "exact scope maps none", body: `{"model":"gpt-5","reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low", MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5"}}, path: "reasoning_effort", want: "low", changed: true}, + {name: "prefix scope maps none", body: `{"model":"gpt-5-mini","reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low", MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt-5"}}, path: "reasoning_effort", want: "low", changed: true}, + {name: "suffix scope maps none", body: `{"model":"gpt-5-mini","reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low", MatchType: domain.ReasoningEffortMatchSuffix, Model: "mini"}}, path: "reasoning_effort", want: "low", changed: true}, + {name: "global scope maps none", body: `{"model":"o3","reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: "low"}}, path: "reasoning_effort", want: "low", changed: true}, {name: "keeps unknown without mapping", body: `{"reasoning_effort":"future"}`, max: "low", path: "reasoning_effort", want: "future", changed: false}, {name: "keeps non string value", body: `{"reasoning_effort":{"level":"high"}}`, max: "low", path: "reasoning_effort.level", want: "high", changed: false}, { @@ -384,6 +441,41 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { {name: "deny after mapping still over ceiling", body: `{"reasoning":{"effort":"max"}}`, max: "medium", overLimit: ReasoningEffortOverLimitDeny, mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "reasoning.effort", want: "max", deny: true}, {name: "deny allows mapping under ceiling", body: `{"reasoning":{"effort":"max"}}`, max: "medium", overLimit: ReasoningEffortOverLimitDeny, mappings: []ReasoningEffortMapping{{From: "max", To: "low"}}, path: "reasoning.effort", want: "low", changed: true}, {name: "deny ignored without ceiling", body: `{"reasoning_effort":"high"}`, overLimit: ReasoningEffortOverLimitDeny, path: "reasoning_effort", want: "high"}, + {name: "mapping deny rejects matching nested effort", body: `{"reasoning":{"effort":"xhigh"}}`, mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}}, path: "reasoning.effort", want: "xhigh", mappingDeny: true}, + {name: "mapping deny rejects matching flat effort", body: `{"reasoning_effort":"X-HIGH"}`, mappings: []ReasoningEffortMapping{{From: "xhigh", To: " DENY "}}, path: "reasoning_effort", want: "X-HIGH", mappingDeny: true}, + {name: "mapping deny rejects Anthropic output config", body: `{"output_config":{"effort":"max"}}`, mappings: []ReasoningEffortMapping{{From: "max", To: ReasoningEffortMappingDeny}}, path: "output_config.effort", want: "max", mappingDeny: true}, + {name: "mapping deny rejects none source", body: `{"reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: ReasoningEffortMappingDeny}}, path: "reasoning_effort", want: "none", mappingDeny: true}, + { + name: "mapping deny respects model scope", + body: `{"model":"gpt-5.6-sol","reasoning":{"effort":"xhigh"}}`, + mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny, MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt-5.6-sol"}}, + path: "reasoning.effort", + want: "xhigh", + mappingDeny: true, + }, + { + name: "mapping deny skips non matching model", + body: `{"model":"gpt-5.6-terra","reasoning":{"effort":"xhigh"}}`, + mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny, MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5.6-sol"}}, + path: "reasoning.effort", + want: "xhigh", + }, + { + name: "mapping deny leaves other efforts unchanged", + body: `{"reasoning_effort":"high"}`, + mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}}, + path: "reasoning_effort", + want: "high", + }, + { + name: "mapping deny takes precedence over ceiling rewrite", + body: `{"reasoning":{"effort":"xhigh"}}`, + max: "medium", + mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}}, + path: "reasoning.effort", + want: "xhigh", + mappingDeny: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -392,15 +484,43 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { require.Error(t, err) var overLimit *ReasoningEffortOverLimitError require.ErrorAs(t, err, &overLimit) + require.True(t, IsReasoningEffortPolicyDenied(err)) + require.False(t, changed) + require.Equal(t, tt.body, string(got)) + return + } + if tt.mappingDeny { + require.Error(t, err) + var mappingDenied *ReasoningEffortMappingDeniedError + require.ErrorAs(t, err, &mappingDenied) + require.True(t, IsReasoningEffortPolicyDenied(err)) + require.Contains(t, mappingDenied.Error(), "denied by this group's mapping policy") + if requested := normalizeReasoningEffortMappingSource(tt.want); requested != "" { + require.Equal(t, requested, mappingDenied.Requested) + } require.False(t, changed) require.Equal(t, tt.body, string(got)) return } require.NoError(t, err) require.Equal(t, tt.changed, changed) + if !tt.changed { + require.Equal(t, tt.body, string(got)) + } if tt.path != "" { require.Equal(t, tt.want, gjson.GetBytes(got, tt.path).String()) } }) } } + +func TestReasoningEffortMappingDeniedError(t *testing.T) { + t.Parallel() + + err := &ReasoningEffortMappingDeniedError{Requested: "xhigh"} + require.Equal(t, `reasoning effort "xhigh" is denied by this group's mapping policy`, err.Error()) + require.True(t, IsReasoningEffortPolicyDenied(err)) + require.True(t, IsReasoningEffortPolicyDenied(&ReasoningEffortOverLimitError{Requested: "max", Max: "low"})) + require.False(t, IsReasoningEffortPolicyDenied(nil)) + require.False(t, IsReasoningEffortPolicyDenied(context.Canceled)) +} diff --git a/backend/internal/service/openai_responses_input_compat.go b/backend/internal/service/openai_responses_input_compat.go index e8c4a8116d..29619bc4dd 100644 --- a/backend/internal/service/openai_responses_input_compat.go +++ b/backend/internal/service/openai_responses_input_compat.go @@ -7,7 +7,8 @@ import ( const openAIResponsesInputTextMaxChars = 10000000 // sanitizeOpenAIResponsesOrphanToolOutputs removes tool-output items that have -// no matching call or item reference anywhere in the current input. +// no matching call or item reference anywhere in the current input. Named +// function outputs without a call ID are standalone inputs, not orphan results. func sanitizeOpenAIResponsesOrphanToolOutputs(reqBody map[string]any, input []any, hasPreviousResponseID bool) bool { if len(input) == 0 || hasPreviousResponseID { return false @@ -45,6 +46,15 @@ func sanitizeOpenAIResponsesOrphanToolOutputs(reqBody map[string]any, input []an } callID := strings.TrimSpace(firstNonEmptyString(item["call_id"])) + // Codex sends externally supplied inputs (such as task delegation) as + // named function outputs without a preceding function call. Preserve + // their native type, namespace and output instead of silently dropping + // the request that started the turn. + if callID == "" && strings.TrimSpace(firstNonEmptyString(item["type"])) == "function_call_output" && + strings.TrimSpace(firstNonEmptyString(item["name"])) != "" { + normalized = append(normalized, rawItem) + continue + } _, hasToolCall := toolCallIDs[callID] _, hasReference := referenceIDs[callID] if callID != "" && (hasToolCall || hasReference) { diff --git a/backend/internal/service/openai_responses_input_compat_test.go b/backend/internal/service/openai_responses_input_compat_test.go index fc9fa7418a..e0d01c03d7 100644 --- a/backend/internal/service/openai_responses_input_compat_test.go +++ b/backend/internal/service/openai_responses_input_compat_test.go @@ -2,6 +2,8 @@ package service import ( "context" + "encoding/json" + "fmt" "net/http" "strings" "testing" @@ -11,6 +13,21 @@ import ( ) func TestSanitizeOpenAIResponsesOrphanToolOutputs(t *testing.T) { + t.Run("named standalone inputs do not legitimize orphan results", func(t *testing.T) { + named := map[string]any{"type": "function_call_output", "name": "send_message_to_thread", "namespace": "codex_app", "output": "delegation"} + input := []any{ + named, + map[string]any{"type": "function_call_output", "output": "missing name and call id"}, + map[string]any{"type": "function_call_output", "name": " ", "output": "blank name"}, + map[string]any{"type": "function_call_output", "name": "send_message_to_thread", "call_id": "missing", "output": "orphan result"}, + map[string]any{"type": "custom_tool_call_output", "name": "apply_patch", "output": "missing call id"}, + } + reqBody := map[string]any{"input": input} + + require.True(t, sanitizeOpenAIResponsesOrphanToolOutputs(reqBody, input, false)) + require.Equal(t, []any{named}, reqBody["input"]) + }) + t.Run("preserves matches regardless of item order", func(t *testing.T) { input := []any{ map[string]any{"type": "tool_search_output", "call_id": "search_1", "output": "first"}, @@ -71,6 +88,48 @@ func TestSanitizeOpenAIResponsesOrphanToolOutputs(t *testing.T) { }) } +func TestWebSocketCompatibilityPreservesNamedDelegation(t *testing.T) { + for _, toolName := range []string{"create_thread", "send_message_to_thread"} { + for _, withHistory := range []bool{false, true} { + for _, previousResponseID := range []string{"", "resp_previous"} { + name := fmt.Sprintf("%s/history=%t/previous=%t", toolName, withHistory, previousResponseID != "") + t.Run(name, func(t *testing.T) { + input := []any{} + if withHistory { + input = append(input, + map[string]any{"type": "function_call", "call_id": "fc_history", "name": "lookup", "arguments": "{}"}, + map[string]any{"type": "function_call_output", "call_id": "fc_history", "output": "historical result"}, + ) + } + input = append(input, + map[string]any{"type": "message", "role": "user", "content": []any{map[string]any{"type": "input_text", "text": "Updated environment context."}}}, + map[string]any{"type": "function_call_output", "name": toolName, "namespace": "codex_app", "output": "\n source-task\n Reply with DELEGATION_OK.\n"}, + ) + reqBody := map[string]any{"type": "response.create", "model": "gpt-5.5", "input": input} + if previousResponseID != "" { + reqBody["previous_response_id"] = previousResponseID + } + body, err := json.Marshal(reqBody) + require.NoError(t, err) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + + normalized, _, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, false) + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(normalized, &got)) + require.Equal(t, input, got["input"], "preserve the delegation envelope, native type and history in order") + require.Equal(t, reqBody["previous_response_id"], got["previous_response_id"]) + + again, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account, false) + require.NoError(t, err) + require.False(t, changed, "normalization must be idempotent") + require.JSONEq(t, string(normalized), string(again)) + }) + } + } + } +} + func TestOpenAIResponsesInputTextIsNeverSilentlyTruncated(t *testing.T) { atLimit := strings.Repeat("z", openAIResponsesInputTextMaxChars) oversized := strings.Repeat("a", openAIResponsesInputTextMaxChars) + "中" diff --git a/backend/internal/service/openai_responses_tool_schema_test.go b/backend/internal/service/openai_responses_tool_schema_test.go index b2dfba46bc..938ab59ab7 100644 --- a/backend/internal/service/openai_responses_tool_schema_test.go +++ b/backend/internal/service/openai_responses_tool_schema_test.go @@ -270,7 +270,7 @@ func TestSanitizeOpenAIResponsesToolSchemasForPlatform_ReplayBoundary(t *testing // A malformed tool definition may be replayed after account failover. Every // compatible account must repair it, while non-OpenAI providers retain their // supported regex semantics. - for _, platform := range []string{PlatformAnthropic, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} { + for _, platform := range []string{PlatformAnthropic, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { t.Run(platform, func(t *testing.T) { for attempt := 0; attempt < 2; attempt++ { normalized, changed, err := sanitizeOpenAIResponsesToolSchemasForPlatform(body, platform) diff --git a/backend/internal/service/openai_routing_hint.go b/backend/internal/service/openai_routing_hint.go index 85fd93314d..c66b941149 100644 --- a/backend/internal/service/openai_routing_hint.go +++ b/backend/internal/service/openai_routing_hint.go @@ -37,14 +37,14 @@ func setOpenAICodexRoutingHint(headers http.Header, account *Account, model stri // Codex treats "default" as an explicit standard-routing sentinel, not as a // service tier sent to the backend. Fast follows the gateway's existing - // canonicalization and therefore becomes "priority"; flex stays "flex". + // canonicalization and therefore becomes "priority"; flex and ultrafast + // stay as themselves. canonicalTier := normalizedOpenAIServiceTierValue(serviceTier) - // This backport has no Codex model-catalog snapshot with which to validate - // arbitrary tier ids. Keep the hint to the two effective tiers Codex itself - // selects; default, missing, and other gateway-compatible API values remain - // model-only rather than expanding the ChatGPT routing protocol here. + // Keep the hint to the effective tiers Codex itself selects. default, + // missing, auto, and scale remain model-only rather than expanding the + // ChatGPT routing protocol here. switch canonicalTier { - case OpenAIFastTierPriority, OpenAIFastTierFlex: + case OpenAIFastTierPriority, OpenAIFastTierFlex, OpenAIFastTierUltrafast: default: canonicalTier = "" } diff --git a/backend/internal/service/openai_routing_hint_test.go b/backend/internal/service/openai_routing_hint_test.go index 5801b15fa4..343d227357 100644 --- a/backend/internal/service/openai_routing_hint_test.go +++ b/backend/internal/service/openai_routing_hint_test.go @@ -24,6 +24,7 @@ func TestSetOpenAICodexRoutingHintCanonicalizesOfficialServiceTiers(t *testing.T {name: "fast alias", model: "gpt-5.6", serviceTier: " fast ", want: "model=gpt-5.6;tier=priority"}, {name: "priority", model: "gpt-5.6", serviceTier: "priority", want: "model=gpt-5.6;tier=priority"}, {name: "flex", model: "gpt-5.6", serviceTier: "flex", want: "model=gpt-5.6;tier=flex"}, + {name: "ultrafast", model: "gpt-5.6", serviceTier: "ultrafast", want: "model=gpt-5.6;tier=ultrafast"}, {name: "explicit default sentinel", model: "gpt-5.6", serviceTier: "default", want: "model=gpt-5.6"}, {name: "omitted tier", model: "gpt-5.6", want: "model=gpt-5.6"}, {name: "auto is not expanded without catalog support", model: "gpt-5.6", serviceTier: "auto", want: "model=gpt-5.6"}, @@ -90,6 +91,7 @@ func TestOpenAIOAuthHTTPBuildersSendRoutingHintFromFinalBody(t *testing.T) { }{ {name: "fast", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"fast"}`), want: "model=gpt-5.6-codex;tier=priority"}, {name: "flex", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"flex"}`), want: "model=gpt-5.6-codex;tier=flex"}, + {name: "ultrafast", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"ultrafast"}`), want: "model=gpt-5.6-codex;tier=ultrafast"}, {name: "default", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"default"}`), want: "model=gpt-5.6-codex"}, {name: "omitted", body: []byte(`{"model":"gpt-5.6-codex"}`), want: "model=gpt-5.6-codex"}, } @@ -216,6 +218,7 @@ func TestBuildOpenAIWSHeadersSendsOAuthRoutingHintOnly(t *testing.T) { }, } require.Equal(t, "model=gpt-5.6-codex;tier=priority", build(t, oauthAccount, "fast").Get(openAICodexRoutingHintHeader)) + require.Equal(t, "model=gpt-5.6-codex;tier=ultrafast", build(t, oauthAccount, "ultrafast").Get(openAICodexRoutingHintHeader)) require.Equal(t, "model=gpt-5.6-codex", build(t, oauthAccount, "default").Get(openAICodexRoutingHintHeader)) require.Empty(t, build(t, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, "priority").Get(openAICodexRoutingHintHeader)) } diff --git a/backend/internal/service/openai_upstream_client_error.go b/backend/internal/service/openai_upstream_client_error.go index 97690e0ab7..431f5a7703 100644 --- a/backend/internal/service/openai_upstream_client_error.go +++ b/backend/internal/service/openai_upstream_client_error.go @@ -55,3 +55,9 @@ func writeOpenAIUpstreamClientError(c *gin.Context, statusCode int, body []byte, c.JSON(statusCode, gin.H{"error": errorPayload}) } + +// WriteOpenAIUpstreamClientError preserves a structured deterministic upstream +// client error when the handler has exhausted all eligible accounts. +func WriteOpenAIUpstreamClientError(c *gin.Context, statusCode int, body []byte, upstreamMsg string) { + writeOpenAIUpstreamClientError(c, statusCode, body, upstreamMsg) +} diff --git a/backend/internal/service/openai_upstream_client_error_test.go b/backend/internal/service/openai_upstream_client_error_test.go index 291c1c491e..cd50e700a6 100644 --- a/backend/internal/service/openai_upstream_client_error_test.go +++ b/backend/internal/service/openai_upstream_client_error_test.go @@ -45,6 +45,67 @@ func newOpenAIUpstreamErrorTestAccount() *Account { return &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "acct"} } +type modelNotFoundManagedAccountRepo struct{ AccountRepository } + +func TestOpenAICompatibleModelNotFound400FailoverScope(t *testing.T) { + svc := &OpenAIGatewayService{accountRepo: &modelNotFoundManagedAccountRepo{}} + body := []byte(`{"error":{"code":"model_not_found","message":"model not found"}}`) + + for _, tc := range []struct { + name string + account *Account + want bool + }{ + {name: "openai api key", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, want: true}, + {name: "compatible provider", account: &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}, want: true}, + {name: "anthropic account", account: &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey}, want: false}, + {name: "missing account", account: nil, want: false}, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, svc.shouldFailoverOpenAIUpstreamResponse( + tc.account, http.StatusBadRequest, "model not found", body, + )) + }) + } + + require.False(t, svc.shouldFailoverOpenAIUpstreamResponse( + &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, + http.StatusBadRequest, + "Invalid value for temperature", + []byte(`{"error":{"code":"invalid_request_error","message":"Invalid value for temperature"}}`), + )) +} + +func TestOpenAICompatibleModelNotFound400WithoutManagedCandidatesRemainsTerminal(t *testing.T) { + svc := &OpenAIGatewayService{} + body := []byte(`{"error":{"code":"model_not_found","message":"model not found"}}`) + + require.False(t, svc.shouldFailoverOpenAIUpstreamResponse( + &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, + http.StatusBadRequest, + "model not found", + body, + )) +} + +func TestFailoverOpenAIUpstreamHTTPError_ModelNotFoundIsNextAccountEligible(t *testing.T) { + c, _ := newOpenAIUpstreamErrorTestContext(t) + svc := &OpenAIGatewayService{cfg: &config.Config{}, accountRepo: &modelNotFoundManagedAccountRepo{}} + account := &Account{ID: 42, Name: "compatible", Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + body := []byte(`{"error":{"code":"model_not_found","message":"model not found"}}`) + resp := newOpenAIUpstreamErrorResponse(http.StatusBadRequest, string(body)) + + failoverErr := svc.failoverOpenAIUpstreamHTTPError( + context.Background(), c, account, resp, body, "model not found", "missing-model", + ) + + require.NotNil(t, failoverErr) + require.Equal(t, http.StatusBadRequest, failoverErr.StatusCode) + require.Equal(t, body, failoverErr.ResponseBody) + require.True(t, failoverErr.ShouldRetryNextAccount()) + require.False(t, IsResponseCommitted(c), "failover must remain eligible before writing downstream") +} + // 主复现:原生 Responses 路径必须回真实的 400 与上游诊断信息,而不是可重试的 502。 // // 归一成 502 时下游网关(CCH 等)会把确定性的 Schema 错误当成临时上游故障重试, diff --git a/backend/internal/service/openai_upstream_transport_error.go b/backend/internal/service/openai_upstream_transport_error.go index 6072704f5b..752b6624ae 100644 --- a/backend/internal/service/openai_upstream_transport_error.go +++ b/backend/internal/service/openai_upstream_transport_error.go @@ -165,8 +165,9 @@ func (s *OpenAIGatewayService) tempUnscheduleOpenAITransportError(ctx context.Co until := time.Now().Add(openAITransportErrorTempUnschedDuration) reason := "upstream transport error (proxy/network): " + safeErr - // Immediate in-memory block (honoured by the scheduler at selection time), - // effective even if the DB write below fails or the account cache lags. + // Immediate in-memory block so this process skips the account until the + // persisted cooldown is visible on the scheduling Account. Selection is + // fail-open: empty snapshot/DB cooldown fields drop a stale local block. s.BlockAccountScheduling(account, until, "transport_error") if s.accountRepo == nil { diff --git a/backend/internal/service/openai_ws_forwarder_client_cancel_test.go b/backend/internal/service/openai_ws_forwarder_client_cancel_test.go new file mode 100644 index 0000000000..7045c78d16 --- /dev/null +++ b/backend/internal/service/openai_ws_forwarder_client_cancel_test.go @@ -0,0 +1,184 @@ +package service + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// TestForwardOpenAIWSV2_ClientCancellationDrainsWithoutSyntheticFailure +// reproduces the HTTP/SSE ingress bug: the client cancels after partial output, +// while the upstream WS still has a terminal event available for usage billing. +func TestForwardOpenAIWSV2_ClientCancellationDrainsWithoutSyntheticFailure(t *testing.T) { + gin.SetMode(gin.TestMode) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + writer := &cancelOnFirstWriteResponseWriter{cancel: cancel} + c, _ := gin.CreateTestContext(writer) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil).WithContext(ctx) + c.Request.Header.Set("User-Agent", "unit-test-agent/1.0") + + captureConn := &openAIWSCancelSafeConn{openAIWSCaptureConn: &openAIWSCaptureConn{ + events: [][]byte{ + []byte(`{"type":"response.created","response":{"id":"resp_cancel_1","model":"gpt-5.5"}}`), + []byte(`{"type":"response.output_text.delta","delta":"partial"}`), + []byte(`{"type":"response.completed","response":{"id":"resp_cancel_1","model":"gpt-5.5","usage":{"input_tokens":3,"output_tokens":5}}}`), + }, + readDelays: []time.Duration{0, 0, 50 * time.Millisecond}, + }} + + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true + cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeCtxPool + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8 + cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(&openAIWSClientConnCancelDialer{conn: captureConn}) + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + account := &Account{ + ID: 9101, + Name: "openai-ws-client-cancel", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{ + "openai_apikey_responses_websockets_v2_mode": OpenAIWSIngressModeCtxPool, + }, + } + + result, err := svc.Forward(ctx, c, account, []byte(`{"model":"gpt-5.5","stream":true,"input":[{"type":"input_text","text":"hello"}]}`)) + + require.NoError(t, err, "client cancellation must not surface as an upstream failure") + require.NotNil(t, result) + require.True(t, result.ClientDisconnect) + require.Equal(t, "resp_cancel_1", result.RequestID) + require.Equal(t, 3, result.Usage.InputTokens) + require.Equal(t, 5, result.Usage.OutputTokens) + require.NotContains(t, writer.body.String(), "response.failed") +} + +type cancelOnFirstWriteResponseWriter struct { + mu sync.Mutex + header http.Header + body bytes.Buffer + cancel context.CancelFunc + writes int + status int +} + +func (w *cancelOnFirstWriteResponseWriter) Header() http.Header { + if w.header == nil { + w.header = make(http.Header) + } + return w.header +} + +func (w *cancelOnFirstWriteResponseWriter) WriteHeader(status int) { + w.status = status +} + +func (w *cancelOnFirstWriteResponseWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if w.writes == 0 && w.cancel != nil { + w.cancel() + } + w.writes++ + return w.body.Write(p) +} + +func (w *cancelOnFirstWriteResponseWriter) Flush() {} + +type openAIWSClientConnCancelDialer struct { + conn openAIWSClientConn +} + +func (d *openAIWSClientConnCancelDialer) Dial( + ctx context.Context, + wsURL string, + headers http.Header, + proxyURL string, +) (openAIWSClientConn, int, http.Header, error) { + return d.conn, 0, nil, nil +} + +// openAIWSCancelSafeConn leaves a delayed event queued when its read context is +// canceled, matching websocket frame semantics and allowing the drain read to +// consume that event with a detached context. +type openAIWSCancelSafeConn struct { + *openAIWSCaptureConn +} + +func (c *openAIWSCancelSafeConn) ReadMessage(ctx context.Context) ([]byte, error) { + if ctx == nil { + ctx = context.Background() + } + c.mu.Lock() + if c.closed { + c.mu.Unlock() + return nil, errOpenAIWSConnClosed + } + if len(c.events) == 0 { + c.mu.Unlock() + return nil, io.EOF + } + delay := time.Duration(0) + if len(c.readDelays) > 0 { + delay = c.readDelays[0] + } + event := c.events[0] + c.mu.Unlock() + if delay > 0 { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-timer.C: + } + } + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return nil, errOpenAIWSConnClosed + } + if len(c.events) == 0 { + return nil, io.EOF + } + c.events = c.events[1:] + if len(c.readDelays) > 0 { + c.readDelays = c.readDelays[1:] + } + return event, nil +} diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index e57c2dd899..f24dc25697 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -137,9 +137,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( forceHTTPBridge = true break } - // 透传 relay 通过 TurnStarted 记录每个 turn 的开始时刻,但不触发 - // BeforeTurn;因此仍只有建连时的利润准入门,没有 turn 级复核。 - // handler 计费在 turn 定价未冻结时回退到对应的 turn 开始时刻。 + // 首轮准入由握手路径完成;后续 response.create 会在写入上游前 + // 依次回调 BeforeRequest 和 BeforeTurn,并在终止或失败时回调 + // AfterTurn,从而覆盖 turn 级利润复核、定价冻结和并发槽位释放。 return s.proxyResponsesWebSocketV2Passthrough( ctx, c, @@ -521,6 +521,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return err } + useHTTPBridge := forceHTTPBridge || s.shouldBridgeOpenAIWSHTTP(account, firstPayload.payloadBytes, firstPayload.previousResponseID) turnState := strings.TrimSpace(c.GetHeader(openAIWSTurnStateHeader)) stateStore := s.getOpenAIWSStateStore() groupID := getOpenAIGroupIDFromContext(c) @@ -530,20 +531,25 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( storeDisabled := false refreshIngressRouteState := func(payload openAIWSClientPayload) { sessionHash = s.GenerateSessionHash(c, payload.rawForHash) + preferredConnID = "" + storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(payload.payloadRaw, account) + if useHTTPBridge { + // Sticky account affinity may be shared, but an HTTP bridge must not + // inherit another connection's native WS turn state or socket binding. + return + } if turnState == "" && stateStore != nil && sessionHash != "" { if savedTurnState, ok := stateStore.GetSessionTurnState(groupID, sessionHash); ok { turnState = savedTurnState } } - preferredConnID = "" if stateStore != nil && payload.previousResponseID != "" { if connID, ok := stateStore.GetResponseConn(payload.previousResponseID); ok { preferredConnID = connID } } - storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(payload.payloadRaw, account) if stateStore != nil && storeDisabled && payload.previousResponseID == "" && sessionHash != "" { if connID, ok := stateStore.GetSessionConn(groupID, sessionHash); ok { preferredConnID = connID @@ -552,7 +558,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } refreshIngressRouteState(firstPayload) - if forceHTTPBridge || s.shouldBridgeOpenAIWSHTTP(account, firstPayload.payloadBytes, firstPayload.previousResponseID) { + if useHTTPBridge { logOpenAIWSModeInfo( "ingress_ws_http_bridge_start account_id=%d account_type=%s payload_bytes=%d threshold_bytes=%d has_session_hash=%v store_disabled=%v", account.ID, @@ -585,29 +591,52 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if turnState != "" && c != nil && c.Request != nil { c.Request.Header.Set(openAIWSTurnStateHeader, turnState) } + if c != nil && sessionHash != "" { + c.Set(openAIWSIngressSessionHashContextKey, sessionHash) + } + // 剥离本会话已知失效的加密项,阻断同一失效密文随历史反复触发上游拒绝。 + // 历史序列须同步剥离,否则与已剥离的当前 input 项错位,prefix 复用失配。 + if invalidDigests := s.sessionInvalidEncryptedContentDigests(groupID, sessionHash); len(invalidDigests) > 0 { + strippedPayload, strippedCount := s.stripSessionInvalidEncryptedContentLogged( + currentBridgePayload.payloadRaw, invalidDigests, "ingress_ws_http_bridge_invalid_encrypted_lineage_strip", account.ID, turn, + ) + if strippedCount > 0 { + currentBridgePayload.payloadRaw = strippedPayload + currentBridgePayload.payloadBytes = len(strippedPayload) + } + if bridgeReplayInputExists { + bridgeReplayInput, _ = stripOpenAIInvalidEncryptedContentFromReplayItems(bridgeReplayInput, invalidDigests) + } + if bridgeAccountFailoverInputExists { + bridgeAccountFailoverInput, _ = stripOpenAIInvalidEncryptedContentFromReplayItems(bridgeAccountFailoverInput, invalidDigests) + } + } bridgePayloadRaw := currentBridgePayload.payloadRaw bridgePayloadBytes := currentBridgePayload.payloadBytes toolOutputCoverage := AnalyzeToolCallOutputContextCoverageBytes(currentBridgePayload.payloadRaw) needsBridgeReplay := currentBridgePayload.previousResponseID != "" || (toolOutputCoverage.HasFunctionCallOutput && !toolOutputCoverage.ContextCoversAllCallIDs) - turnReplayInput, turnReplayInputExists, replayInputErr := buildOpenAIWSReplayInputSequence( + // 一次解析当前 input,正常 replay 与 account-failover 两份序列共享同一批正文。 + bridgeCurrentItems, bridgeCurrentItemsExist, extractErr := openAIWSExtractNormalizedInputSequence( + currentBridgePayload.payloadRaw, + ) + if extractErr != nil { + return fmt.Errorf("build websocket http bridge replay input: %w", extractErr) + } + turnReplayInput, turnReplayInputExists := buildOpenAIWSReplayInputSequenceFromItems( bridgeReplayInput, bridgeReplayInputExists, - currentBridgePayload.payloadRaw, + bridgeCurrentItems, + bridgeCurrentItemsExist, needsBridgeReplay, ) - if replayInputErr != nil { - return fmt.Errorf("build websocket http bridge replay input: %w", replayInputErr) - } - turnAccountFailoverInput, turnAccountFailoverInputExists, failoverInputErr := buildOpenAIWSReplayInputSequence( + turnAccountFailoverInput, turnAccountFailoverInputExists := buildOpenAIWSReplayInputSequenceFromItems( bridgeAccountFailoverInput, bridgeAccountFailoverInputExists, - currentBridgePayload.payloadRaw, + bridgeCurrentItems, + bridgeCurrentItemsExist, needsBridgeReplay, ) - if failoverInputErr != nil { - return fmt.Errorf("build websocket account failover input: %w", failoverInputErr) - } if needsBridgeReplay && turnReplayInputExists { updatedPayload, setInputErr := setOpenAIWSPayloadInputSequence( currentBridgePayload.payloadRaw, @@ -684,26 +713,27 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if result == nil { return errors.New("websocket http bridge turn result is nil") } - bridgeReplayInput = cloneOpenAIWSRawMessages(turnReplayInput) + // turnReplayInput/turnAccountFailoverInput 可能共享同一头数组(转移自 + // bridgeCurrentItems),保存历史必须经 combine 新建头,禁止就地 append。 + bridgeReplayInput = turnReplayInput bridgeReplayInputExists = turnReplayInputExists if result.wsReplayInputExists { - bridgeReplayInput = append(bridgeReplayInput, cloneOpenAIWSRawMessages(result.wsReplayInput)...) + bridgeReplayInput = combineOpenAIWSReplayItems(bridgeReplayInput, result.wsReplayInput) bridgeReplayInputExists = true } - bridgeAccountFailoverInput = cloneOpenAIWSRawMessages(turnAccountFailoverInput) + bridgeAccountFailoverInput = turnAccountFailoverInput bridgeAccountFailoverInputExists = turnAccountFailoverInputExists if len(result.wsAccountFailoverReplayInput) > 0 { - bridgeAccountFailoverInput = append( + bridgeAccountFailoverInput = combineOpenAIWSReplayItems( bridgeAccountFailoverInput, - cloneOpenAIWSRawMessages(result.wsAccountFailoverReplayInput)..., + result.wsAccountFailoverReplayInput, ) bridgeAccountFailoverInputExists = true } if bridgeTurnState := strings.TrimSpace(result.ResponseHeaders.Get(openAIWSTurnStateHeader)); bridgeTurnState != "" { + // Follow-up turns on this bridge retain their own upstream state; + // publishing it by session hash would leak it to independent bridges. turnState = bridgeTurnState - if stateStore != nil && sessionHash != "" { - stateStore.BindSessionTurnState(groupID, sessionHash, bridgeTurnState, s.openAIWSSessionStickyTTL()) - } } responseID := strings.TrimSpace(result.RequestID) if responseID != "" && stateStore != nil { @@ -996,6 +1026,12 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } lastEventType = eventType } + if openAIWSMessageShouldParseUsage(eventType, upstreamMessage) { + parseOpenAIWSResponseUsageFromCompletedEvent(upstreamMessage, &usage) + } + if eventType == "error" || eventType == "response.failed" { + markOpenAICyberPolicyEvent(c, upstreamMessage, http.StatusOK, &usage) + } if eventType == "error" { s.handleOpenAIWSErrorEventTransientFailure(ctx, account, mappedModel, lease.HandshakeHeaders(), upstreamMessage) errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(upstreamMessage) @@ -1025,6 +1061,18 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } s.persistOpenAIWSRateLimitSignal(ctx, account, lease.HandshakeHeaders(), upstreamMessage, errCodeRaw, errTypeRaw, errMsgRaw, mappedModel) fallbackReason, _ := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) + if fallbackReason == openAIWSFallbackReasonInvalidEncryptedContent { + // 记录被上游拒绝的密文摘要;错误照旧透传,下一轮进场时按摘要预剥离。 + if digests := collectOpenAIEncryptedContentDigestsRaw(payload); len(digests) > 0 { + s.markOpenAIWSInvalidEncryptedContentLineage(groupID, sessionHash, digests) + logOpenAIWSModeInfo( + "ingress_ws_invalid_encrypted_lineage_mark account_id=%d turn=%d digests=%d", + account.ID, + turn, + len(digests), + ) + } + } errCode, errType, errMessage := summarizeOpenAIWSErrorEventFieldsFromRaw(errCodeRaw, errTypeRaw, errMsgRaw) recoverablePrevNotFound := fallbackReason == openAIWSIngressStagePreviousResponseNotFound && turnPreviousResponseID != "" && @@ -1098,23 +1146,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ms := int(time.Since(turnStart).Milliseconds()) firstTokenMs = &ms } - if openAIWSMessageShouldParseUsage(eventType, upstreamMessage) { - parseOpenAIWSResponseUsageFromCompletedEvent(upstreamMessage, &usage) - } imageCounter.AddSSEData(upstreamMessage) - if eventType == "response.failed" { - if hit, code, msg := detectOpenAICyberPolicy(upstreamMessage); hit { - MarkOpsCyberPolicy(c, CyberPolicyMark{ - Code: code, - Message: msg, - Body: truncateString(string(upstreamMessage), 4096), - UpstreamStatus: http.StatusOK, - UpstreamInTok: usage.InputTokens, - UpstreamOutTok: usage.OutputTokens, - }) - } - } if !clientDisconnected { if needModelReplace && len(mappedModelBytes) > 0 && openAIWSEventMayContainModel(eventType) && bytes.Contains(upstreamMessage, mappedModelBytes) { upstreamMessage = replaceOpenAIWSMessageModel(upstreamMessage, mappedModel, originalModel) @@ -1427,6 +1460,20 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } } skipBeforeTurn = false + // 剥离本会话已知失效的加密项,阻断同一失效密文随历史反复触发上游拒绝。 + // 历史序列须同步剥离,否则与已剥离的当前 input 项错位,prefix 复用失配。 + if invalidDigests := s.sessionInvalidEncryptedContentDigests(groupID, sessionHash); len(invalidDigests) > 0 { + strippedPayload, strippedCount := s.stripSessionInvalidEncryptedContentLogged( + currentPayload, invalidDigests, "ingress_ws_invalid_encrypted_lineage_strip", account.ID, turn, + ) + if strippedCount > 0 { + currentPayload = strippedPayload + currentPayloadBytes = len(strippedPayload) + } + if lastTurnReplayInputExists { + lastTurnReplayInput, _ = stripOpenAIInvalidEncryptedContentFromReplayItems(lastTurnReplayInput, invalidDigests) + } + } currentPreviousResponseID := openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id") expectedPrev := strings.TrimSpace(lastTurnResponseID) toolSignals := ToolContinuationSignals{ @@ -1765,16 +1812,19 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } responseID := strings.TrimSpace(result.RequestID) lastTurnResponseID = responseID - lastTurnPayload = cloneOpenAIWSPayloadBytes(currentPayload) - lastTurnReplayInput = cloneOpenAIWSRawMessages(currentTurnReplayInput) + // 正文共享:currentPayload/currentTurnReplayInput 均不可变,历史直接引用; + // collector 增量经 combine 合并(新头数组)。 + lastTurnReplayInput = currentTurnReplayInput lastTurnReplayInputExists = currentTurnReplayInputExists if result.wsReplayInputExists { - lastTurnReplayInput = append(lastTurnReplayInput, cloneOpenAIWSRawMessages(result.wsReplayInput)...) + lastTurnReplayInput = combineOpenAIWSReplayItems(lastTurnReplayInput, result.wsReplayInput) lastTurnReplayInputExists = true } nextStrictState, strictStateErr := buildOpenAIWSIngressPreviousTurnStrictState(currentPayload) if strictStateErr != nil { lastTurnStrictState = nil + // strict 状态不可用时保留整份上一轮 payload 供慢路径比较。 + lastTurnPayload = currentPayload logOpenAIWSModeInfo( "ingress_ws_prev_response_strict_state_skip account_id=%d turn=%d conn_id=%s reason=build_error cause=%s", account.ID, @@ -1784,6 +1834,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ) } else { lastTurnStrictState = nextStrictState + lastTurnPayload = nil } if responseID != "" && stateStore != nil { diff --git a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go index 150a87b079..98003200c6 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_session_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_session_test.go @@ -4581,3 +4581,157 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_ClientDisconnect t.Fatal("未收到断连后的 turn 结果回调") } } + +func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_InvalidEncryptedContentLineageStripsNextTurn(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := &config.Config{} + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.OAuthEnabled = true + cfg.Gateway.OpenAIWS.APIKeyEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1 + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1 + cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8 + cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + upstreamConn := &openAIWSCaptureConn{ + events: [][]byte{ + []byte(`{"type":"error","error":{"type":"invalid_request_error","code":"invalid_encrypted_content","message":"The encrypted content could not be verified"}}`), + []byte(`{"type":"response.failed","response":{"id":"resp_enc_lineage_1","model":"gpt-5.1","error":{"code":"invalid_encrypted_content","message":"The encrypted content could not be verified"}}}`), + []byte(`{"type":"response.completed","response":{"id":"resp_enc_lineage_2","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`), + }, + } + dialer := &openAIWSQueueDialer{ + conns: []openAIWSClientConn{upstreamConn}, + } + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(dialer) + + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + + account := &Account{ + ID: 119, + Name: "openai-ingress-enc-lineage", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + }, + Extra: map[string]any{ + "responses_websockets_v2_enabled": true, + }, + } + + serverErrCh := make(chan error, 1) + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{ + CompressionMode: coderws.CompressionContextTakeover, + }) + if err != nil { + serverErrCh <- err + return + } + defer func() { + _ = conn.CloseNow() + }() + + rec := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(rec) + req := r.Clone(r.Context()) + req.Header = req.Header.Clone() + req.Header.Set("User-Agent", "unit-test-agent/1.0") + ginCtx.Request = req + + readCtx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + msgType, firstMessage, readErr := conn.Read(readCtx) + cancel() + if readErr != nil { + serverErrCh <- readErr + return + } + if msgType != coderws.MessageText && msgType != coderws.MessageBinary { + serverErrCh <- errors.New("unsupported websocket client message type") + return + } + + serverErrCh <- svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "sk-test", firstMessage, nil) + })) + defer wsServer.Close() + + dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) + clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil) + cancelDial() + require.NoError(t, err) + defer func() { + _ = clientConn.CloseNow() + }() + + writeMessage := func(payload string) { + writeCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + require.NoError(t, clientConn.Write(writeCtx, coderws.MessageText, []byte(payload))) + } + readMessage := func() []byte { + readCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + msgType, message, readErr := clientConn.Read(readCtx) + require.NoError(t, readErr) + require.Equal(t, coderws.MessageText, msgType) + return message + } + + // turn1:携带失效密文,上游以 invalid_encrypted_content 拒绝(error + response.failed 透传给客户端)。 + writeMessage(`{"type":"response.create","model":"gpt-5.1","stream":false,"input":[{"type":"reasoning","id":"rs_1","encrypted_content":"stale-cipher","summary":[]},{"type":"input_text","text":"hi"}]}`) + firstEvent := readMessage() + require.Equal(t, "error", gjson.GetBytes(firstEvent, "type").String()) + require.Equal(t, "invalid_encrypted_content", gjson.GetBytes(firstEvent, "error.code").String()) + secondEvent := readMessage() + require.Equal(t, "response.failed", gjson.GetBytes(secondEvent, "type").String()) + + // turn2:客户端历史仍带同一失效密文,进场应被 lineage 预剥离后再发上游。 + writeMessage(`{"type":"response.create","model":"gpt-5.1","stream":false,"input":[{"type":"reasoning","id":"rs_1","encrypted_content":"stale-cipher","summary":[]},{"type":"input_text","text":"hi"},{"type":"input_text","text":"again"}]}`) + thirdEvent := readMessage() + require.Equal(t, "response.completed", gjson.GetBytes(thirdEvent, "type").String()) + require.Equal(t, "resp_enc_lineage_2", gjson.GetBytes(thirdEvent, "response.id").String()) + + require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done")) + select { + case serverErr := <-serverErrCh: + require.NoError(t, serverErr) + case <-time.After(5 * time.Second): + t.Fatal("等待 ingress websocket 结束超时") + } + + upstreamConn.mu.Lock() + writes := append([]map[string]any(nil), upstreamConn.writes...) + upstreamConn.mu.Unlock() + require.Len(t, writes, 2, "两轮各应发送一次上游请求") + + firstUpstream := requestToJSONString(writes[0]) + require.Equal(t, "stale-cipher", gjson.Get(firstUpstream, "input.0.encrypted_content").String(), "首轮请求原样携带密文") + + secondUpstream := requestToJSONString(writes[1]) + secondInput := gjson.Get(secondUpstream, "input").Array() + require.Len(t, secondInput, 3, "剥离仅移除 encrypted_content 字段,reasoning 骨架保留") + for _, item := range secondInput { + require.False(t, item.Get("encrypted_content").Exists(), "第二轮请求不得再携带已失效密文: %s", item.Raw) + } + require.Equal(t, "rs_1", gjson.Get(secondUpstream, "input.0.id").String()) + require.Equal(t, "again", gjson.Get(secondUpstream, "input.2.text").String()) +} diff --git a/backend/internal/service/openai_ws_forwarder_ingress_test.go b/backend/internal/service/openai_ws_forwarder_ingress_test.go index 1f00d0dce6..b70f83659a 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress_test.go +++ b/backend/internal/service/openai_ws_forwarder_ingress_test.go @@ -1,6 +1,7 @@ package service import ( + "bytes" "context" "encoding/json" "errors" @@ -1007,18 +1008,79 @@ func TestSetOpenAIWSPayloadInputSequence(t *testing.T) { }) } -func TestCloneOpenAIWSRawMessages(t *testing.T) { +func TestCombineOpenAIWSReplayItems(t *testing.T) { t.Parallel() - t.Run("nil_slice", func(t *testing.T) { - cloned := cloneOpenAIWSRawMessages(nil) - require.Nil(t, cloned) + t.Run("empty_delta_returns_history", func(t *testing.T) { + history := []json.RawMessage{json.RawMessage(`{"a":1}`)} + require.Nil(t, combineOpenAIWSReplayItems(nil, nil)) + combined := combineOpenAIWSReplayItems(history, nil) + require.Len(t, combined, 1) }) - t.Run("empty_slice", func(t *testing.T) { - items := make([]json.RawMessage, 0) - cloned := cloneOpenAIWSRawMessages(items) - require.NotNil(t, cloned) - require.Len(t, cloned, 0) + t.Run("new_header_shares_bodies", func(t *testing.T) { + history := []json.RawMessage{json.RawMessage(`{"a":1}`)} + delta := []json.RawMessage{json.RawMessage(`{"b":2}`)} + combined := combineOpenAIWSReplayItems(history, delta) + require.Len(t, combined, 2) + // 头数组必须是新建的:对 combined 追加不影响 history。 + require.NotSame(t, &history[0], &combined[0]) + // 正文共享:不发生字节级深拷贝。 + require.Same(t, &history[0][0], &combined[0][0]) + require.Same(t, &delta[0][0], &combined[1][0]) + }) +} + +func TestOpenAIWSReplaySequenceSharesBodies(t *testing.T) { + t.Parallel() + + t.Run("extract_shares_payload_backing_array", func(t *testing.T) { + payload := []byte(`{"input":[{"type":"input_text","text":"hello"},{"type":"input_text","text":"world"}]}`) + items, exists, err := openAIWSExtractNormalizedInputSequence(payload) + require.NoError(t, err) + require.True(t, exists) + require.Len(t, items, 2) + for _, item := range items { + start := bytes.Index(payload, []byte(item)) + require.GreaterOrEqual(t, start, 0) + require.Same(t, &payload[start], &item[0], "extract 应零拷贝共享 payload 底层数组") + } + }) + + t.Run("build_transfers_current_items_ownership", func(t *testing.T) { + payload := []byte(`{"input":[{"type":"input_text","text":"hello"}]}`) + items, exists, err := buildOpenAIWSReplayInputSequence(nil, false, payload, false) + require.NoError(t, err) + require.True(t, exists) + require.Len(t, items, 1) + start := bytes.Index(payload, []byte(items[0])) + require.GreaterOrEqual(t, start, 0) + require.Same(t, &payload[start], &items[0][0]) + }) + + t.Run("build_merge_shares_history_bodies", func(t *testing.T) { + history := []json.RawMessage{json.RawMessage(`{"type":"input_text","text":"hello"}`)} + items, exists, err := buildOpenAIWSReplayInputSequence( + history, + true, + []byte(`{"previous_response_id":"resp_1","input":[{"type":"input_text","text":"world"}]}`), + true, + ) + require.NoError(t, err) + require.True(t, exists) + require.Len(t, items, 2) + require.Same(t, &history[0][0], &items[0][0], "历史正文应共享而非深拷贝") + }) + + t.Run("build_prefix_hit_transfers_current_items", func(t *testing.T) { + history := []json.RawMessage{json.RawMessage(`{"type":"input_text","text":"hello"}`)} + payload := []byte(`{"previous_response_id":"resp_1","input":[{"type":"input_text","text":"hello"},{"type":"input_text","text":"world"}]}`) + items, exists, err := buildOpenAIWSReplayInputSequence(history, true, payload, true) + require.NoError(t, err) + require.True(t, exists) + require.Len(t, items, 2) + start := bytes.Index(payload, []byte(items[1])) + require.GreaterOrEqual(t, start, 0) + require.Same(t, &payload[start], &items[1][0], "prefix 命中应转移当前 items 所有权并共享 payload 底层数组") }) } diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index 939db28437..bc24ebc265 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "strings" + "unsafe" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -434,24 +435,37 @@ func alignStoreDisabledPreviousResponseID( return updated, true, nil } -func cloneOpenAIWSPayloadBytes(payload []byte) []byte { - if len(payload) == 0 { - return nil +// Replay 状态所有权不变式:replay 序列中的 json.RawMessage 正文一经放入即视为 +// 不可变,所有持有者共享同一份字节,任何修改都必须整体替换元素或重建 payload。 +// 序列头数组在跨持有者保存时必须新建(combineOpenAIWSReplayItems),禁止通过 +// 共享头 append,否则会写入其他持有者可见的底层数组。 + +// combineOpenAIWSReplayItems 合并历史与增量为新头数组,正文共享不复制。 +func combineOpenAIWSReplayItems(history, delta []json.RawMessage) []json.RawMessage { + if len(delta) == 0 { + return history } - cloned := make([]byte, len(payload)) - copy(cloned, payload) - return cloned + combined := make([]json.RawMessage, 0, len(history)+len(delta)) + combined = append(combined, history...) + return append(combined, delta...) } -func cloneOpenAIWSRawMessages(items []json.RawMessage) []json.RawMessage { - if items == nil { - return nil +// openAIWSPayloadStringView 返回与 payload 共享底层数组的零拷贝 string 视图, +// 供 gjson.Get 使用(gjson.GetBytes 会整段复制结果 Raw,对 input 这类占 +// payload 主体的字段是每次 O(payload) 分配)。调用方必须保证 payload 在结果 +// 存活期间不可变(replay 所有权不变式)。 +func openAIWSPayloadStringView(payload []byte) string { + return unsafe.String(unsafe.SliceData(payload), len(payload)) +} + +// openAIWSRawMessageFromResult 优先返回 parent 的子切片(gjson 值零拷贝共享), +// Index 不可用时回退为复制。共享要求 parent 遵守上面的不可变约定。 +func openAIWSRawMessageFromResult(parent []byte, value gjson.Result) json.RawMessage { + idx := value.Index + if idx > 0 && idx+len(value.Raw) <= len(parent) && string(parent[idx:idx+len(value.Raw)]) == value.Raw { + return json.RawMessage(parent[idx : idx+len(value.Raw)]) } - cloned := make([]json.RawMessage, 0, len(items)) - for idx := range items { - cloned = append(cloned, json.RawMessage(cloneOpenAIWSPayloadBytes(items[idx]))) - } - return cloned + return json.RawMessage(value.Raw) } func normalizeOpenAIWSJSONForCompare(raw []byte) ([]byte, error) { @@ -498,30 +512,38 @@ func normalizeOpenAIWSPayloadWithoutInputAndPreviousResponseID(payload []byte) ( return json.Marshal(decoded) } +// openAIWSExtractNormalizedInputSequence 拆出 input 序列。返回的正文尽可能与 +// payload 共享底层数组(零拷贝),受 replay 所有权不变式保护。 func openAIWSExtractNormalizedInputSequence(payload []byte) ([]json.RawMessage, bool, error) { if len(payload) == 0 { return nil, false, nil } - inputValue := gjson.GetBytes(payload, "input") + inputValue := gjson.Get(openAIWSPayloadStringView(payload), "input") if !inputValue.Exists() { return nil, false, nil } if inputValue.Type == gjson.JSON { - raw := strings.TrimSpace(inputValue.Raw) - if strings.HasPrefix(raw, "[") { - var items []json.RawMessage - if err := json.Unmarshal([]byte(raw), &items); err != nil { - return nil, true, err + if inputValue.IsArray() { + // gjson 宽容解析;数组整体先做零分配合法性校验,避免把断裂 + // JSON 塞进 replay 历史。 + arrayRaw := openAIWSRawMessageFromResult(payload, inputValue) + if !json.Valid(arrayRaw) { + return nil, true, errors.New("input array json is invalid") + } + elems := inputValue.Array() + items := make([]json.RawMessage, 0, len(elems)) + for _, elem := range elems { + items = append(items, openAIWSRawMessageFromResult(payload, elem)) } return items, true, nil } - return []json.RawMessage{json.RawMessage(raw)}, true, nil + return []json.RawMessage{openAIWSRawMessageFromResult(payload, inputValue)}, true, nil } if inputValue.Type == gjson.String { encoded, _ := json.Marshal(inputValue.String()) return []json.RawMessage{encoded}, true, nil } - return []json.RawMessage{json.RawMessage(inputValue.Raw)}, true, nil + return []json.RawMessage{openAIWSRawMessageFromResult(payload, inputValue)}, true, nil } func openAIWSInputIsPrefixExtended(previousPayload, currentPayload []byte) (bool, error) { @@ -564,6 +586,10 @@ func openAIWSRawItemsHasPrefix(items []json.RawMessage, prefix []json.RawMessage return false } for idx := range prefix { + // 快路径:客户端逐字节重发历史时直接比较,避免整轮历史的解码/再编码。 + if bytes.Equal(bytes.TrimSpace(prefix[idx]), bytes.TrimSpace(items[idx])) { + continue + } previousNormalized := normalizeOpenAIWSJSONForCompareOrRaw(prefix[idx]) currentNormalized := normalizeOpenAIWSJSONForCompareOrRaw(items[idx]) if !bytes.Equal(previousNormalized, currentNormalized) { @@ -614,12 +640,13 @@ func openAIWSRawItemsHaveToolCallContextForOutputs(items []json.RawMessage) bool return true } +// sanitizeOpenAIWSHistoricalReplayToolCalls 返回的新头数组与 previousItems 共享正文。 func sanitizeOpenAIWSHistoricalReplayToolCalls( previousItems []json.RawMessage, currentItems []json.RawMessage, ) []json.RawMessage { if len(previousItems) == 0 { - return cloneOpenAIWSRawMessages(previousItems) + return previousItems } outputCallIDs := make(map[string]struct{}) collectOutputCallIDs := func(items []json.RawMessage) { @@ -643,7 +670,7 @@ func sanitizeOpenAIWSHistoricalReplayToolCalls( continue } } - sanitized = append(sanitized, append(json.RawMessage(nil), item...)) + sanitized = append(sanitized, item) } return sanitized } @@ -652,7 +679,7 @@ func openAIWSRawPayloadHasToolCallOutput(payload []byte) bool { if len(payload) == 0 { return false } - input := gjson.GetBytes(payload, "input") + input := gjson.Get(openAIWSPayloadStringView(payload), "input") if !input.Exists() { return false } @@ -670,6 +697,33 @@ func openAIWSRawPayloadHasToolCallOutput(payload []byte) bool { return false } +// buildOpenAIWSReplayInputSequenceFromItems 基于已解析的当前 turn input 构建 +// replay 序列。返回序列的正文与 previousFullInput/currentItems 共享所有权 +// (见 combineOpenAIWSReplayItems 上方的所有权不变式),头数组可能直接转移自 +// currentItems。 +func buildOpenAIWSReplayInputSequenceFromItems( + previousFullInput []json.RawMessage, + previousFullInputExists bool, + currentItems []json.RawMessage, + currentExists bool, + hasPreviousResponseID bool, +) ([]json.RawMessage, bool) { + if !hasPreviousResponseID || !previousFullInputExists { + return currentItems, currentExists + } + previousFullInput = sanitizeOpenAIWSHistoricalReplayToolCalls(previousFullInput, currentItems) + if !currentExists || len(currentItems) == 0 { + return previousFullInput, true + } + if openAIWSRawItemsHasPrefix(currentItems, previousFullInput) { + return currentItems, true + } + merged := make([]json.RawMessage, 0, len(previousFullInput)+len(currentItems)) + merged = append(merged, previousFullInput...) + merged = append(merged, currentItems...) + return merged, true +} + func buildOpenAIWSReplayInputSequence( previousFullInput []json.RawMessage, previousFullInputExists bool, @@ -680,23 +734,14 @@ func buildOpenAIWSReplayInputSequence( if currentErr != nil { return nil, false, currentErr } - if !hasPreviousResponseID { - return cloneOpenAIWSRawMessages(currentItems), currentExists, nil - } - if !previousFullInputExists { - return cloneOpenAIWSRawMessages(currentItems), currentExists, nil - } - previousFullInput = sanitizeOpenAIWSHistoricalReplayToolCalls(previousFullInput, currentItems) - if !currentExists || len(currentItems) == 0 { - return cloneOpenAIWSRawMessages(previousFullInput), true, nil - } - if openAIWSRawItemsHasPrefix(currentItems, previousFullInput) { - return cloneOpenAIWSRawMessages(currentItems), true, nil - } - merged := make([]json.RawMessage, 0, len(previousFullInput)+len(currentItems)) - merged = append(merged, cloneOpenAIWSRawMessages(previousFullInput)...) - merged = append(merged, cloneOpenAIWSRawMessages(currentItems)...) - return merged, true, nil + items, exists := buildOpenAIWSReplayInputSequenceFromItems( + previousFullInput, + previousFullInputExists, + currentItems, + currentExists, + hasPreviousResponseID, + ) + return items, exists, nil } func setOpenAIWSPayloadInputSequence( diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index 81f8631e96..9f04d42640 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -374,6 +374,59 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( firstEventType := "" lastEventType := "" upstreamTerminalEvent := "" + clientDisconnected := false + clientDisconnectDrainStartedAt := time.Time{} + readTimeout := s.openAIWSReadTimeout() + upstreamReadCtx := ctx + upstreamReadDetached := false + clientRequestCanceled := func() bool { + return ctx != nil && errors.Is(ctx.Err(), context.Canceled) + } + markClientDisconnected := func(cause string) { + if clientDisconnected { + return + } + clientDisconnected = true + clientDisconnectDrainStartedAt = time.Now() + if !upstreamReadDetached { + upstreamReadCtx = context.WithoutCancel(ctx) + upstreamReadDetached = true + } + logOpenAIWSModeInfo( + "client_disconnected account_id=%d conn_id=%s cause=%s events=%d token_events=%d", + account.ID, + connID, + cause, + eventCount, + tokenEventCount, + ) + } + markClientRequestCanceled := func() { + if clientRequestCanceled() { + markClientDisconnected("request_context_canceled") + } + } + resultWithUsage := func() *OpenAIForwardResult { + return &OpenAIForwardResult{ + RequestID: responseID, + ResponseID: responseID, + Usage: *usage, + Model: originalModel, + UpstreamModel: mappedModel, + UpstreamResponseModel: responseModelObserver.Model(), + UpstreamResponseModelConflict: responseModelObserver.Conflict(), + UpstreamResponseServiceTier: responseModelObserver.ServiceTier(), + ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTier(reqBody)), + ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel), + Stream: reqStream, + OpenAIWSMode: true, + UpstreamTerminalEvent: upstreamTerminalEvent, + ResponseHeaders: lease.HandshakeHeaders(), + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ClientDisconnect: clientDisconnected, + } + } var flusher http.Flusher if reqStream { @@ -392,7 +445,6 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( flusher = f } - clientDisconnected := false flushBatchSize := s.openAIWSEventFlushBatchSize() flushInterval := s.openAIWSEventFlushInterval() pendingFlushEvents := 0 @@ -425,7 +477,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( flushStreamWriter(forceFlush) return } - clientDisconnected = true + markClientDisconnected("downstream_write_error") logger.LegacyPrintf("service.openai_gateway", "[OpenAI WS Mode] client disconnected, continue draining upstream: account=%d", account.ID) } flushBufferedStreamEvents := func(reason string) { @@ -452,17 +504,32 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( } } - readTimeout := s.openAIWSReadTimeout() + // Keep per-read timeouts unchanged for connected clients. Once a client + // disconnects, use the same timeout as a bounded total drain budget. var pendingJSONDocuments [][]byte +readLoop: for { + markClientRequestCanceled() var message []byte var readErr error + readUsedDetachedContext := upstreamReadDetached if len(pendingJSONDocuments) > 0 { message = pendingJSONDocuments[0] pendingJSONDocuments = pendingJSONDocuments[1:] } else { - message, readErr = lease.ReadMessageWithContextTimeout(ctx, readTimeout) + currentReadTimeout := readTimeout + if clientDisconnected && !clientDisconnectDrainStartedAt.IsZero() { + remaining := readTimeout - time.Since(clientDisconnectDrainStartedAt) + if remaining <= 0 { + lease.MarkBroken() + break readLoop + } + if remaining < currentReadTimeout { + currentReadTimeout = remaining + } + } + message, readErr = lease.ReadMessageWithContextTimeout(upstreamReadCtx, currentReadTimeout) if readErr == nil { if documents, repaired := splitOpenAIConcatenatedJSONDocuments(message); repaired { logOpenAIWSModeInfo( @@ -477,6 +544,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( } } } + markClientRequestCanceled() if readErr == nil && !json.Valid(message) { eventType, _, _ := parseOpenAIWSEventEnvelope(message) if eventType == "" { @@ -515,12 +583,15 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( truncateOpenAIWSLogValue(firstEventType, openAIWSLogValueMaxLen), truncateOpenAIWSLogValue(lastEventType, openAIWSLogValueMaxLen), ) + if clientDisconnected { + if !readUsedDetachedContext && errors.Is(readErr, context.Canceled) && clientRequestCanceled() { + continue + } + break + } if !wroteDownstream { return nil, wrapOpenAIWSFallback(classifyOpenAIWSReadFallbackReason(readErr), readErr) } - if clientDisconnected { - break - } setOpsUpstreamError(c, 0, sanitizeUpstreamErrorMessage(readErr.Error()), "") return nil, fmt.Errorf("openai ws read event: %w", readErr) } @@ -585,17 +656,8 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( } imageCounter.AddSSEData(message) - if eventType == "response.failed" { - if hit, code, msg := detectOpenAICyberPolicy(message); hit { - MarkOpsCyberPolicy(c, CyberPolicyMark{ - Code: code, - Message: msg, - Body: truncateString(string(message), 4096), - UpstreamStatus: http.StatusOK, - UpstreamInTok: usage.InputTokens, - UpstreamOutTok: usage.OutputTokens, - }) - } + if eventType == "error" || eventType == "response.failed" { + markOpenAICyberPolicyEvent(c, message, http.StatusOK, usage) } if eventType == "error" { @@ -710,7 +772,13 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( } } + if clientDisconnected && terminalEventCount == 0 { + return resultWithUsage(), fmt.Errorf("openai ws stream incomplete after client disconnect: %w", context.Canceled) + } if !reqStream { + if clientDisconnected { + return resultWithUsage(), nil + } if len(finalResponse) == 0 { logOpenAIWSModeInfo( "missing_final_response account_id=%d conn_id=%s events=%d token_events=%d terminal_events=%d wrote_downstream=%v", @@ -772,26 +840,10 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( clientDisconnected, ) - return &OpenAIForwardResult{ - RequestID: responseID, - Usage: *usage, - Model: originalModel, - UpstreamModel: mappedModel, - UpstreamResponseModel: responseModelObserver.Model(), - UpstreamResponseModelConflict: responseModelObserver.Conflict(), - UpstreamResponseServiceTier: responseModelObserver.ServiceTier(), - ImageCount: imageCounter.Count(), - ImageOutputSizes: imageCounter.Sizes(), - ServiceTier: resolvedOpenAIUpstreamServiceTierFromObserver(responseModelObserver, extractOpenAIServiceTier(reqBody)), - ReasoningEffort: extractOpenAIReasoningEffort(reqBody, mappedModel, originalModel), - RequestedReasoningEffort: CanonicalRequestedReasoningEffortFromReqBody(reqBody, originalModel, mappedModel), - Stream: reqStream, - OpenAIWSMode: true, - UpstreamTerminalEvent: upstreamTerminalEvent, - ResponseHeaders: lease.HandshakeHeaders(), - Duration: time.Since(startTime), - FirstTokenMs: firstTokenMs, - }, nil + result := resultWithUsage() + result.ImageCount = imageCounter.Count() + result.ImageOutputSizes = imageCounter.Sizes() + return result, nil } // ProxyResponsesWebSocketFromClient 处理客户端入站 WebSocket(OpenAI Responses WS Mode)并转发到上游。 diff --git a/backend/internal/service/openai_ws_forwarder_v2_test.go b/backend/internal/service/openai_ws_forwarder_v2_test.go index ab3301cc8d..636535a7eb 100644 --- a/backend/internal/service/openai_ws_forwarder_v2_test.go +++ b/backend/internal/service/openai_ws_forwarder_v2_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "net/http" "net/http/httptest" @@ -94,3 +95,76 @@ func TestForwardOpenAIWSV2_KeepsOutboundAndObservedServiceTiersSeparate(t *testi }) } } + +func TestForwardOpenAIWSV2_MarksCyberPolicyForFailureEventShapes(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + upstreamEvent []byte + wantError bool + wantInput int + wantOutput int + }{ + { + name: "error_before_return", + upstreamEvent: []byte(`{"type":"error","error":{"type":"rate_limit_error","code":"cyber_policy","message":"rate limit exceeded by cyber policy"},"usage":{"input_tokens":5,"output_tokens":1}}`), + wantError: true, + wantInput: 5, + wantOutput: 1, + }, + { + name: "response_failed_terminal", + upstreamEvent: []byte(`{"type":"response.failed","response":{"id":"resp_cyber","status":"failed","error":{"code":"cyber_policy","message":"blocked by cyber policy"},"usage":{"input_tokens":9,"output_tokens":2}}}`), + wantInput: 9, + wantOutput: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + + cfg := newOpenAIWSV2TestConfig() + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + captureConn := &openAIWSCaptureConn{events: [][]byte{append([]byte(nil), tt.upstreamEvent...)}} + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn}) + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + account := &Account{ + ID: 5883, Name: "openai-ws-v2-cyber", Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{"responses_websockets_v2_enabled": true}, + } + + result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.5","stream":false,"input":"hello"}`)) + if tt.wantError { + require.Error(t, err) + require.Nil(t, result) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + } else { + require.NoError(t, err) + require.NotNil(t, result) + } + + mark := GetOpsCyberPolicy(c) + require.NotNil(t, mark) + require.Equal(t, "cyber_policy", mark.Code) + require.Equal(t, tt.wantInput, mark.UpstreamInTok) + require.Equal(t, tt.wantOutput, mark.UpstreamOutTok) + }) + } +} diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 8e5cbf6159..52420c584a 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "slices" "strings" "time" @@ -281,12 +282,14 @@ func (c *openAIWSToolCallReplayCollector) AddEvent(eventType string, message []b } } +// Items/AllItems 返回浅拷贝头数组;正文由 collector 独立分配且此后不可变, +// 调用方按 replay 所有权不变式共享持有。 func (c *openAIWSToolCallReplayCollector) Items() []json.RawMessage { - return cloneOpenAIWSRawMessages(c.items) + return slices.Clone(c.items) } func (c *openAIWSToolCallReplayCollector) AllItems() []json.RawMessage { - return cloneOpenAIWSRawMessages(c.allItems) + return slices.Clone(c.allItems) } func (c *openAIWSToolCallReplayCollector) addAllItem(item gjson.Result) { @@ -560,6 +563,13 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( respBody, _ := io.ReadAll(io.LimitReader(resp.Body, openAIWSHTTPBridgeErrorBodyLimitBytes)) _ = resp.Body.Close() + markOpenAICyberPolicyEvent(c, respBody, resp.StatusCode, nil) + if resp.StatusCode == http.StatusBadRequest && + extractUpstreamErrorCode(respBody) == openAIWSFallbackReasonInvalidEncryptedContent { + s.markOpenAIWSInvalidEncryptedContentLineageFromPayload( + c, body, "ingress_ws_http_bridge_invalid_encrypted_lineage_mark", account.ID, turn, + ) + } retryBody, retryReason, changed, retryErr := normalizeOpenAIResponsesRejectedFieldRetryBody(resp.StatusCode, body, respBody) if retryErr != nil { return nil, fmt.Errorf("normalize websocket http bridge rejected field retry: %w", retryErr) @@ -580,7 +590,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( if upstreamMsg == "" { upstreamMsg = http.StatusText(resp.StatusCode) } - shouldFailover := s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) + shouldFailover := s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMsg, respBody) if account.Platform == PlatformGrok { shouldFailover = s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) s.handleGrokAccountUpstreamError(withGrokTeamRateLimitModel(ctx, resolveGrokWSUpstreamModel(account, body, originalModel)), account, resp.StatusCode, resp.Header, respBody) @@ -767,6 +777,9 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( if openAIWSMessageShouldParseUsage(eventType, upstreamMessage) { parseOpenAIWSResponseUsageFromCompletedEvent(upstreamMessage, &usage) } + if eventType == "error" || eventType == "response.failed" { + markOpenAICyberPolicyEvent(c, upstreamMessage, http.StatusOK, &usage) + } imageCounter.AddSSEData(upstreamMessage) if needModelReplace && len(mappedModelBytes) > 0 && openAIWSEventMayContainModel(eventType) && strings.Contains(trimmedData, mappedModel) { @@ -802,6 +815,11 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn( if account.Platform == PlatformGrok { statusCode = openAIWSErrorHTTPStatusFromRaw(errCodeRaw, errTypeRaw) } + if reason, _ := classifyOpenAIWSErrorEventFromRaw(errCodeRaw, errTypeRaw, errMessage); reason == openAIWSFallbackReasonInvalidEncryptedContent { + s.markOpenAIWSInvalidEncryptedContentLineageFromPayload( + c, body, "ingress_ws_http_bridge_invalid_encrypted_lineage_mark", account.ID, turn, + ) + } } requestScopedCapacity := isOpenAIUpstreamCapacityShedEvent(upstreamMessage) if account.Platform == PlatformGrok && eventType == "error" { diff --git a/backend/internal/service/openai_ws_http_bridge_isolation_test.go b/backend/internal/service/openai_ws_http_bridge_isolation_test.go new file mode 100644 index 0000000000..53711dccd3 --- /dev/null +++ b/backend/internal/service/openai_ws_http_bridge_isolation_test.go @@ -0,0 +1,249 @@ +package service + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + coderws "github.com/coder/websocket" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +type httpBridgeIsolationBody struct { + ctx context.Context + prefix *bytes.Reader + suffix *bytes.Reader + release <-chan struct{} + closed chan struct{} + once sync.Once +} + +func (b *httpBridgeIsolationBody) Read(p []byte) (int, error) { + if b.prefix.Len() > 0 { + return b.prefix.Read(p) + } + select { + case <-b.release: + return b.suffix.Read(p) + case <-b.closed: + return 0, io.EOF + case <-b.ctx.Done(): + return 0, b.ctx.Err() + } +} + +func (b *httpBridgeIsolationBody) Close() error { + b.once.Do(func() { close(b.closed) }) + return nil +} + +type httpBridgeIsolationRequest struct { + input []string + state string +} + +type httpBridgeIsolationUpstream struct { + ctx context.Context + mu sync.Mutex + requests []httpBridgeIsolationRequest + firstRelease chan struct{} +} + +func (u *httpBridgeIsolationUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + _ = req.Body.Close() + input := gjson.GetBytes(body, "input") + var texts []string + if input.Type == gjson.String { + texts = append(texts, input.String()) + } else { + for _, item := range input.Array() { + if item.Type == gjson.String { + texts = append(texts, item.String()) + continue + } + if item.Get("type").String() == "function_call" { + texts = append(texts, item.Get("call_id").String()) + continue + } + if item.Get("type").String() == "function_call_output" { + texts = append(texts, item.Get("output").String()) + continue + } + content := item.Get("content") + if content.Type == gjson.String { + texts = append(texts, content.String()) + } else { + for _, part := range content.Array() { + if text := part.Get("text"); text.Exists() { + texts = append(texts, text.String()) + } + } + } + } + } + if len(texts) == 0 { + return nil, fmt.Errorf("missing bridge input") + } + u.mu.Lock() + u.requests = append(u.requests, httpBridgeIsolationRequest{input: texts, state: req.Header.Get(openAIWSTurnStateHeader)}) + u.mu.Unlock() + + id := texts[0] + turn := 1 + if input.IsArray() { + turn = 2 + } + responseID := fmt.Sprintf("resp_%s_%d", id, turn) + prefix := fmt.Sprintf("data: {\"type\":\"response.created\",\"response\":{\"id\":%q,\"status\":\"in_progress\"}}\n\n", responseID) + if turn == 1 { + prefix += "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"id\":\"rs_test\",\"type\":\"reasoning\",\"encrypted_content\":\"opaque\"}}\n\n" + } + output := "[]" + if turn == 1 { + output = fmt.Sprintf(`[{"type":"function_call","id":%q,"call_id":%q,"name":"lookup","arguments":"{}","status":"completed"}]`, "fc_"+id, "call_"+id) + } + suffix := fmt.Sprintf("data: {\"type\":\"response.completed\",\"response\":{\"id\":%q,\"status\":\"completed\",\"output\":%s,\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n", responseID, output) + headers := http.Header{"Content-Type": []string{"text/event-stream"}} + headers.Set(openAIWSTurnStateHeader, "turn-"+id) + responseBody := io.NopCloser(strings.NewReader(prefix + suffix)) + if turn == 1 { + responseBody = &httpBridgeIsolationBody{ctx: u.ctx, prefix: bytes.NewReader([]byte(prefix)), suffix: bytes.NewReader([]byte(suffix)), release: u.firstRelease, closed: make(chan struct{})} + } + return &http.Response{StatusCode: http.StatusOK, Header: headers, Body: responseBody}, nil +} + +func (u *httpBridgeIsolationUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) { + return u.Do(req, proxyURL, accountID, concurrency) +} + +func TestOpenAIWSHTTPBridgeSessionIsolationAcrossSameSessionHash(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.Enabled = true + cfg.Gateway.OpenAIWS.OAuthEnabled = true + cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true + cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true + cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeHTTPBridge + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 5 + upstream := &httpBridgeIsolationUpstream{ctx: ctx, firstRelease: make(chan struct{})} + stateStore := NewOpenAIWSStateStore(nil) + svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream, openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), openaiWSStateStore: stateStore} + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{"access_token": "test-token"}, + Extra: map[string]any{"openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeHTTPBridge}, + Concurrency: 2, Status: StatusActive, Schedulable: true} + groupID := int64(7) + newContext := func(r *http.Request) *gin.Context { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = r.Clone(ctx) + c.Request.Header.Set("session-id", "shared-session") + c.Set("api_key", &APIKey{ID: 11, GroupID: &groupID}) + return c + } + seedHash := svc.GenerateSessionHash(newContext(httptest.NewRequest(http.MethodGet, "/v1/responses", nil)), nil) + stateStore.BindSessionTurnState(groupID, seedHash, "sentinel-turn-state", time.Hour) + stateStore.BindSessionConn(groupID, seedHash, "sentinel-native-conn", time.Hour) + + serverResults := make(chan error, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, nil) + if err != nil { + serverResults <- err + return + } + defer func() { _ = conn.CloseNow() }() + _, firstMessage, err := conn.Read(ctx) + if err != nil { + serverResults <- err + return + } + c := newContext(r) + // Match the handler-owned registration and nested forwarding call. + preemptCtx, cleanup, _ := svc.BeginOpenAIWSIngressSessionPreemption(ctx, c, account, firstMessage) + defer cleanup() + hooks := &OpenAIWSIngressHooks{ClientLifecycleContext: ctx} + serverResults <- svc.ProxyResponsesWebSocketFromClient(preemptCtx, c, conn, account, "test-token", firstMessage, hooks) + })) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(upstream.firstRelease) }) } + defer func() { + release() + cancel() + server.Close() + }() + + readEvent := func(conn *coderws.Conn, wantType string) []byte { + t.Helper() + _, event, err := conn.Read(ctx) + require.NoError(t, err) + require.Equal(t, wantType, gjson.GetBytes(event, "type").String(), "event: %s", event) + return event + } + clients := make([]*coderws.Conn, 0, 2) + for _, id := range []string{"alpha", "beta"} { + conn, _, err := coderws.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http"), nil) + require.NoError(t, err) + defer func() { _ = conn.CloseNow() }() + clients = append(clients, conn) + payload := fmt.Sprintf(`{"type":"response.create","model":"gpt-5","prompt_cache_key":%q,"client_metadata":{"thread_id":%q},"input":%q}`, id+"-cache", id+"-thread", id) + require.NoError(t, conn.Write(ctx, coderws.MessageText, []byte(payload))) + readEvent(conn, "response.created") + readEvent(conn, "response.output_item.added") + } + // Both HTTP streams are now in flight. Neither may replace the other. + release() + for i, id := range []string{"alpha", "beta"} { + event := readEvent(clients[i], "response.completed") + require.Equal(t, "resp_"+id+"_1", gjson.GetBytes(event, "response.id").String()) + } + for i, id := range []string{"alpha", "beta"} { + payload := fmt.Sprintf(`{"type":"response.create","model":"gpt-5","previous_response_id":%q,"input":[{"type":"function_call_output","call_id":%q,"output":%q}]}`, "resp_"+id+"_1", "call_"+id, id+"-result") + require.NoError(t, clients[i].Write(ctx, coderws.MessageText, []byte(payload))) + readEvent(clients[i], "response.created") + event := readEvent(clients[i], "response.completed") + require.Equal(t, "resp_"+id+"_2", gjson.GetBytes(event, "response.id").String()) + require.NoError(t, clients[i].Close(coderws.StatusNormalClosure, "done")) + } + for range clients { + select { + case err := <-serverResults: + require.NoError(t, err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + + upstream.mu.Lock() + requests := append([]httpBridgeIsolationRequest(nil), upstream.requests...) + upstream.mu.Unlock() + require.ElementsMatch(t, []httpBridgeIsolationRequest{ + {input: []string{"alpha"}}, + {input: []string{"beta"}}, + {input: []string{"alpha", "call_alpha", "alpha-result"}, state: "turn-alpha"}, + {input: []string{"beta", "call_beta", "beta-result"}, state: "turn-beta"}, + }, requests) + gotState, ok := stateStore.GetSessionTurnState(groupID, seedHash) + require.True(t, ok) + require.Equal(t, "sentinel-turn-state", gotState) + gotConn, ok := stateStore.GetSessionConn(groupID, seedHash) + require.True(t, ok) + require.Equal(t, "sentinel-native-conn", gotConn) +} diff --git a/backend/internal/service/openai_ws_http_bridge_test.go b/backend/internal/service/openai_ws_http_bridge_test.go index 0cdf64b974..5e23a51985 100644 --- a/backend/internal/service/openai_ws_http_bridge_test.go +++ b/backend/internal/service/openai_ws_http_bridge_test.go @@ -1052,6 +1052,79 @@ func TestProxyOpenAIWSHTTPBridgeTurnBareErrorUsesAuthoritativeFailed(t *testing. require.Equal(t, "response.failed", gjson.GetBytes(writes[1], "type").String()) } +func TestProxyOpenAIWSHTTPBridgeTurnMarksCyberPolicyForFailureShapes(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + statusCode int + body string + wantStatus int + wantInput int + wantOutput int + wantResult bool + wantError bool + }{ + { + name: "http_error_body", + statusCode: http.StatusBadRequest, + body: `{"error":{"code":"cyber_policy","message":"blocked by HTTP cyber policy"}}`, + wantStatus: http.StatusBadRequest, + wantError: true, + }, + { + name: "sse_error_event", + statusCode: http.StatusOK, + body: "data: {\"type\":\"error\",\"error\":{\"code\":\"cyber_policy\",\"message\":\"blocked by error event\"},\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}\n\n", + wantStatus: http.StatusOK, + wantInput: 5, + wantOutput: 1, + wantResult: true, + wantError: true, + }, + { + name: "sse_response_failed_event", + statusCode: http.StatusOK, + body: "data: {\"type\":\"response.failed\",\"response\":{\"id\":\"resp_cyber\",\"error\":{\"code\":\"cyber_policy\",\"message\":\"blocked by failed event\"},\"usage\":{\"input_tokens\":9,\"output_tokens\":2}}}\n\n", + wantStatus: http.StatusOK, + wantInput: 9, + wantOutput: 2, + wantResult: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: tt.statusCode, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(tt.body)), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ID: 112, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1} + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + payload := []byte(`{"type":"response.create","model":"gpt-5","input":"hi"}`) + + result, err := svc.proxyOpenAIWSHTTPBridgeTurn( + context.Background(), c, account, "sk-test", payload, len(payload), + "gpt-5", "", "", "", "", 1, func([]byte) error { return nil }, + ) + + require.Equal(t, tt.wantResult, result != nil) + require.Equal(t, tt.wantError, err != nil) + mark := GetOpsCyberPolicy(c) + require.NotNil(t, mark) + require.Equal(t, "cyber_policy", mark.Code) + require.Equal(t, tt.wantStatus, mark.UpstreamStatus) + require.Equal(t, tt.wantInput, mark.UpstreamInTok) + require.Equal(t, tt.wantOutput, mark.UpstreamOutTok) + require.Contains(t, mark.Body, `"code":"cyber_policy"`) + }) + } +} + func TestProxyOpenAIWSHTTPBridgeTurnBareErrorEOFSynthesizesFailed(t *testing.T) { gin.SetMode(gin.TestMode) body := "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_eof\",\"status\":\"in_progress\"}}\n\n" + diff --git a/backend/internal/service/openai_ws_ingress_capacity_shed_test.go b/backend/internal/service/openai_ws_ingress_capacity_shed_test.go index d37b7f2cda..c9d041afa6 100644 --- a/backend/internal/service/openai_ws_ingress_capacity_shed_test.go +++ b/backend/internal/service/openai_ws_ingress_capacity_shed_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "net/http" "net/http/httptest" "strings" @@ -11,6 +12,7 @@ import ( coderws "github.com/coder/websocket" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) // openAIWSIngressCapacityShedRepo 补齐 SetError,避免非容量类错误(如 @@ -182,3 +184,128 @@ func TestProxyResponsesWebSocketFromClient_RewritesCapacityShedCodeForClient(t * }) } } + +func TestProxyResponsesWebSocketFromClient_MarksCyberPolicyBeforeEarlyReturn(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + upstreamEvent []byte + wantFailover bool + wantClientMsg bool + wantInput int + wantOutput int + }{ + { + name: "error_before_rate_limit_failover", + upstreamEvent: []byte(`{"type":"error","error":{"type":"rate_limit_error","code":"cyber_policy","message":"rate limit exceeded by cyber policy"},"usage":{"input_tokens":5,"output_tokens":1}}`), + wantFailover: true, + wantInput: 5, + wantOutput: 1, + }, + { + name: "response_failed_terminal", + upstreamEvent: []byte(`{"type":"response.failed","response":{"id":"resp_cyber","status":"failed","error":{"code":"cyber_policy","message":"blocked by cyber policy"},"usage":{"input_tokens":9,"output_tokens":2}}}`), + wantClientMsg: true, + wantInput: 9, + wantOutput: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := newOpenAIWSV2TestConfig() + cfg.Security.URLAllowlist.Enabled = false + cfg.Security.URLAllowlist.AllowInsecureHTTP = true + cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true + cfg.Gateway.OpenAIWS.IngressModeDefault = OpenAIWSIngressModeCtxPool + cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0 + + captureConn := &openAIWSCaptureConn{events: [][]byte{append([]byte(nil), tt.upstreamEvent...)}} + pool := newOpenAIWSConnPool(cfg) + pool.setClientDialerForTest(&openAIWSCaptureDialer{conn: captureConn}) + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + openaiWSPool: pool, + } + account := &Account{ + ID: 5402, Name: "openai-ingress-cyber", Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, + Credentials: map[string]any{"api_key": "sk-test"}, + Extra: map[string]any{ + "openai_apikey_responses_websockets_v2_mode": OpenAIWSIngressModeCtxPool, + }, + } + + markCh := make(chan *CyberPolicyMark, 1) + serverErrCh := make(chan error, 1) + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover}) + if err != nil { + serverErrCh <- err + return + } + defer func() { _ = conn.CloseNow() }() + + readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second) + _, firstMessage, err := conn.Read(readCtx) + cancelRead() + if err != nil { + serverErrCh <- err + return + } + + recorder := httptest.NewRecorder() + ginCtx, _ := gin.CreateTestContext(recorder) + ginCtx.Request = r.Clone(r.Context()) + hooks := &OpenAIWSIngressHooks{AfterTurn: func(_ int, _ *OpenAIForwardResult, _ error) { + markCh <- GetOpsCyberPolicy(ginCtx) + }} + serverErrCh <- svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "sk-test", firstMessage, hooks) + })) + defer wsServer.Close() + + dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) + clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil) + cancelDial() + require.NoError(t, err) + defer func() { _ = clientConn.CloseNow() }() + + writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`)) + cancelWrite() + require.NoError(t, err) + + if tt.wantClientMsg { + readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) + _, message, readErr := clientConn.Read(readCtx) + cancelRead() + require.NoError(t, readErr) + require.Equal(t, "response.failed", gjson.GetBytes(message, "type").String()) + } + + select { + case mark := <-markCh: + require.NotNil(t, mark) + require.Equal(t, "cyber_policy", mark.Code) + require.Equal(t, tt.wantInput, mark.UpstreamInTok) + require.Equal(t, tt.wantOutput, mark.UpstreamOutTok) + case <-time.After(3 * time.Second): + t.Fatal("AfterTurn did not observe the cyber mark") + } + + _ = clientConn.CloseNow() + select { + case serverErr := <-serverErrCh: + var failoverErr *UpstreamFailoverError + require.Equal(t, tt.wantFailover, errors.As(serverErr, &failoverErr)) + case <-time.After(5 * time.Second): + t.Fatal("waiting for ingress websocket shutdown timed out") + } + }) + } +} diff --git a/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go b/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go index 16be2bd4c6..a405fbb29a 100644 --- a/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go +++ b/backend/internal/service/openai_ws_passthrough_turn_pricing_test.go @@ -60,50 +60,38 @@ func startPassthroughHookRecordingServer( return server, serverErr } -// TestPassthroughIngressReportsTurnStartedBeforeAfterTurnWithoutBeforeTurn 钉死 -// ws_v2 透传 ingress 与 handler 侧 turn 定价的耦合:透传 relay 不触发 -// BeforeTurn,但会在每个 AfterTurn 前通过 TurnStarted 报告同一 turn 的开始时刻。 -// -// handler 的 recordTurnStart 保存该时刻,AfterTurn 再用 currentOr(turnStart) -// 作为计费 PricingAt;不触发 BeforeTurn 也意味着透传仍没有 turn 级利润复核。 -func TestPassthroughIngressReportsTurnStartedBeforeAfterTurnWithoutBeforeTurn(t *testing.T) { +func TestPassthroughIngressFollowUpCallsBeforeTurnAfterBeforeRequest(t *testing.T) { gin.SetMode(gin.TestMode) controlCtx, cancelControl := context.WithCancelCause(context.Background()) defer cancelControl(context.Canceled) upstream := newStagedPassthroughConn() - upstream.Send(`{"type":"response.completed","response":{"id":"resp_pricing","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + upstream.Send(`{"type":"response.completed","response":{"id":"resp_pricing_1","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) var hooksMu sync.Mutex - beforeTurnCalls := 0 - expectedTurnStartedAt := time.Date(2026, time.August, 17, 9, 59, 59, 0, time.UTC) - type hookEvent struct { - name string - turn int - startedAt time.Time - } - var hookEvents []hookEvent + var callbacks []string + afterTurnCalls := 0 hooks := &OpenAIWSIngressHooks{ - InitialTurnStartedAt: expectedTurnStartedAt, - TurnStarted: func(turn int, startedAt time.Time) { + BeforeRequest: func(int, []byte, string) error { hooksMu.Lock() - hookEvents = append(hookEvents, hookEvent{name: "TurnStarted", turn: turn, startedAt: startedAt}) - hooksMu.Unlock() - }, - BeforeTurn: func(int) error { - hooksMu.Lock() - beforeTurnCalls++ + callbacks = append(callbacks, "before_request") hooksMu.Unlock() return nil }, - AfterTurn: func(turn int, _ *OpenAIForwardResult, _ error) { + BeforeTurn: func(int) error { hooksMu.Lock() - hookEvents = append(hookEvents, hookEvent{name: "AfterTurn", turn: turn}) + callbacks = append(callbacks, "before_turn") + hooksMu.Unlock() + return nil + }, + AfterTurn: func(int, *OpenAIForwardResult, error) { + hooksMu.Lock() + afterTurnCalls++ hooksMu.Unlock() }, } - server, serverErr := startPassthroughHookRecordingServer( + server, _ := startPassthroughHookRecordingServer( t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), @@ -113,30 +101,90 @@ func TestPassthroughIngressReportsTurnStartedBeforeAfterTurnWithoutBeforeTurn(t defer server.Close() clientConn := dialPassthroughLifecycleClient(t, server) defer func() { _ = clientConn.CloseNow() }() + require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "type").String()) event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) require.NoError(t, err) require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) - // 等待连接自然结束(inter-turn idle 超时),确保 AfterTurn 已提交。 - _, _ = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1"}`)) + cancelWrite() + require.NoError(t, err) + require.Equal(t, "response.create", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "type").String()) + upstream.Send(`{"type":"response.completed","response":{"id":"resp_pricing_2","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + event, err = readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String()) + + hooksMu.Lock() + gotCallbacks := append([]string(nil), callbacks...) + gotAfter := afterTurnCalls + hooksMu.Unlock() + + require.Equal(t, []string{"before_request", "before_turn"}, gotCallbacks) + require.Equal(t, 2, gotAfter) +} + +func TestPassthroughIngressBeforeTurnRejectionDoesNotForwardFollowUp(t *testing.T) { + gin.SetMode(gin.TestMode) + controlCtx, cancelControl := context.WithCancelCause(context.Background()) + defer cancelControl(context.Canceled) + + upstream := newStagedPassthroughConn() + upstream.Send(`{"type":"response.completed","response":{"id":"resp_reject_1","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + rejection := errors.New("turn rejected") + var hooksMu sync.Mutex + afterTurnCalls := 0 + var finalErr error + beforeTurnTurn := 0 + hooks := &OpenAIWSIngressHooks{ + BeforeTurn: func(turn int) error { + hooksMu.Lock() + beforeTurnTurn = turn + hooksMu.Unlock() + return rejection + }, + AfterTurn: func(_ int, _ *OpenAIForwardResult, turnErr error) { + hooksMu.Lock() + afterTurnCalls++ + if turnErr != nil { + finalErr = turnErr + } + hooksMu.Unlock() + }, + } + + server, serverErr := startPassthroughHookRecordingServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount(), hooks) + defer server.Close() + clientConn := dialPassthroughLifecycleClient(t, server) + defer func() { _ = clientConn.CloseNow() }() + requirePassthroughUpstreamWrite(t, upstream, time.Second) + _, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second) + require.NoError(t, err) + + writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1"}`)) + cancelWrite() + require.NoError(t, err) select { - case <-serverErr: + case payload := <-upstream.writes: + t.Fatalf("rejected response.create was forwarded upstream: %s", payload) + case <-time.After(100 * time.Millisecond): + } + select { + case err = <-serverErr: + require.ErrorIs(t, err, rejection) case <-time.After(3 * time.Second): - t.Fatal("passthrough ingress did not exit") + t.Fatal("passthrough ingress did not exit after BeforeTurn rejection") } hooksMu.Lock() - gotBefore := beforeTurnCalls - gotEvents := append([]hookEvent(nil), hookEvents...) + gotBeforeTurn, gotAfter, gotFinalErr := beforeTurnTurn, afterTurnCalls, finalErr hooksMu.Unlock() - - require.Zero(t, gotBefore, "透传 ingress 不应调用 BeforeTurn") - require.GreaterOrEqual(t, len(gotEvents), 2, "透传 ingress 应报告 TurnStarted 和 AfterTurn") - require.Equal(t, "TurnStarted", gotEvents[0].name) - require.Equal(t, expectedTurnStartedAt, gotEvents[0].startedAt, "TurnStarted 必须携带入口冻结的首轮开始时刻") - require.Equal(t, "AfterTurn", gotEvents[1].name) - require.Equal(t, gotEvents[0].turn, gotEvents[1].turn, "TurnStarted 后应提交同一 turn 的 AfterTurn") + require.Equal(t, 2, gotBeforeTurn) + require.Equal(t, 2, gotAfter, "each started turn must be finalized exactly once") + require.ErrorIs(t, gotFinalErr, rejection) } func TestPassthroughIngressFreezesSubsequentTurnBeforeRequestPolicy(t *testing.T) { diff --git a/backend/internal/service/openai_ws_replay_allocation_test.go b/backend/internal/service/openai_ws_replay_allocation_test.go new file mode 100644 index 0000000000..d6f688b114 --- /dev/null +++ b/backend/internal/service/openai_ws_replay_allocation_test.go @@ -0,0 +1,80 @@ +package service + +import ( + "encoding/json" + "fmt" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// buildReplayTurnPayload 构造第 turn 轮的全量重发 payload:客户端历史逐轮追加 +// itemBytes 大小的项(Codex store=false 模式的真实形态)。 +func buildReplayTurnPayload(turn, itemBytes int) []byte { + var b strings.Builder + _, _ = b.WriteString(`{"type":"response.create","model":"gpt-5.5","stream":true,"input":[`) + filler := strings.Repeat("x", itemBytes) + for i := 1; i <= turn; i++ { + if i > 1 { + _, _ = b.WriteString(",") + } + _, _ = fmt.Fprintf(&b, `{"type":"input_text","text":"turn-%d-%s"}`, i, filler) + } + _, _ = b.WriteString(`]}`) + return []byte(b.String()) +} + +// TestOpenAIWSReplayStateBuildAllocationBounded 长会话分配回归:128 turn、每轮 +// ~10KiB 增量,全量历史逐轮重发。replay 状态构建(build + 历史保存)必须共享 +// 正文而非逐轮深拷贝,否则累计分配为 O(T²)(本场景 >160MB);共享实现的累计 +// 分配与客户端重发总量同阶(~85MB payload 本身之外仅头数组与解析开销)。 +func TestOpenAIWSReplayStateBuildAllocationBounded(t *testing.T) { + const ( + turns = 128 + itemBytes = 10 * 1024 + ) + + payloads := make([][]byte, 0, turns) + for turn := 1; turn <= turns; turn++ { + payloads = append(payloads, buildReplayTurnPayload(turn, itemBytes)) + } + + var history []json.RawMessage + historyExists := false + delta := []json.RawMessage{json.RawMessage(`{"type":"function_call","id":"item_1","call_id":"call_1","name":"exec","arguments":"{}"}`)} + + runtime.GC() + var before runtime.MemStats + runtime.ReadMemStats(&before) + + for turn := 1; turn <= turns; turn++ { + items, exists, err := buildOpenAIWSReplayInputSequence(history, historyExists, payloads[turn-1], turn > 1) + require.NoError(t, err) + require.True(t, exists) + require.Len(t, items, turn) + // 保存历史 + collector 增量合并(与 ingress/bridge 保存点同构)。 + history = combineOpenAIWSReplayItems(items, delta) + historyExists = true + // 下一轮 payload 含全部用户项但不含 collector 项,触发 sanitize+merge 路径中 + // 最常见的 prefix 分支比较。 + history = history[:len(history)-1] + } + + var after runtime.MemStats + runtime.ReadMemStats(&after) + allocated := after.TotalAlloc - before.TotalAlloc + + // 正文共享实现全程约 2.5MB(头数组与 gjson 解析开销)。任何逐轮 O(H) 的 + // 字节级复制(extract 拷贝、正文 clone、保存点深拷贝)都会叠加 ≥84MB + // (sum(t×10KiB))的量级,直接击穿此上界。 + const maxAllocatedBytes = 32 * 1024 * 1024 + require.Lessf( + t, + allocated, + uint64(maxAllocatedBytes), + "replay 状态构建累计分配 %d bytes 超出上界,疑似回归为逐轮深拷贝", + allocated, + ) +} diff --git a/backend/internal/service/openai_ws_session_preemption.go b/backend/internal/service/openai_ws_session_preemption.go index 3047de6998..95fa49e7f7 100644 --- a/backend/internal/service/openai_ws_session_preemption.go +++ b/backend/internal/service/openai_ws_session_preemption.go @@ -56,9 +56,14 @@ func (s *OpenAIGatewayService) BeginOpenAIWSIngressSessionPreemption( if armed, _ := ctx.Value(openAIWSSessionPreemptContextKey{}).(bool); armed { return ctx, func() {}, true } - if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.ModeRouterV2Enabled && - account != nil && account.ResolveOpenAIResponsesWebSocketV2Mode(s.cfg.Gateway.OpenAIWS.IngressModeDefault) == OpenAIWSIngressModePassthrough { - return ctx, func() {}, false + if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.ModeRouterV2Enabled && account != nil { + switch account.ResolveOpenAIResponsesWebSocketV2Mode(s.cfg.Gateway.OpenAIWS.IngressModeDefault) { + case OpenAIWSIngressModePassthrough, OpenAIWSIngressModeHTTPBridge: + // These modes own their upstream transport and replay state per + // inbound connection. Sharing a sticky/cache identity does not mean + // a new connection should cancel an independent in-flight request. + return ctx, func() {}, false + } } preemptSessionHash := "" diff --git a/backend/internal/service/openai_ws_session_preemption_test.go b/backend/internal/service/openai_ws_session_preemption_test.go index 0e0c6f5622..d00adf63b4 100644 --- a/backend/internal/service/openai_ws_session_preemption_test.go +++ b/backend/internal/service/openai_ws_session_preemption_test.go @@ -224,6 +224,59 @@ func TestOpenAIWSSessionPreemptRemoteClaimAndStaleReleaseAreAtomic(t *testing.T) require.Equal(t, "owner-b", current, "stale cleanup must preserve the replacement owner") } +func TestOpenAIWSHTTPBridgeSessionPreemptionEligibility(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + name string + routerEnabled bool + defaultMode string + accountMode string + wantArmed bool + }{ + {name: "explicit HTTP bridge", routerEnabled: true, defaultMode: OpenAIWSIngressModeCtxPool, accountMode: OpenAIWSIngressModeHTTPBridge}, + {name: "default HTTP bridge", routerEnabled: true, defaultMode: OpenAIWSIngressModeHTTPBridge}, + {name: "ctx pool overrides bridge default", routerEnabled: true, defaultMode: OpenAIWSIngressModeHTTPBridge, accountMode: OpenAIWSIngressModeCtxPool, wantArmed: true}, + {name: "disabled router retains legacy preemption", defaultMode: OpenAIWSIngressModeHTTPBridge, accountMode: OpenAIWSIngressModeHTTPBridge, wantArmed: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &config.Config{} + cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = tt.routerEnabled + cfg.Gateway.OpenAIWS.IngressModeDefault = tt.defaultMode + cache := &openAIWSSessionPreemptCacheStub{} + svc := &OpenAIGatewayService{cfg: cfg, cache: cache} + groupID := int64(7) + newContext := func() *gin.Context { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil) + c.Request.Header.Set("session-id", "shared-session") + c.Set("api_key", &APIKey{ID: 11, GroupID: &groupID}) + return c + } + account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Extra: map[string]any{"openai_oauth_responses_websockets_v2_mode": tt.accountMode}} + firstMessage := []byte(`{"type":"response.create","prompt_cache_key":"cache-a","input":"first request"}`) + secondMessage := []byte(`{"type":"response.create","prompt_cache_key":"cache-b","input":"second request"}`) + first, cleanupFirst, firstArmed := svc.BeginOpenAIWSIngressSessionPreemption(context.Background(), newContext(), account, firstMessage) + defer cleanupFirst() + second, cleanupSecond, secondArmed := svc.BeginOpenAIWSIngressSessionPreemption(context.Background(), newContext(), account, secondMessage) + defer cleanupSecond() + require.Equal(t, tt.wantArmed, firstArmed) + require.Equal(t, tt.wantArmed, secondArmed) + require.NoError(t, second.Err()) + if tt.wantArmed { + require.True(t, IsOpenAIWSSessionPreemptedError(context.Cause(first))) + } else { + require.NoError(t, first.Err(), "independent HTTP bridges must not cancel each other") + cache.mu.Lock() + ownerCount := len(cache.owners) + cache.mu.Unlock() + require.Zero(t, ownerCount, "HTTP bridges must not claim a distributed preemption owner") + } + }) + } +} + func TestNewOpenAIWSSessionPreemptKeyRequiresFullIsolationScope(t *testing.T) { _, ok := newOpenAIWSSessionPreemptKey(0, 11, "sess") require.False(t, ok) diff --git a/backend/internal/service/openai_ws_state_store.go b/backend/internal/service/openai_ws_state_store.go index fe4ee54926..70658f6f61 100644 --- a/backend/internal/service/openai_ws_state_store.go +++ b/backend/internal/service/openai_ws_state_store.go @@ -47,6 +47,18 @@ type openAIWSSessionConnBinding struct { expiresAt time.Time } +// openAIWSInvalidEncryptedBinding 记录一个会话中已被上游判定失效的 +// encrypted_content 摘要(invalid_encrypted_content lineage)。 +type openAIWSInvalidEncryptedBinding struct { + digests map[string]struct{} + expiresAt time.Time +} + +// openAIWSInvalidEncryptedDigestsPerSession 是单会话摘要集合的存储自保护上限。 +// 超出后新增摘要被忽略,仅退化为"该项下次仍触发一次上游拒绝后的常规 recovery", +// 不影响正确性。 +const openAIWSInvalidEncryptedDigestsPerSession = 512 + // OpenAIWSStateStore 管理 WSv2 的粘连状态。 // - response_id -> account_id 用于续链路由 // - response_id -> conn_id 用于连接内上下文复用 @@ -71,6 +83,15 @@ type OpenAIWSStateStore interface { BindSessionConn(groupID int64, sessionHash, connID string, ttl time.Duration) GetSessionConn(groupID int64, sessionHash string) (string, bool) DeleteSessionConn(groupID int64, sessionHash string) + + // invalid_encrypted_content lineage:按会话记录已被上游拒绝的 + // encrypted_content 摘要,后续 turn 进场时仅剥离命中项,避免同一失效 + // 密文随客户端历史反复触发"整包被拒→剥离→重试/重连"。仅进程内有效。 + MarkSessionInvalidEncryptedContent(groupID int64, sessionHash string, digests []string, ttl time.Duration) + GetSessionInvalidEncryptedContentDigests(groupID int64, sessionHash string) map[string]struct{} + // HasAnySessionInvalidEncryptedContent 是热路径快速探测:全局无记录时 + // 调用方可跳过会话哈希计算与摘要匹配。 + HasAnySessionInvalidEncryptedContent() bool } type defaultOpenAIWSStateStore struct { @@ -87,18 +108,22 @@ type defaultOpenAIWSStateStore struct { sessionToConnMu sync.RWMutex sessionToConn map[string]openAIWSSessionConnBinding + sessionInvalidEncryptedMu sync.RWMutex + sessionInvalidEncrypted map[string]openAIWSInvalidEncryptedBinding + lastCleanupUnixNano atomic.Int64 } // NewOpenAIWSStateStore 创建默认 WS 状态存储。 func NewOpenAIWSStateStore(cache GatewayCache) OpenAIWSStateStore { store := &defaultOpenAIWSStateStore{ - cache: cache, - responseToAccount: make(map[string]openAIWSAccountBinding, 256), - responseOwners: make(map[string]openAIHTTPResponseOwnerBinding, 256), - responseToConn: make(map[string]openAIWSConnBinding, 256), - sessionToTurnState: make(map[string]openAIWSTurnStateBinding, 256), - sessionToConn: make(map[string]openAIWSSessionConnBinding, 256), + cache: cache, + responseToAccount: make(map[string]openAIWSAccountBinding, 256), + responseOwners: make(map[string]openAIHTTPResponseOwnerBinding, 256), + responseToConn: make(map[string]openAIWSConnBinding, 256), + sessionToTurnState: make(map[string]openAIWSTurnStateBinding, 256), + sessionToConn: make(map[string]openAIWSSessionConnBinding, 256), + sessionInvalidEncrypted: make(map[string]openAIWSInvalidEncryptedBinding), } store.lastCleanupUnixNano.Store(time.Now().UnixNano()) return store @@ -396,6 +421,76 @@ func (s *defaultOpenAIWSStateStore) DeleteSessionConn(groupID int64, sessionHash s.sessionToConnMu.Unlock() } +func (s *defaultOpenAIWSStateStore) MarkSessionInvalidEncryptedContent(groupID int64, sessionHash string, digests []string, ttl time.Duration) { + key := openAIWSSessionTurnStateKey(groupID, sessionHash) + if key == "" || len(digests) == 0 { + return + } + ttl = normalizeOpenAIWSTTL(ttl) + s.maybeCleanup() + + now := time.Now() + overflowed := 0 + s.sessionInvalidEncryptedMu.Lock() + ensureBindingCapacity(s.sessionInvalidEncrypted, key, openAIWSStateStoreMaxEntriesPerMap) + binding, ok := s.sessionInvalidEncrypted[key] + if !ok || now.After(binding.expiresAt) || binding.digests == nil { + binding = openAIWSInvalidEncryptedBinding{digests: make(map[string]struct{}, len(digests))} + } + for _, digest := range digests { + digest = strings.TrimSpace(digest) + if digest == "" { + continue + } + if len(binding.digests) >= openAIWSInvalidEncryptedDigestsPerSession { + if _, exists := binding.digests[digest]; !exists { + overflowed++ + } + continue + } + binding.digests[digest] = struct{}{} + } + binding.expiresAt = now.Add(ttl) + s.sessionInvalidEncrypted[key] = binding + s.sessionInvalidEncryptedMu.Unlock() + + // HasAny/Get 在每个 OpenAI 请求热路径上抢同一把锁,日志 IO 必须在锁外。 + if overflowed > 0 { + logOpenAIWSModeInfo( + "invalid_encrypted_lineage_capacity_overflow dropped_digests=%d capacity=%d", + overflowed, + openAIWSInvalidEncryptedDigestsPerSession, + ) + } +} + +func (s *defaultOpenAIWSStateStore) GetSessionInvalidEncryptedContentDigests(groupID int64, sessionHash string) map[string]struct{} { + key := openAIWSSessionTurnStateKey(groupID, sessionHash) + if key == "" { + return nil + } + s.maybeCleanup() + + now := time.Now() + s.sessionInvalidEncryptedMu.RLock() + defer s.sessionInvalidEncryptedMu.RUnlock() + binding, ok := s.sessionInvalidEncrypted[key] + if !ok || now.After(binding.expiresAt) || len(binding.digests) == 0 { + return nil + } + digests := make(map[string]struct{}, len(binding.digests)) + for digest := range binding.digests { + digests[digest] = struct{}{} + } + return digests +} + +func (s *defaultOpenAIWSStateStore) HasAnySessionInvalidEncryptedContent() bool { + s.sessionInvalidEncryptedMu.RLock() + defer s.sessionInvalidEncryptedMu.RUnlock() + return len(s.sessionInvalidEncrypted) > 0 +} + func (s *defaultOpenAIWSStateStore) maybeCleanup() { if s == nil { return @@ -429,6 +524,26 @@ func (s *defaultOpenAIWSStateStore) maybeCleanup() { s.sessionToConnMu.Lock() cleanupExpiredSessionConnBindings(s.sessionToConn, now, openAIWSStateStoreCleanupMaxPerMap) s.sessionToConnMu.Unlock() + + s.sessionInvalidEncryptedMu.Lock() + cleanupExpiredInvalidEncryptedBindings(s.sessionInvalidEncrypted, now, openAIWSStateStoreCleanupMaxPerMap) + s.sessionInvalidEncryptedMu.Unlock() +} + +func cleanupExpiredInvalidEncryptedBindings(bindings map[string]openAIWSInvalidEncryptedBinding, now time.Time, maxScan int) { + if len(bindings) == 0 || maxScan <= 0 { + return + } + scanned := 0 + for key, binding := range bindings { + if now.After(binding.expiresAt) { + delete(bindings, key) + } + scanned++ + if scanned >= maxScan { + break + } + } } func cleanupExpiredAccountBindings(bindings map[string]openAIWSAccountBinding, now time.Time, maxScan int) { diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index fdb539a073..3c6bd076dc 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -102,6 +102,7 @@ type RelayTraceEvent struct { type relayState struct { usage Usage turnUsage Usage + turnWroteDownstream atomic.Bool requestModelMu sync.RWMutex requestModel string pendingTurnStart atomic.Pointer[time.Time] @@ -204,7 +205,8 @@ func Relay( return upstreamConn.WriteFrame(writeCtx, msgType, payload) } writeClientFrameUpstream := func(msgType coderws.MessageType, payload []byte) error { - if isClientResponseCreateFrame(msgType, payload) { + isResponseCreate := isClientResponseCreateFrame(msgType, payload) + if isResponseCreate { state.setRequestModel(strings.TrimSpace(gjson.GetBytes(payload, "model").String())) turnStartedAt := time.Time{} if options.TakeNextTurnStartedAt != nil { @@ -214,8 +216,18 @@ func Relay( turnStartedAt = nowFn() } state.setPendingTurnStartedAt(turnStartedAt) + // The policy-enforcing client connection has accepted this turn. + // Reset before the write so an immediate upstream response cannot race + // with the transport returning from WriteFrame. + state.turnWroteDownstream.Store(false) } - return writeUpstream(msgType, payload) + err := writeUpstream(msgType, payload) + if err != nil && isResponseCreate { + // The relay exits on this error, but retain the previous turn's state + // for accurate diagnostics while the two relay goroutines settle. + state.turnWroteDownstream.Store(true) + } + return err } writeClient := func(msgType coderws.MessageType, payload []byte) error { // 下行写超时故意不挂在 relayCtx 上:coder/websocket 在已武装的 write @@ -538,7 +550,7 @@ func runUpstreamToClient( // the upstream has started a Responses turn, success still requires a // terminal protocol event. Treat an early 1000/EOF as a relay failure so // the adapter does not report relay_completed with an active turn. - if graceful && openAIWSRelayActiveTurnID(state) != "" { + if graceful && state.hasUnfinishedTurn() { graceful = false err = errors.New("upstream websocket closed before terminal event: " + err.Error()) } @@ -559,7 +571,11 @@ func runUpstreamToClient( } markActivity() if beforeWriteClient != nil { - if err := beforeWriteClient(msgType, payload, wroteDownstream); err != nil { + wroteDownstreamInTurn := wroteDownstream + if state != nil { + wroteDownstreamInTurn = state.turnWroteDownstream.Load() + } + if err := beforeWriteClient(msgType, payload, wroteDownstreamInTurn); err != nil { emitRelayTrace(onTrace, RelayTraceEvent{ Stage: "upstream_message_rejected", Direction: "upstream_to_client", @@ -585,7 +601,14 @@ func runUpstreamToClient( } observedEvent = observeUpstreamMessage(state, payload, startAt, nowFn, onUsageParseFailure) case coderws.MessageBinary: - // binary frame 直接透传,不进入 JSON 观测路径(避免无效解析开销)。 + // Binary frames remain opaque for usage/result observation, but a JSON + // terminal still settles relay lifecycle. Otherwise the pending-turn + // disconnect guard would turn an already-delivered terminal into a false + // missing-terminal failure when the upstream closes normally. + if isTerminalEvent(strings.TrimSpace(gjson.GetBytes(payload, "type").String())) { + state.consumePendingTurnStartedAt() + openAIWSRelayDiscardActiveTurnTiming(state) + } } emitTurnComplete(onTurnComplete, state, observedEvent) if dropDownstreamWrites != nil && dropDownstreamWrites.Load() { @@ -630,6 +653,9 @@ func runUpstreamToClient( return } wroteDownstream = true + if state != nil { + state.turnWroteDownstream.Store(true) + } if afterWriteClient != nil { afterWriteClient(msgType, payload) } @@ -995,6 +1021,13 @@ func (s *relayState) consumePendingTurnStartedAt() time.Time { return *startedAt } +func (s *relayState) hasUnfinishedTurn() bool { + if s == nil { + return false + } + return s.pendingTurnStart.Load() != nil || s.activeTurn != nil +} + func openAIWSRelayDeleteTurnTiming(state *relayState, responseID string) (relayTurnTiming, bool) { if state == nil || state.turnTimingByID == nil { return relayTurnTiming{}, false diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go index a5505b4981..394aa713c5 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "strings" "sync" "sync/atomic" "testing" @@ -309,11 +310,46 @@ func TestRelay_UpstreamDisconnect(t *testing.T) { defer cancel() result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{}) - // 上游 EOF 属于 disconnect,标记为 graceful - require.Nil(t, relayExit, "上游 EOF 应被视为 graceful disconnect") + require.NotNil(t, relayExit) + require.Equal(t, "read_upstream", relayExit.Stage) + require.False(t, relayExit.Graceful) + require.ErrorContains(t, relayExit.Err, "upstream websocket closed before terminal event") require.Equal(t, "gpt-4o", result.RequestModel) } +func TestRelay_UpstreamNormalCloseBeforeResponseIDIsFailure(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := &eofReplacementFrameConn{ + FrameConn: newPassthroughTestFrameConn([]passthroughTestFrame{ + { + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.created","status":"in_progress"}`), + }, + { + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.in_progress","status":"in_progress"}`), + }, + }, true), + err: coderws.CloseError{Code: coderws.StatusNormalClosure}, + } + + firstPayload := []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{}) + require.NotNil(t, relayExit) + require.Equal(t, "read_upstream", relayExit.Stage) + require.False(t, relayExit.Graceful) + require.True(t, relayExit.WroteDownstream) + require.ErrorContains(t, relayExit.Err, "upstream websocket closed before terminal event") + require.Empty(t, result.RequestID) + require.Empty(t, result.TerminalEventType) + require.Len(t, clientConn.Writes(), 2) +} + func TestRelay_UpstreamNormalCloseBeforeTerminalIsFailure(t *testing.T) { t.Parallel() @@ -903,6 +939,63 @@ func TestRelay_OnTurnComplete_UsesSubsequentResponseCreateTimeAcrossPricingBound } } +func TestRelay_BeforeWriteClientTracksDownstreamPerTurn(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := newPassthroughTestFrameConn(nil, false) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + wroteStates := make(chan bool, 3) + done := make(chan *RelayExit, 1) + stopErr := errors.New("stop after second-turn error") + go func() { + _, relayExit := Relay( + ctx, + clientConn, + upstreamConn, + []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`), + RelayOptions{BeforeWriteClient: func(_ coderws.MessageType, payload []byte, wroteDownstream bool) error { + wroteStates <- wroteDownstream + if strings.Contains(string(payload), `"type":"error"`) { + return stopErr + } + return nil + }}, + ) + done <- relayExit + }() + + require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 1 }, time.Second, time.Millisecond) + upstreamConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_first","usage":{"input_tokens":1,"output_tokens":1}}}`), + } + require.False(t, <-wroteStates) + require.Eventually(t, func() bool { return len(clientConn.Writes()) == 1 }, time.Second, time.Millisecond) + + clientConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`), + } + require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 2 }, time.Second, time.Millisecond) + upstreamConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"error","error":{"type":"usage_limit_reached"}}`), + } + require.False(t, <-wroteStates, "the next turn must not inherit the first turn's downstream write") + + select { + case relayExit := <-done: + require.NotNil(t, relayExit) + require.ErrorIs(t, relayExit.Err, stopErr) + require.True(t, relayExit.WroteDownstream, "connection-wide diagnostics must retain prior output") + case <-time.After(time.Second): + t.Fatal("relay did not stop after the rejected second-turn event") + } +} + func TestRelay_BinaryFramePassthrough(t *testing.T) { t.Parallel() @@ -921,7 +1014,10 @@ func TestRelay_BinaryFramePassthrough(t *testing.T) { defer cancel() result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{}) - require.Nil(t, relayExit) + require.NotNil(t, relayExit) + require.Equal(t, "read_upstream", relayExit.Stage) + require.False(t, relayExit.Graceful) + require.ErrorContains(t, relayExit.Err, "upstream websocket closed before terminal event") // binary frame 不解析 usage require.Equal(t, 0, result.Usage.InputTokens) @@ -986,7 +1082,12 @@ func TestRelay_PreservesFirstMessageType(t *testing.T) { t.Parallel() clientConn := newPassthroughTestFrameConn(nil, false) - upstreamConn := newPassthroughTestFrameConn(nil, true) + upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{ + { + msgType: coderws.MessageBinary, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_binary_first_type"}}`), + }, + }, true) firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -1001,6 +1102,9 @@ func TestRelay_PreservesFirstMessageType(t *testing.T) { require.Len(t, upstreamWrites, 1) require.Equal(t, coderws.MessageBinary, upstreamWrites[0].msgType) require.Equal(t, firstPayload, upstreamWrites[0].payload) + clientWrites := clientConn.Writes() + require.Len(t, clientWrites, 1) + require.Equal(t, coderws.MessageBinary, clientWrites[0].msgType) } func TestRelay_UsageParseFailureDoesNotBlockRelay(t *testing.T) { diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 8d29839b3b..556ad06274 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -1054,6 +1054,11 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( return payload, nil, err } } + if hooks != nil && hooks.BeforeTurn != nil { + if err := hooks.BeforeTurn(turnNo); err != nil { + return payload, nil, err + } + } if hooks != nil && hooks.MapRequestModel != nil { upstreamModel, err := hooks.MapRequestModel(turnNo, requestModelForThisFrame) if err != nil { @@ -1299,6 +1304,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( truncateOpenAIWSLogValue(errTypeRaw, openAIWSLogValueMaxLen), truncateOpenAIWSLogValue(errMsgRaw, openAIWSLogValueMaxLen), ) + if completedTurns.Load() > 0 { + return NewOpenAIWSClientCloseError( + coderws.StatusTryAgainLater, + "upstream rate limit exceeded; please reconnect", + errors.New("later passthrough turn was rate limited before output"), + ) + } return s.newOpenAIWSRateLimitFailoverError(account, handshakeHeaders, payload, errMsgRaw) }, OnTrace: func(event openaiwsv2.RelayTraceEvent) { @@ -1477,21 +1489,9 @@ func openAIWSPassthroughRelayClientClose(exit openaiwsv2.RelayExit, completedTur } func markOpenAIWSV2PassthroughCyberPolicy(c *gin.Context, payload []byte) bool { - hit, code, message := detectOpenAICyberPolicy(payload) - if !hit { - return false - } usage := OpenAIUsage{} parseOpenAIWSResponseUsageFromCompletedEvent(payload, &usage) - MarkOpsCyberPolicy(c, CyberPolicyMark{ - Code: code, - Message: message, - Body: truncateString(string(payload), 4096), - UpstreamStatus: http.StatusOK, - UpstreamInTok: usage.InputTokens, - UpstreamOutTok: usage.OutputTokens, - }) - return true + return markOpenAICyberPolicyEvent(c, payload, http.StatusOK, &usage) } func (s *OpenAIGatewayService) mapOpenAIWSPassthroughDialError( diff --git a/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go b/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go index 5fa6a7def9..24afbeda09 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go +++ b/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strings" @@ -210,6 +211,61 @@ func startPassthroughLifecycleServerWithHooks( return server, serverErr } +func TestPassthroughLifecycle_LaterTurnPreOutputRateLimitRequestsReconnect(t *testing.T) { + gin.SetMode(gin.TestMode) + controlCtx, cancelControl := context.WithCancelCause(context.Background()) + defer cancelControl(context.Canceled) + upstream := newStagedPassthroughConn() + account := passthroughLifecycleAccount() + repo := &openAIWSRateLimitSignalRepo{stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{*account}}} + svc := newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream) + svc.accountRepo = repo + svc.rateLimitService = &RateLimitService{accountRepo: repo} + + server, serverErr := startPassthroughLifecycleServer(t, controlCtx, svc, account) + defer server.Close() + clientConn := dialPassthroughLifecycleClient(t, server) + defer func() { _ = clientConn.CloseNow() }() + firstRequest := requirePassthroughUpstreamWrite(t, upstream, time.Second) + require.Equal(t, "response.create", gjson.GetBytes(firstRequest, "type").String()) + + upstream.Send(`{"type":"response.completed","response":{"id":"resp_first","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + completed, err := readPassthroughLifecycleFrame(t, clientConn, time.Second) + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(completed, "type").String()) + + writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`)) + cancelWrite() + require.NoError(t, err) + secondRequest := requirePassthroughUpstreamWrite(t, upstream, time.Second) + require.Equal(t, "response.create", gjson.GetBytes(secondRequest, "type").String()) + + resetAt := time.Now().Add(90 * time.Minute).Unix() + upstream.Send(fmt.Sprintf(`{"type":"error","error":{"code":"rate_limit_exceeded","type":"usage_limit_reached","message":"The usage limit has been reached","resets_at":%d}}`, resetAt)) + _, err = readPassthroughLifecycleFrame(t, clientConn, time.Second) + var websocketCloseErr coderws.CloseError + require.ErrorAs(t, err, &websocketCloseErr) + require.Equal(t, coderws.StatusTryAgainLater, websocketCloseErr.Code) + require.Equal(t, "upstream rate limit exceeded; please reconnect", websocketCloseErr.Reason) + require.Len(t, repo.rateLimitCalls, 1) + require.WithinDuration(t, time.Unix(resetAt, 0), repo.rateLimitCalls[0], 2*time.Second) + + select { + case err := <-serverErr: + var closeErr *OpenAIWSClientCloseError + require.ErrorAs(t, err, &closeErr) + require.Equal(t, coderws.StatusTryAgainLater, closeErr.StatusCode()) + case <-time.After(time.Second): + t.Fatal("later-turn rate limit did not terminate passthrough") + } + select { + case replay := <-upstream.writes: + t.Fatalf("later-turn reconnect must not replay the retained first request: %s", replay) + default: + } +} + func TestPassthroughLifecycle_CyberTerminalEventsMarkBeforeAfterTurn(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/ops_cleanup_overlay_test.go b/backend/internal/service/ops_cleanup_overlay_test.go index f751a42627..f1e1cbf9dc 100644 --- a/backend/internal/service/ops_cleanup_overlay_test.go +++ b/backend/internal/service/ops_cleanup_overlay_test.go @@ -45,7 +45,8 @@ func TestComputeEffective_FallbackToCfgWhenSettingsAbsent(t *testing.T) { svc.computeEffectiveLocked(context.Background()) - if svc.effective != base { + want := opsCleanupEffectiveConfig{OpsCleanupConfig: base, SystemLogRetentionDays: 30} + if svc.effective != want { t.Fatalf("expected effective == cfg base, got %#v", svc.effective) } } @@ -70,12 +71,15 @@ func TestComputeEffective_SettingsOverridesAll(t *testing.T) { svc.computeEffectiveLocked(context.Background()) - want := config.OpsCleanupConfig{ - Enabled: true, - Schedule: "0 * * * *", - ErrorLogRetentionDays: 0, - MinuteMetricsRetentionDays: 7, - HourlyMetricsRetentionDays: 14, + want := opsCleanupEffectiveConfig{ + OpsCleanupConfig: config.OpsCleanupConfig{ + Enabled: true, + Schedule: "0 * * * *", + ErrorLogRetentionDays: 0, + MinuteMetricsRetentionDays: 7, + HourlyMetricsRetentionDays: 14, + }, + SystemLogRetentionDays: 30, } if svc.effective != want { t.Fatalf("effective mismatch:\nwant %#v\n got %#v", want, svc.effective) @@ -156,11 +160,72 @@ func TestComputeEffective_BadJSONFallsBackToCfg(t *testing.T) { svc.computeEffectiveLocked(context.Background()) - if svc.effective != base { + want := opsCleanupEffectiveConfig{OpsCleanupConfig: base, SystemLogRetentionDays: 30} + if svc.effective != want { t.Fatalf("expected fallback to cfg on bad JSON, got %#v", svc.effective) } } +func TestComputeEffective_RuntimeConfigOwnsSystemLogRetention(t *testing.T) { + repo := newRuntimeSettingRepoStub() + writeAdvancedSettings(t, repo, OpsDataRetentionSettings{ + CleanupEnabled: true, + CleanupSchedule: "0 2 * * *", + ErrorLogRetentionDays: 90, + }) + repo.values[SettingKeyOpsRuntimeLogConfig] = `{"retention_days":7}` + svc := makeOverlayService(repo, config.OpsCleanupConfig{ErrorLogRetentionDays: 30}) + + svc.computeEffectiveLocked(context.Background()) + + if svc.effective.ErrorLogRetentionDays != 90 { + t.Fatalf("error log retention = %d, want 90", svc.effective.ErrorLogRetentionDays) + } + if svc.effective.SystemLogRetentionDays != 7 { + t.Fatalf("system log retention = %d, want 7", svc.effective.SystemLogRetentionDays) + } +} + +func TestComputeEffective_InvalidRuntimeConfigDoesNotDiscardAdvancedSettings(t *testing.T) { + repo := newRuntimeSettingRepoStub() + writeAdvancedSettings(t, repo, OpsDataRetentionSettings{ + CleanupEnabled: true, + CleanupSchedule: "0 * * * *", + ErrorLogRetentionDays: 14, + }) + repo.values[SettingKeyOpsRuntimeLogConfig] = `{invalid-json}` + svc := makeOverlayService(repo, config.OpsCleanupConfig{ErrorLogRetentionDays: 30}) + + svc.computeEffectiveLocked(context.Background()) + + if !svc.effective.Enabled || svc.effective.ErrorLogRetentionDays != 14 { + t.Fatalf("advanced settings were discarded: %#v", svc.effective) + } + if svc.effective.SystemLogRetentionDays != 30 { + t.Fatalf("system log retention = %d, want default 30", svc.effective.SystemLogRetentionDays) + } +} + +func TestComputeEffective_PartialAdvancedSettingsUseSafeDefaults(t *testing.T) { + repo := newRuntimeSettingRepoStub() + repo.values[SettingKeyOpsAdvancedSettings] = `{"data_retention":{"cleanup_enabled":true}}` + svc := makeOverlayService(repo, config.OpsCleanupConfig{ + Schedule: "0 3 * * *", + ErrorLogRetentionDays: 90, + MinuteMetricsRetentionDays: 90, + HourlyMetricsRetentionDays: 90, + }) + + svc.computeEffectiveLocked(context.Background()) + + if svc.effective.Schedule != opsCleanupDefaultSchedule || + svc.effective.ErrorLogRetentionDays != 30 || + svc.effective.MinuteMetricsRetentionDays != 30 || + svc.effective.HourlyMetricsRetentionDays != 30 { + t.Fatalf("partial settings did not use safe defaults: %#v", svc.effective) + } +} + // 验证 OpsService.UpdateOpsAdvancedSettings 写入后会调用 cleanupReloader.Reload。 type fakeCleanupReloader struct { calls int diff --git a/backend/internal/service/ops_cleanup_service.go b/backend/internal/service/ops_cleanup_service.go index 38e94fb17a..52024e9abc 100644 --- a/backend/internal/service/ops_cleanup_service.go +++ b/backend/internal/service/ops_cleanup_service.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "encoding/json" - "errors" "fmt" "strings" "sync" @@ -59,11 +58,16 @@ type OpsCleanupService struct { cron *cron.Cron started bool stopped bool - effective config.OpsCleanupConfig + effective opsCleanupEffectiveConfig warnNoRedisOnce sync.Once } +type opsCleanupEffectiveConfig struct { + config.OpsCleanupConfig + SystemLogRetentionDays int +} + func NewOpsCleanupService( opsRepo OpsRepository, db *sql.DB, @@ -166,9 +170,10 @@ func (s *OpsCleanupService) applyScheduleLocked(ctx context.Context) error { c.Start() s.cron = c logger.LegacyPrintf("service.ops_cleanup", - "[OpsCleanup] scheduled (schedule=%q tz=%s retention_days=err:%d/min:%d/hour:%d)", + "[OpsCleanup] scheduled (schedule=%q tz=%s retention_days=err:%d/system:%d/min:%d/hour:%d)", schedule, loc.String(), s.effective.ErrorLogRetentionDays, + s.effective.SystemLogRetentionDays, s.effective.MinuteMetricsRetentionDays, s.effective.HourlyMetricsRetentionDays, ) @@ -198,11 +203,14 @@ func (s *OpsCleanupService) Reload(ctx context.Context) error { // - Schedule:settings 非空时覆盖,否则保留 cfg // - *RetentionDays:settings >=0 时覆盖(包括 0=TRUNCATE),<0 沿用 cfg // -// 若 settings 表无该 key(ErrSettingNotFound)或解析失败,整体 fallback 到 cfg.Ops.Cleanup。 +// System log retention has a separate owner: settings.ops_runtime_log_config.retention_days. +// Missing or invalid settings fall back independently instead of disabling all cleanup overlays. func (s *OpsCleanupService) computeEffectiveLocked(ctx context.Context) { - base := config.OpsCleanupConfig{} + base := opsCleanupEffectiveConfig{ + SystemLogRetentionDays: defaultOpsRuntimeLogConfig(s.cfg).RetentionDays, + } if s.cfg != nil { - base = s.cfg.Ops.Cleanup + base.OpsCleanupConfig = s.cfg.Ops.Cleanup } defer func() { s.effective = base }() @@ -212,38 +220,54 @@ func (s *OpsCleanupService) computeEffectiveLocked(ctx context.Context) { if ctx == nil { ctx = context.Background() } - raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpsAdvancedSettings) + values, err := s.settingRepo.GetMultiple(ctx, []string{ + SettingKeyOpsAdvancedSettings, + SettingKeyOpsRuntimeLogConfig, + }) if err != nil { - if !errors.Is(err, ErrSettingNotFound) { - logger.LegacyPrintf("service.ops_cleanup", - "[OpsCleanup] read advanced settings failed, using cfg: %v", err) - } - return - } - var adv OpsAdvancedSettings - if err := json.Unmarshal([]byte(raw), &adv); err != nil { logger.LegacyPrintf("service.ops_cleanup", - "[OpsCleanup] parse advanced settings failed, using cfg: %v", err) + "[OpsCleanup] read cleanup settings failed, using defaults: %v", err) return } - dr := adv.DataRetention - base.Enabled = dr.CleanupEnabled - if sched := strings.TrimSpace(dr.CleanupSchedule); sched != "" { - base.Schedule = sched + if raw, ok := values[SettingKeyOpsAdvancedSettings]; ok { + adv := defaultOpsAdvancedSettingsForConfig(s.cfg) + if err := json.Unmarshal([]byte(raw), adv); err != nil { + logger.LegacyPrintf("service.ops_cleanup", + "[OpsCleanup] parse advanced settings failed, using cfg: %v", err) + } else { + dr := adv.DataRetention + base.Enabled = dr.CleanupEnabled + if sched := strings.TrimSpace(dr.CleanupSchedule); sched != "" { + base.Schedule = sched + } + if dr.ErrorLogRetentionDays >= 0 { + base.ErrorLogRetentionDays = dr.ErrorLogRetentionDays + } + if dr.MinuteMetricsRetentionDays >= 0 { + base.MinuteMetricsRetentionDays = dr.MinuteMetricsRetentionDays + } + if dr.HourlyMetricsRetentionDays >= 0 { + base.HourlyMetricsRetentionDays = dr.HourlyMetricsRetentionDays + } + } } - if dr.ErrorLogRetentionDays >= 0 { - base.ErrorLogRetentionDays = dr.ErrorLogRetentionDays - } - if dr.MinuteMetricsRetentionDays >= 0 { - base.MinuteMetricsRetentionDays = dr.MinuteMetricsRetentionDays - } - if dr.HourlyMetricsRetentionDays >= 0 { - base.HourlyMetricsRetentionDays = dr.HourlyMetricsRetentionDays + if raw, ok := values[SettingKeyOpsRuntimeLogConfig]; ok { + defaults := defaultOpsRuntimeLogConfig(s.cfg) + runtimeCfg := *defaults + if err := json.Unmarshal([]byte(raw), &runtimeCfg); err != nil { + logger.LegacyPrintf("service.ops_cleanup", + "[OpsCleanup] parse runtime log config failed, using default system-log retention: %v", err) + } else { + normalizeOpsRuntimeLogConfig(&runtimeCfg, defaults) + if runtimeCfg.RetentionDays >= 1 && runtimeCfg.RetentionDays <= 3650 { + base.SystemLogRetentionDays = runtimeCfg.RetentionDays + } + } } } // snapshotEffective 取一份 effective 副本(runCleanupOnce 等读路径使用)。 -func (s *OpsCleanupService) snapshotEffective() config.OpsCleanupConfig { +func (s *OpsCleanupService) snapshotEffective() opsCleanupEffectiveConfig { s.mu.Lock() defer s.mu.Unlock() return s.effective @@ -305,8 +329,8 @@ func (s *OpsCleanupService) runCleanupOnce(ctx context.Context) (opsCleanupDelet {effective.ErrorLogRetentionDays, "ops_error_logs", "created_at", false, &out.errorLogs}, {effective.ErrorLogRetentionDays, "ops_ingress_reject_aggregates", "bucket_start", false, &out.ingressRejects}, {effective.ErrorLogRetentionDays, "ops_alert_events", "created_at", false, &out.alertEvents}, - {effective.ErrorLogRetentionDays, "ops_system_logs", "created_at", false, &out.systemLogs}, - {effective.ErrorLogRetentionDays, "ops_system_log_cleanup_audits", "created_at", false, &out.logAudits}, + {effective.SystemLogRetentionDays, "ops_system_logs", "created_at", false, &out.systemLogs}, + {effective.SystemLogRetentionDays, "ops_system_log_cleanup_audits", "created_at", false, &out.logAudits}, {effective.MinuteMetricsRetentionDays, "ops_system_metrics", "created_at", false, &out.systemMetrics}, {effective.HourlyMetricsRetentionDays, "ops_metrics_hourly", "bucket_start", false, &out.hourlyPreagg}, {effective.HourlyMetricsRetentionDays, "ops_metrics_daily", "bucket_date", true, &out.dailyPreagg}, diff --git a/backend/internal/service/ops_log_runtime.go b/backend/internal/service/ops_log_runtime.go index ed8aefa9a3..637ab8d69f 100644 --- a/backend/internal/service/ops_log_runtime.go +++ b/backend/internal/service/ops_log_runtime.go @@ -15,13 +15,14 @@ import ( func defaultOpsRuntimeLogConfig(cfg *config.Config) *OpsRuntimeLogConfig { out := &OpsRuntimeLogConfig{ - Level: "info", - EnableSampling: false, - SamplingInitial: 100, - SamplingNext: 100, - Caller: true, - StacktraceLevel: "error", - RetentionDays: 30, + Level: "info", + PersistAccessLogs: false, + EnableSampling: false, + SamplingInitial: 100, + SamplingNext: 100, + Caller: true, + StacktraceLevel: "error", + RetentionDays: 30, } if cfg == nil { return out @@ -32,8 +33,8 @@ func defaultOpsRuntimeLogConfig(cfg *config.Config) *OpsRuntimeLogConfig { out.SamplingNext = cfg.Log.Sampling.Thereafter out.Caller = cfg.Log.Caller out.StacktraceLevel = strings.ToLower(strings.TrimSpace(cfg.Log.StacktraceLevel)) - if cfg.Ops.Cleanup.ErrorLogRetentionDays > 0 { - out.RetentionDays = cfg.Ops.Cleanup.ErrorLogRetentionDays + if cfg.Ops.Cleanup.SystemLogRetentionDays > 0 { + out.RetentionDays = cfg.Ops.Cleanup.SystemLogRetentionDays } return out } @@ -144,7 +145,7 @@ func (s *OpsService) UpdateRuntimeLogConfig(ctx context.Context, req *OpsRuntime return nil, err } - if err := applyOpsRuntimeLogConfig(&next); err != nil { + if err := s.applyRuntimeLogConfig(&next); err != nil { s.auditRuntimeLogConfigFailure(operatorID, oldCfg, &next, "apply_failed: "+err.Error()) return nil, err } @@ -159,12 +160,13 @@ func (s *OpsService) UpdateRuntimeLogConfig(ctx context.Context, req *OpsRuntime } if err := s.settingRepo.Set(ctx, SettingKeyOpsRuntimeLogConfig, string(encoded)); err != nil { // 存储失败时回滚到旧配置,避免内存状态与持久化状态不一致。 - _ = applyOpsRuntimeLogConfig(oldCfg) + _ = s.applyRuntimeLogConfig(oldCfg) s.auditRuntimeLogConfigFailure(operatorID, oldCfg, &next, "persist_failed: "+err.Error()) return nil, err } s.auditRuntimeLogConfigChange(operatorID, oldCfg, &next, "updated") + s.reloadOpsCleanup(ctx) return &next, nil } @@ -191,14 +193,14 @@ func (s *OpsService) ResetRuntimeLogConfig(ctx context.Context, operatorID int64 s.auditRuntimeLogConfigFailure(operatorID, oldCfg, resetCfg, "reset_validation_failed: "+err.Error()) return nil, err } - if err := applyOpsRuntimeLogConfig(resetCfg); err != nil { + if err := s.applyRuntimeLogConfig(resetCfg); err != nil { s.auditRuntimeLogConfigFailure(operatorID, oldCfg, resetCfg, "reset_apply_failed: "+err.Error()) return nil, err } // 清理 runtime 覆盖配置,回退到 env/yaml baseline。 if err := s.settingRepo.Delete(ctx, SettingKeyOpsRuntimeLogConfig); err != nil && !errors.Is(err, ErrSettingNotFound) { - _ = applyOpsRuntimeLogConfig(oldCfg) + _ = s.applyRuntimeLogConfig(oldCfg) s.auditRuntimeLogConfigFailure(operatorID, oldCfg, resetCfg, "reset_persist_failed: "+err.Error()) return nil, err } @@ -209,9 +211,29 @@ func (s *OpsService) ResetRuntimeLogConfig(ctx context.Context, operatorID int64 resetCfg.UpdatedByUserID = operatorID s.auditRuntimeLogConfigChange(operatorID, oldCfg, resetCfg, "reset") + s.reloadOpsCleanup(ctx) return resetCfg, nil } +func (s *OpsService) applyRuntimeLogConfig(cfg *OpsRuntimeLogConfig) error { + if err := applyOpsRuntimeLogConfig(cfg); err != nil { + return err + } + if s != nil && s.systemLogSink != nil { + s.systemLogSink.SetPersistAccessLogs(cfg.PersistAccessLogs) + } + return nil +} + +func (s *OpsService) reloadOpsCleanup(ctx context.Context) { + if s == nil || s.cleanupReloader == nil { + return + } + if err := s.cleanupReloader.Reload(ctx); err != nil { + logger.LegacyPrintf("service.ops_log_runtime", "[OpsLogRuntime] cleanup reload failed: %v", err) + } +} + func applyOpsRuntimeLogConfig(cfg *OpsRuntimeLogConfig) error { if cfg == nil { return fmt.Errorf("nil runtime log config") @@ -238,7 +260,7 @@ func (s *OpsService) applyRuntimeLogConfigOnStartup(ctx context.Context) { if err != nil { return } - _ = applyOpsRuntimeLogConfig(cfg) + _ = s.applyRuntimeLogConfig(cfg) } func (s *OpsService) auditRuntimeLogConfigChange(operatorID int64, oldCfg *OpsRuntimeLogConfig, newCfg *OpsRuntimeLogConfig, action string) { diff --git a/backend/internal/service/ops_log_runtime_test.go b/backend/internal/service/ops_log_runtime_test.go index 0a8cfa9067..f4d202ceb0 100644 --- a/backend/internal/service/ops_log_runtime_test.go +++ b/backend/internal/service/ops_log_runtime_test.go @@ -21,6 +21,15 @@ type runtimeSettingRepoStub struct { deleteFn func(key string) error } +type runtimeCleanupReloader struct { + calls int +} + +func (r *runtimeCleanupReloader) Reload(context.Context) error { + r.calls++ + return nil +} + func newRuntimeSettingRepoStub() *runtimeSettingRepoStub { return &runtimeSettingRepoStub{ values: map[string]string{}, @@ -181,7 +190,8 @@ func TestResetRuntimeLogConfig_ShouldFallbackToBaseline(t *testing.T) { }, Ops: config.OpsConfig{ Cleanup: config.OpsCleanupConfig{ - ErrorLogRetentionDays: 45, + ErrorLogRetentionDays: 7, + SystemLogRetentionDays: 45, }, }, }, @@ -282,7 +292,8 @@ func TestUpdateRuntimeLogConfig_PersistFailureRollback(t *testing.T) { } svc := &OpsService{ - settingRepo: repo, + settingRepo: repo, + systemLogSink: &OpsSystemLogSink{}, cfg: &config.Config{ Log: config.LogConfig{ Level: "info", @@ -311,13 +322,14 @@ func TestUpdateRuntimeLogConfig_PersistFailureRollback(t *testing.T) { } _, err := svc.UpdateRuntimeLogConfig(context.Background(), &OpsRuntimeLogConfig{ - Level: "debug", - EnableSampling: false, - SamplingInitial: 100, - SamplingNext: 100, - Caller: true, - StacktraceLevel: "error", - RetentionDays: 30, + Level: "debug", + PersistAccessLogs: true, + EnableSampling: false, + SamplingInitial: 100, + SamplingNext: 100, + Caller: true, + StacktraceLevel: "error", + RetentionDays: 30, }, 5) if err == nil { t.Fatalf("expected persist error") @@ -326,15 +338,19 @@ func TestUpdateRuntimeLogConfig_PersistFailureRollback(t *testing.T) { if logger.CurrentLevel() != "info" { t.Fatalf("logger level should rollback to info, got %s", logger.CurrentLevel()) } + if svc.systemLogSink.persistAccessLogs.Load() { + t.Fatal("access-log persistence should rollback after setting write failure") + } } func TestApplyRuntimeLogConfigOnStartup(t *testing.T) { repo := newRuntimeSettingRepoStub() - cfgRaw := `{"level":"debug","enable_sampling":false,"sampling_initial":100,"sampling_thereafter":100,"caller":true,"stacktrace_level":"error","retention_days":30}` + cfgRaw := `{"level":"debug","persist_access_logs":true,"enable_sampling":false,"sampling_initial":100,"sampling_thereafter":100,"caller":true,"stacktrace_level":"error","retention_days":30}` repo.values[SettingKeyOpsRuntimeLogConfig] = cfgRaw svc := &OpsService{ - settingRepo: repo, + settingRepo: repo, + systemLogSink: &OpsSystemLogSink{}, cfg: &config.Config{ Log: config.LogConfig{ Level: "info", @@ -366,6 +382,9 @@ func TestApplyRuntimeLogConfigOnStartup(t *testing.T) { if logger.CurrentLevel() != "debug" { t.Fatalf("expected startup apply debug, got %s", logger.CurrentLevel()) } + if !svc.systemLogSink.persistAccessLogs.Load() { + t.Fatal("startup config did not enable access-log persistence") + } } func TestDefaultNormalizeAndValidateRuntimeLogConfig(t *testing.T) { @@ -382,13 +401,17 @@ func TestDefaultNormalizeAndValidateRuntimeLogConfig(t *testing.T) { }, Ops: config.OpsConfig{ Cleanup: config.OpsCleanupConfig{ - ErrorLogRetentionDays: 7, + ErrorLogRetentionDays: 7, + SystemLogRetentionDays: 14, }, }, }) - if defaults.Level != "debug" || defaults.StacktraceLevel != "fatal" || defaults.RetentionDays != 7 { + if defaults.Level != "debug" || defaults.StacktraceLevel != "fatal" || defaults.RetentionDays != 14 { t.Fatalf("unexpected defaults: %+v", defaults) } + if defaults.PersistAccessLogs { + t.Fatal("access-log persistence should be disabled by default") + } cfg := &OpsRuntimeLogConfig{ Level: " ", @@ -403,7 +426,7 @@ func TestDefaultNormalizeAndValidateRuntimeLogConfig(t *testing.T) { if cfg.Level != "debug" || cfg.StacktraceLevel != "fatal" { t.Fatalf("normalize level/stacktrace failed: %+v", cfg) } - if cfg.SamplingInitial != 50 || cfg.SamplingNext != 20 || cfg.RetentionDays != 7 { + if cfg.SamplingInitial != 50 || cfg.SamplingNext != 20 || cfg.RetentionDays != 14 { t.Fatalf("normalize numeric defaults failed: %+v", cfg) } if err := validateOpsRuntimeLogConfig(cfg); err != nil { @@ -489,8 +512,11 @@ func TestUpdateRuntimeLogConfig_PreconditionErrors(t *testing.T) { func TestUpdateRuntimeLogConfig_Success(t *testing.T) { repo := newRuntimeSettingRepoStub() + reloader := &runtimeCleanupReloader{} svc := &OpsService{ - settingRepo: repo, + settingRepo: repo, + systemLogSink: &OpsSystemLogSink{}, + cleanupReloader: reloader, cfg: &config.Config{ Log: config.LogConfig{ Level: "info", @@ -519,13 +545,14 @@ func TestUpdateRuntimeLogConfig_Success(t *testing.T) { } next, err := svc.UpdateRuntimeLogConfig(context.Background(), &OpsRuntimeLogConfig{ - Level: "debug", - EnableSampling: false, - SamplingInitial: 100, - SamplingNext: 100, - Caller: true, - StacktraceLevel: "error", - RetentionDays: 30, + Level: "debug", + PersistAccessLogs: true, + EnableSampling: false, + SamplingInitial: 100, + SamplingNext: 100, + Caller: true, + StacktraceLevel: "error", + RetentionDays: 30, }, 2) if err != nil { t.Fatalf("UpdateRuntimeLogConfig() error: %v", err) @@ -536,6 +563,12 @@ func TestUpdateRuntimeLogConfig_Success(t *testing.T) { if logger.CurrentLevel() != "debug" { t.Fatalf("expected applied level debug, got %s", logger.CurrentLevel()) } + if !svc.systemLogSink.persistAccessLogs.Load() { + t.Fatal("runtime update did not enable access-log persistence") + } + if reloader.calls != 1 { + t.Fatalf("cleanup reload calls = %d, want 1", reloader.calls) + } } func TestResetRuntimeLogConfig_IgnoreNotFoundDelete(t *testing.T) { diff --git a/backend/internal/service/ops_runtime_snapshot_test.go b/backend/internal/service/ops_runtime_snapshot_test.go index 9e0631ecb1..99d8a28a6a 100644 --- a/backend/internal/service/ops_runtime_snapshot_test.go +++ b/backend/internal/service/ops_runtime_snapshot_test.go @@ -114,28 +114,60 @@ func TestOpsRuntimeSettingsAdministrativeUpdatesAreImmediatelyVisible(t *testing } func TestOpsRuntimeSettingsBackgroundRefreshConverges(t *testing.T) { - repo := &opsRuntimeRefreshRepo{values: map[string]string{SettingKeyOpsMonitoringEnabled: "false"}} - svc := &OpsService{settingRepo: repo} + repo := &opsRuntimeRefreshRepo{values: map[string]string{ + SettingKeyOpsMonitoringEnabled: "false", + SettingKeyOpsRuntimeLogConfig: `{"persist_access_logs":false}`, + }} + sink := &OpsSystemLogSink{} + svc := &OpsService{settingRepo: repo, systemLogSink: sink} svc.initRuntimeSettings(context.Background()) if svc.IsMonitoringEnabled(context.Background()) { t.Fatal("initial monitoring state = true, want false") } repo.set(SettingKeyOpsMonitoringEnabled, "true") + repo.set(SettingKeyOpsRuntimeLogConfig, `{"persist_access_logs":true}`) svc.startRuntimeSettingsRefresh(context.Background(), 5*time.Millisecond, 0, 50*time.Millisecond) t.Cleanup(svc.StopRuntimeSettingsRefresh) waitForOpsRefresh(t, time.Second, func() bool { - return svc.IsMonitoringEnabled(context.Background()) && svc.RuntimeSettingsRefreshHealth().SuccessTotal > 0 + return svc.IsMonitoringEnabled(context.Background()) && + sink.persistAccessLogs.Load() && + svc.RuntimeSettingsRefreshHealth().SuccessTotal > 0 }) } +func TestOpsRuntimeSettingsRefreshUsesSafeAccessLogDefault(t *testing.T) { + repo := &opsRuntimeRefreshRepo{values: map[string]string{ + SettingKeyOpsRuntimeLogConfig: `{"persist_access_logs":true}`, + }} + sink := &OpsSystemLogSink{} + svc := &OpsService{settingRepo: repo, systemLogSink: sink} + svc.initRuntimeSettings(context.Background()) + if !sink.persistAccessLogs.Load() { + t.Fatal("valid runtime config did not enable access-log persistence") + } + + repo.set(SettingKeyOpsRuntimeLogConfig, `{invalid-json}`) + if err := svc.RefreshRuntimeSettings(context.Background()); err != nil { + t.Fatalf("RefreshRuntimeSettings() error = %v", err) + } + if sink.persistAccessLogs.Load() { + t.Fatal("invalid runtime config should fail closed for access-log persistence") + } +} + func TestOpsRuntimeSettingsRefreshFailuresKeepLastKnownGoodSnapshot(t *testing.T) { repo := &opsRuntimeRefreshRepo{values: map[string]string{ SettingKeyOpsMonitoringEnabled: "false", SettingKeyOpsAdvancedSettings: `{"ignore_no_available_accounts":true}`, + SettingKeyOpsRuntimeLogConfig: `{"persist_access_logs":true}`, }} - svc := &OpsService{settingRepo: repo} + sink := &OpsSystemLogSink{} + svc := &OpsService{settingRepo: repo, systemLogSink: sink} svc.initRuntimeSettings(context.Background()) + if !sink.persistAccessLogs.Load() { + t.Fatal("initial access-log setting was not applied") + } repo.fail.Store(true) svc.startRuntimeSettingsRefresh(context.Background(), 5*time.Millisecond, 0, 50*time.Millisecond) t.Cleanup(svc.StopRuntimeSettingsRefresh) @@ -149,6 +181,9 @@ func TestOpsRuntimeSettingsRefreshFailuresKeepLastKnownGoodSnapshot(t *testing.T if !svc.OpsAdvancedSettingsSnapshot().IgnoreNoAvailableAccounts { t.Fatal("failed refresh overwrote last known advanced settings") } + if !sink.persistAccessLogs.Load() { + t.Fatal("failed refresh overwrote last known access-log setting") + } } func TestOpsRuntimeSettingsRefreshStopEndsLifecycle(t *testing.T) { diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go index 0e110658b1..81cf3c33fe 100644 --- a/backend/internal/service/ops_service.go +++ b/backend/internal/service/ops_service.go @@ -177,7 +177,7 @@ func (s *OpsService) initRuntimeSettings(ctx context.Context) { if s == nil { return } - defaults := defaultOpsAdvancedSettings() + defaults := defaultOpsAdvancedSettingsForConfig(s.cfg) s.runtimeSettings.Store(&opsRuntimeSettingsSnapshot{monitoringEnabled: true, advanced: *defaults}) _ = s.RefreshRuntimeSettings(ctx) } @@ -198,6 +198,7 @@ func (s *OpsService) RefreshRuntimeSettings(ctx context.Context) error { values, err := s.settingRepo.GetMultiple(ctx, []string{ SettingKeyOpsMonitoringEnabled, SettingKeyOpsAdvancedSettings, + SettingKeyOpsRuntimeLogConfig, }) if err != nil { return err @@ -207,15 +208,27 @@ func (s *OpsService) RefreshRuntimeSettings(ctx context.Context) error { if raw, ok := values[SettingKeyOpsMonitoringEnabled]; ok { monitoringEnabled = parseOpsMonitoringEnabled(raw) } - advanced := defaultOpsAdvancedSettings() + advanced := defaultOpsAdvancedSettingsForConfig(s.cfg) if raw, ok := values[SettingKeyOpsAdvancedSettings]; ok { if err := json.Unmarshal([]byte(raw), advanced); err != nil { - advanced = defaultOpsAdvancedSettings() + advanced = defaultOpsAdvancedSettingsForConfig(s.cfg) } } normalizeOpsAdvancedSettings(advanced) s.runtimeSettings.Store(&opsRuntimeSettingsSnapshot{monitoringEnabled: monitoringEnabled, advanced: *advanced}) + if s.systemLogSink != nil { + persistAccessLogs := false + if raw, ok := values[SettingKeyOpsRuntimeLogConfig]; ok { + var runtimeCfg struct { + PersistAccessLogs bool `json:"persist_access_logs"` + } + if json.Unmarshal([]byte(raw), &runtimeCfg) == nil { + persistAccessLogs = runtimeCfg.PersistAccessLogs + } + } + s.systemLogSink.SetPersistAccessLogs(persistAccessLogs) + } return nil } @@ -360,7 +373,7 @@ func (s *OpsService) SetMonitoringEnabled(enabled bool) { } s.runtimeSettingsMu.Lock() current := s.runtimeSettings.Load() - next := &opsRuntimeSettingsSnapshot{monitoringEnabled: enabled, advanced: *defaultOpsAdvancedSettings()} + next := &opsRuntimeSettingsSnapshot{monitoringEnabled: enabled, advanced: *defaultOpsAdvancedSettingsForConfig(s.cfg)} if current != nil { next.advanced = current.advanced } diff --git a/backend/internal/service/ops_settings.go b/backend/internal/service/ops_settings.go index e2943be5ce..2b18b483c0 100644 --- a/backend/internal/service/ops_settings.go +++ b/backend/internal/service/ops_settings.go @@ -4,9 +4,11 @@ import ( "context" "encoding/json" "errors" - "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "strings" "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" ) const ( @@ -360,7 +362,9 @@ func (s *OpsService) UpdateOpsAlertRuntimeSettings(ctx context.Context, cfg *Ops func defaultOpsAdvancedSettings() *OpsAdvancedSettings { return &OpsAdvancedSettings{ DataRetention: OpsDataRetentionSettings{ - CleanupEnabled: false, + // Keep the DB-backed default aligned with ops.cleanup.enabled so a + // first settings read cannot silently disable configured retention. + CleanupEnabled: true, CleanupSchedule: opsCleanupDefaultSchedule, ErrorLogRetentionDays: 30, MinuteMetricsRetentionDays: 30, @@ -382,6 +386,14 @@ func defaultOpsAdvancedSettings() *OpsAdvancedSettings { } } +func defaultOpsAdvancedSettingsForConfig(cfg *config.Config) *OpsAdvancedSettings { + defaults := defaultOpsAdvancedSettings() + if cfg != nil { + defaults.DataRetention.CleanupEnabled = cfg.Ops.Cleanup.Enabled + } + return defaults +} + func normalizeOpsAdvancedSettings(cfg *OpsAdvancedSettings) { if cfg == nil { return @@ -456,6 +468,7 @@ func (s *OpsService) OpsAdvancedSettingsSnapshot() OpsAdvancedSettings { if snapshot := s.runtimeSettings.Load(); snapshot != nil { return snapshot.advanced } + return *defaultOpsAdvancedSettingsForConfig(s.cfg) } return *defaultOpsAdvancedSettings() } diff --git a/backend/internal/service/ops_settings_advanced_test.go b/backend/internal/service/ops_settings_advanced_test.go index 39d0b8722a..adcaca411c 100644 --- a/backend/internal/service/ops_settings_advanced_test.go +++ b/backend/internal/service/ops_settings_advanced_test.go @@ -23,11 +23,29 @@ func TestGetOpsAdvancedSettings_DefaultSnapshotHidesOpenAITokenStats(t *testing. if !cfg.DisplayAlertEvents { t.Fatalf("DisplayAlertEvents = false, want true by default") } + if !cfg.DataRetention.CleanupEnabled { + t.Fatal("data cleanup should be enabled by default") + } if repo.getValueCalls != 0 || repo.getMultipleCalls != 0 { t.Fatalf("hot-path snapshot read touched repository: get=%d get_multiple=%d", repo.getValueCalls, repo.getMultipleCalls) } } +func TestGetOpsAdvancedSettings_DefaultCleanupFollowsDeploymentConfig(t *testing.T) { + svc := &OpsService{cfg: &config.Config{Ops: config.OpsConfig{ + Cleanup: config.OpsCleanupConfig{Enabled: false}, + }}} + svc.initRuntimeSettings(context.Background()) + + cfg, err := svc.GetOpsAdvancedSettings(context.Background()) + if err != nil { + t.Fatalf("GetOpsAdvancedSettings() error = %v", err) + } + if cfg.DataRetention.CleanupEnabled { + t.Fatal("data cleanup should follow the disabled deployment baseline") + } +} + func TestUpdateOpsAdvancedSettings_PersistsOpenAITokenStatsVisibility(t *testing.T) { repo := newRuntimeSettingRepoStub() svc := &OpsService{settingRepo: repo} diff --git a/backend/internal/service/ops_settings_models.go b/backend/internal/service/ops_settings_models.go index 3057978007..93c364153c 100644 --- a/backend/internal/service/ops_settings_models.go +++ b/backend/internal/service/ops_settings_models.go @@ -69,12 +69,14 @@ type OpsMetricThresholds struct { } type OpsRuntimeLogConfig struct { - Level string `json:"level"` - EnableSampling bool `json:"enable_sampling"` - SamplingInitial int `json:"sampling_initial"` - SamplingNext int `json:"sampling_thereafter"` - Caller bool `json:"caller"` - StacktraceLevel string `json:"stacktrace_level"` + Level string `json:"level"` + PersistAccessLogs bool `json:"persist_access_logs"` + EnableSampling bool `json:"enable_sampling"` + SamplingInitial int `json:"sampling_initial"` + SamplingNext int `json:"sampling_thereafter"` + Caller bool `json:"caller"` + StacktraceLevel string `json:"stacktrace_level"` + // RetentionDays is the authoritative retention for ops_system_logs. RetentionDays int `json:"retention_days"` Source string `json:"source,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` diff --git a/backend/internal/service/ops_system_log_sink.go b/backend/internal/service/ops_system_log_sink.go index 7a247a290d..0bff194816 100644 --- a/backend/internal/service/ops_system_log_sink.go +++ b/backend/internal/service/ops_system_log_sink.go @@ -42,10 +42,11 @@ type OpsSystemLogSink struct { cancel context.CancelFunc wg sync.WaitGroup - droppedCount uint64 - writeFailed uint64 - writtenCount uint64 - totalDelayNs uint64 + droppedCount uint64 + writeFailed uint64 + writtenCount uint64 + totalDelayNs uint64 + persistAccessLogs atomic.Bool lastError atomic.Value } @@ -147,6 +148,15 @@ func (s *OpsSystemLogSink) WriteLogEvent(event *logger.LogEvent) { } } +// SetPersistAccessLogs controls whether high-volume request access logs are +// copied into PostgreSQL. Warning/error and audit events are always retained. +func (s *OpsSystemLogSink) SetPersistAccessLogs(enabled bool) { + if s == nil { + return + } + s.persistAccessLogs.Store(enabled) +} + func (s *OpsSystemLogSink) shouldIndex(event *logger.LogEvent) bool { if event != nil && event.Fields != nil { if skip, _ := event.Fields[logger.OpsSystemLogSkipField].(bool); skip { @@ -167,7 +177,7 @@ func (s *OpsSystemLogSink) shouldIndex(event *logger.LogEvent) bool { } } if strings.Contains(component, "http.access") { - return true + return s.persistAccessLogs.Load() } if strings.Contains(component, "audit") { return true diff --git a/backend/internal/service/ops_system_log_sink_test.go b/backend/internal/service/ops_system_log_sink_test.go index fbe61d3867..2362cb505d 100644 --- a/backend/internal/service/ops_system_log_sink_test.go +++ b/backend/internal/service/ops_system_log_sink_test.go @@ -32,9 +32,9 @@ func TestOpsSystemLogSink_ShouldIndex(t *testing.T) { want: true, }, { - name: "access component", + name: "access component disabled by default", event: &logger.LogEvent{Level: "info", Component: "http.access"}, - want: true, + want: false, }, { name: "rejected access excluded from database sink", @@ -46,13 +46,13 @@ func TestOpsSystemLogSink_ShouldIndex(t *testing.T) { want: false, }, { - name: "access component from fields (real zap path)", + name: "access component from fields disabled by default", event: &logger.LogEvent{ Level: "info", Component: "", Fields: map[string]any{"component": "http.access"}, }, - want: true, + want: false, }, { name: "audit component", @@ -82,6 +82,28 @@ func TestOpsSystemLogSink_ShouldIndex(t *testing.T) { } } +func TestOpsSystemLogSink_ShouldIndexAccessLogsOnlyWhenEnabled(t *testing.T) { + sink := &OpsSystemLogSink{} + sink.SetPersistAccessLogs(true) + + if !sink.shouldIndex(&logger.LogEvent{Level: "info", Component: "http.access"}) { + t.Fatal("access log should be indexed after explicit opt-in") + } + if !sink.shouldIndex(&logger.LogEvent{ + Level: "info", + Fields: map[string]any{"component": "http.access"}, + }) { + t.Fatal("field-based access log should be indexed after explicit opt-in") + } + if sink.shouldIndex(&logger.LogEvent{ + Level: "info", + Component: "http.access", + Fields: map[string]any{logger.OpsSystemLogSkipField: true}, + }) { + t.Fatal("explicit skip marker must override access-log persistence") + } +} + func TestOpsSystemLogSink_WriteLogEvent_ShouldDropWhenQueueFull(t *testing.T) { sink := &OpsSystemLogSink{ queue: make(chan *logger.LogEvent, 1), diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 4d442f3d1e..2b70a90ef6 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -317,20 +317,62 @@ const ( // resolveRedeemAction decides the idempotency action based on an existing redeem code lookup. // existing is the result of GetByCode; lookupErr is the error from that call. -func resolveRedeemAction(existing *RedeemCode, lookupErr error) redeemAction { - if existing == nil || lookupErr != nil { - return redeemActionCreate +func resolveRedeemAction(existing *RedeemCode, lookupErr error) (redeemAction, error) { + if lookupErr != nil { + if errors.Is(lookupErr, ErrRedeemCodeNotFound) { + return redeemActionCreate, nil + } + return redeemActionCreate, fmt.Errorf("lookup payment redeem code: %w", lookupErr) + } + if existing == nil { + return redeemActionCreate, nil } if existing.IsUsed() { - return redeemActionSkipCompleted + return redeemActionSkipCompleted, nil } - return redeemActionRedeem + return redeemActionRedeem, nil +} + +func validatePaymentRedeemCode(o *dbent.PaymentOrder, code *RedeemCode) error { + if o == nil || code == nil { + return errors.New("payment redeem code validation requires an order and code") + } + if code.Code != o.RechargeCode { + return fmt.Errorf("payment redeem code mismatch for order %d", o.ID) + } + if code.Type != RedeemTypeBalance { + return fmt.Errorf("payment redeem code type mismatch for order %d: got %s", o.ID, code.Type) + } + if math.IsNaN(code.Value) || math.IsInf(code.Value, 0) || math.Abs(code.Value-o.Amount) > 1e-8 { + return fmt.Errorf("payment redeem code amount mismatch for order %d: expected %.8f, got %.8f", o.ID, o.Amount, code.Value) + } + switch code.Status { + case StatusUnused: + if code.UsedBy != nil { + return fmt.Errorf("unused payment redeem code has a user for order %d", o.ID) + } + case StatusUsed: + if code.UsedBy == nil || *code.UsedBy != o.UserID { + return fmt.Errorf("payment redeem code user mismatch for order %d", o.ID) + } + default: + return fmt.Errorf("payment redeem code has invalid status for order %d: %s", o.ID, code.Status) + } + return nil } func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder, lease *paymentFulfillmentLease) error { // Idempotency: check if redeem code already exists (from a previous partial run) existing, lookupErr := s.redeemService.GetByCode(ctx, o.RechargeCode) - action := resolveRedeemAction(existing, lookupErr) + action, err := resolveRedeemAction(existing, lookupErr) + if err != nil { + return err + } + if existing != nil { + if err := validatePaymentRedeemCode(o, existing); err != nil { + return err + } + } switch action { case redeemActionSkipCompleted: @@ -347,7 +389,7 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder, l case redeemActionRedeem: // Code exists but unused — skip creation, proceed to redeem } - if _, err := s.redeemService.Redeem(ContextSkipRedeemAffiliate(ctx), o.UserID, o.RechargeCode); err != nil { + if _, err := s.redeemService.redeemForPaymentFulfillment(ctx, o.UserID, o.RechargeCode); err != nil { return fmt.Errorf("redeem balance: %w", err) } if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil { diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index 50ccd485ed..b1366c3c96 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -23,6 +23,52 @@ type paymentFulfillmentTestProvider struct { supportedTypes []payment.PaymentType } +type paymentFulfillmentRedeemCacheStub struct { + count int + getCalls int + incrementCalls int + acquireCalls int + releaseCalls int +} + +type paymentFulfillmentRedeemRepo struct { + paymentOrderLifecycleRedeemRepo + createCalls int +} + +func (r *paymentFulfillmentRedeemRepo) Create(_ context.Context, code *RedeemCode) error { + r.createCalls++ + if r.codesByCode == nil { + r.codesByCode = make(map[string]*RedeemCode) + } + cloned := *code + cloned.ID = int64(100 + r.createCalls) + code.ID = cloned.ID + r.codesByCode[cloned.Code] = &cloned + return nil +} + +func (c *paymentFulfillmentRedeemCacheStub) GetRedeemAttemptCount(context.Context, int64) (int, error) { + c.getCalls++ + return c.count, nil +} + +func (c *paymentFulfillmentRedeemCacheStub) IncrementRedeemAttemptCount(context.Context, int64) error { + c.incrementCalls++ + c.count++ + return nil +} + +func (c *paymentFulfillmentRedeemCacheStub) AcquireRedeemLock(context.Context, string, time.Duration) (bool, error) { + c.acquireCalls++ + return true, nil +} + +func (c *paymentFulfillmentRedeemCacheStub) ReleaseRedeemLock(context.Context, string) error { + c.releaseCalls++ + return nil +} + func (p paymentFulfillmentTestProvider) Name() string { return p.key } func (p paymentFulfillmentTestProvider) ProviderKey() string { return p.key } func (p paymentFulfillmentTestProvider) SupportedTypes() []payment.PaymentType { @@ -210,14 +256,15 @@ func ensurePaymentAuditOrderActionUniqueIndex(t *testing.T, ctx context.Context, func TestResolveRedeemAction_CodeNotFound(t *testing.T) { t.Parallel() - action := resolveRedeemAction(nil, nil) - assert.Equal(t, redeemActionCreate, action, "nil code with nil error should create") + action, err := resolveRedeemAction(nil, ErrRedeemCodeNotFound) + require.NoError(t, err) + assert.Equal(t, redeemActionCreate, action, "a missing code should be created") } func TestResolveRedeemAction_LookupError(t *testing.T) { t.Parallel() - action := resolveRedeemAction(nil, errors.New("db connection lost")) - assert.Equal(t, redeemActionCreate, action, "lookup error should fall back to create") + _, err := resolveRedeemAction(nil, errors.New("db connection lost")) + require.ErrorContains(t, err, "lookup payment redeem code") } func TestResolveRedeemAction_LookupErrorWithNonNilCode(t *testing.T) { @@ -225,8 +272,8 @@ func TestResolveRedeemAction_LookupErrorWithNonNilCode(t *testing.T) { // Edge case: both code and error are non-nil (shouldn't happen in practice, // but the function should still treat error as authoritative) code := &RedeemCode{Status: StatusUnused} - action := resolveRedeemAction(code, errors.New("partial error")) - assert.Equal(t, redeemActionCreate, action, "non-nil error should always result in create regardless of code") + _, err := resolveRedeemAction(code, errors.New("partial error")) + require.ErrorContains(t, err, "lookup payment redeem code") } func TestResolveRedeemAction_CodeExistsAndUsed(t *testing.T) { @@ -237,7 +284,8 @@ func TestResolveRedeemAction_CodeExistsAndUsed(t *testing.T) { Type: RedeemTypeBalance, Value: 10.0, } - action := resolveRedeemAction(code, nil) + action, err := resolveRedeemAction(code, nil) + require.NoError(t, err) assert.Equal(t, redeemActionSkipCompleted, action, "used code should skip to completed") } @@ -249,7 +297,8 @@ func TestResolveRedeemAction_CodeExistsAndUnused(t *testing.T) { Type: RedeemTypeBalance, Value: 25.0, } - action := resolveRedeemAction(code, nil) + action, err := resolveRedeemAction(code, nil) + require.NoError(t, err) assert.Equal(t, redeemActionRedeem, action, "unused code should skip creation and proceed to redeem") } @@ -261,7 +310,8 @@ func TestResolveRedeemAction_CodeExistsWithExpiredStatus(t *testing.T) { Code: "expired-code", Status: StatusExpired, } - action := resolveRedeemAction(code, nil) + action, err := resolveRedeemAction(code, nil) + require.NoError(t, err) assert.Equal(t, redeemActionRedeem, action, "expired-status code is not IsUsed(), should redeem") } @@ -277,6 +327,7 @@ func TestResolveRedeemAction_Table(t *testing.T) { code *RedeemCode err error expected redeemAction + wantErr bool }{ { name: "nil code, nil error — first run", @@ -291,10 +342,11 @@ func TestResolveRedeemAction_Table(t *testing.T) { expected: redeemActionCreate, }, { - name: "nil code, generic DB error — treat as not found", + name: "nil code, generic DB error — fail closed", code: nil, err: errors.New("connection refused"), expected: redeemActionCreate, + wantErr: true, }, { name: "code exists, used — previous run completed redeem", @@ -309,17 +361,23 @@ func TestResolveRedeemAction_Table(t *testing.T) { expected: redeemActionRedeem, }, { - name: "code exists but error also set — error takes precedence", + name: "code exists but error also set — fail closed", code: &RedeemCode{Status: StatusUsed}, err: errors.New("unexpected"), expected: redeemActionCreate, + wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := resolveRedeemAction(tt.code, tt.err) + got, err := resolveRedeemAction(tt.code, tt.err) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) assert.Equal(t, tt.expected, got) }) } @@ -350,11 +408,48 @@ func TestResolveRedeemAction_IsUsedCanUseConsistency(t *testing.T) { // Verify our decision function is consistent with the domain model methods assert.True(t, usedCode.IsUsed()) assert.False(t, usedCode.CanUse()) - assert.Equal(t, redeemActionSkipCompleted, resolveRedeemAction(usedCode, nil)) + usedAction, err := resolveRedeemAction(usedCode, nil) + require.NoError(t, err) + assert.Equal(t, redeemActionSkipCompleted, usedAction) assert.False(t, unusedCode.IsUsed()) assert.True(t, unusedCode.CanUse()) - assert.Equal(t, redeemActionRedeem, resolveRedeemAction(unusedCode, nil)) + unusedAction, err := resolveRedeemAction(unusedCode, nil) + require.NoError(t, err) + assert.Equal(t, redeemActionRedeem, unusedAction) +} + +func TestValidatePaymentRedeemCode(t *testing.T) { + t.Parallel() + userID := int64(42) + otherUserID := int64(43) + order := &dbent.PaymentOrder{ID: 7, UserID: userID, RechargeCode: "PAY-7-12345", Amount: 80} + + tests := []struct { + name string + code *RedeemCode + wantErr string + }{ + {name: "unused code", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUnused}}, + {name: "used by order user", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUsed, UsedBy: &userID}}, + {name: "used without user", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUsed}, wantErr: "user mismatch"}, + {name: "used by another user", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusUsed, UsedBy: &otherUserID}, wantErr: "user mismatch"}, + {name: "wrong code", code: &RedeemCode{Code: "OTHER", Type: RedeemTypeBalance, Value: 80, Status: StatusUnused}, wantErr: "code mismatch"}, + {name: "wrong type", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeConcurrency, Value: 80, Status: StatusUnused}, wantErr: "type mismatch"}, + {name: "wrong amount", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 79, Status: StatusUnused}, wantErr: "amount mismatch"}, + {name: "invalid status", code: &RedeemCode{Code: order.RechargeCode, Type: RedeemTypeBalance, Value: 80, Status: StatusDisabled}, wantErr: "invalid status"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validatePaymentRedeemCode(order, tt.code) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + require.ErrorContains(t, err, tt.wantErr) + }) + } } func TestExpectedNotificationProviderKeyPrefersOrderInstanceProvider(t *testing.T) { @@ -668,6 +763,100 @@ func TestFulfillmentLeaseVersionRejectsStaleWorker(t *testing.T) { require.NoError(t, svc.markCompleted(ctx, order, secondLease, "SUBSCRIPTION_SUCCESS")) } +func TestPublicRedeemStillEnforcesFailureLimit(t *testing.T) { + cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxFailedAttempts} + svc := &RedeemService{cache: cache} + + result, err := svc.Redeem(context.Background(), 42, "PUBLIC-CODE") + + require.Nil(t, result) + require.ErrorIs(t, err, ErrRedeemRateLimited) + require.Equal(t, 1, cache.getCalls) + require.Zero(t, cache.incrementCalls) + require.Zero(t, cache.acquireCalls) +} + +func TestPublicRedeemStillIncrementsInvalidCodeFailures(t *testing.T) { + cache := &paymentFulfillmentRedeemCacheStub{} + redeemRepo := &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}} + svc := &RedeemService{redeemRepo: redeemRepo, cache: cache} + + result, err := svc.Redeem(context.Background(), 42, "MISSING") + + require.Nil(t, result) + require.ErrorIs(t, err, ErrRedeemCodeNotFound) + require.Equal(t, 1, cache.getCalls) + require.Equal(t, 1, cache.incrementCalls) + require.Equal(t, 1, cache.count) + require.Equal(t, 1, cache.acquireCalls) + require.Equal(t, 1, cache.releaseCalls) +} + +func TestPaymentRedeemDoesNotIncrementFailureLimit(t *testing.T) { + ctx := context.Background() + cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxFailedAttempts} + redeemRepo := &redeemRejectRepo{code: RedeemCode{ + ID: 1, + Code: "PAY-EXPIRED", + Type: RedeemTypeBalance, + Value: 10, + Status: StatusExpired, + }} + svc := &RedeemService{redeemRepo: redeemRepo, cache: cache} + + result, err := svc.redeemForPaymentFulfillment(ctx, 42, redeemRepo.code.Code) + + require.Nil(t, result) + require.ErrorIs(t, err, ErrRedeemCodeExpired) + require.Zero(t, cache.getCalls) + require.Zero(t, cache.incrementCalls) + require.Equal(t, 1, cache.acquireCalls) + require.Equal(t, 1, cache.releaseCalls) +} + +func TestExecuteBalanceFulfillmentBypassesUserRedeemRateLimit(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client) + order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusPaid, time.Now()) + order, err := client.PaymentOrder.UpdateOneID(order.ID). + SetOrderType(payment.OrderTypeBalance). + ClearPlanID(). + ClearSubscriptionGroupID(). + ClearSubscriptionDays(). + Save(ctx) + require.NoError(t, err) + + redeemRepo := &paymentFulfillmentRedeemRepo{} + credited := 0.0 + userRepo := &mockUserRepo{getByIDUser: &User{ID: order.UserID, Balance: 0}} + userRepo.updateBalanceFn = func(_ context.Context, id int64, amount float64) error { + require.Equal(t, order.UserID, id) + credited += amount + return nil + } + cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxFailedAttempts} + redeemService := NewRedeemService(redeemRepo, userRepo, nil, cache, nil, client, nil, nil) + svc := &PaymentService{entClient: client, redeemService: redeemService, userRepo: userRepo} + + require.NoError(t, svc.ExecuteBalanceFulfillment(ctx, order.ID)) + require.InDelta(t, order.Amount, credited, 1e-8) + require.Zero(t, cache.getCalls, "trusted payment fulfillment must not read the public failure counter") + require.Zero(t, cache.incrementCalls, "trusted payment fulfillment must not mutate the public failure counter") + require.Equal(t, 1, cache.acquireCalls) + require.Equal(t, 1, cache.releaseCalls) + require.Equal(t, 1, redeemRepo.createCalls) + require.Len(t, redeemRepo.useCalls, 1) + + usedCode := redeemRepo.codesByCode[order.RechargeCode] + require.Equal(t, StatusUsed, usedCode.Status) + require.NotNil(t, usedCode.UsedBy) + require.Equal(t, order.UserID, *usedCode.UsedBy) + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusCompleted, reloaded.Status) +} + func TestExecuteBalanceFulfillmentRecoversAfterRedeemWithoutCreditingAgain(t *testing.T) { ctx := context.Background() client := newPaymentConfigServiceTestClient(t) @@ -683,6 +872,7 @@ func TestExecuteBalanceFulfillmentRecoversAfterRedeemWithoutCreditingAgain(t *te Save(ctx) require.NoError(t, err) + usedBy := order.UserID redeemRepo := &redeemCodeRepoStub{codesByCode: map[string]*RedeemCode{ order.RechargeCode: { ID: 101, @@ -690,6 +880,7 @@ func TestExecuteBalanceFulfillmentRecoversAfterRedeemWithoutCreditingAgain(t *te Type: RedeemTypeBalance, Value: order.Amount, Status: StatusUsed, + UsedBy: &usedBy, }, }} svc := &PaymentService{ @@ -770,6 +961,44 @@ func TestPaymentNotificationRejectsAmountMismatchBeforeFulfillment(t *testing.T) require.Equal(t, OrderStatusPending, reloaded.Status) } +func TestExecuteBalanceFulfillmentRejectsCodeUsedByAnotherUser(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + ensurePaymentAuditOrderActionUniqueIndex(t, ctx, client) + order := createPaymentFulfillmentSubscriptionOrder(t, ctx, client, OrderStatusPaid, time.Now()) + order, err := client.PaymentOrder.UpdateOneID(order.ID). + SetOrderType(payment.OrderTypeBalance). + ClearPlanID(). + ClearSubscriptionGroupID(). + ClearSubscriptionDays(). + Save(ctx) + require.NoError(t, err) + + otherUserID := order.UserID + 1 + redeemRepo := &redeemCodeRepoStub{codesByCode: map[string]*RedeemCode{ + order.RechargeCode: { + ID: 101, + Code: order.RechargeCode, + Type: RedeemTypeBalance, + Value: order.Amount, + Status: StatusUsed, + UsedBy: &otherUserID, + }, + }} + svc := &PaymentService{ + entClient: client, + redeemService: &RedeemService{redeemRepo: redeemRepo}, + } + + err = svc.ExecuteBalanceFulfillment(ctx, order.ID) + require.ErrorContains(t, err, "payment redeem code user mismatch") + require.Empty(t, redeemRepo.useCalls) + reloaded, err := client.PaymentOrder.Get(ctx, order.ID) + require.NoError(t, err) + require.Equal(t, OrderStatusFailed, reloaded.Status) + require.Nil(t, reloaded.CompletedAt) +} + func TestExecuteSubscriptionFulfillmentRecoversCommittedAssignmentWithoutExtendingAgain(t *testing.T) { ctx := context.Background() client := newPaymentConfigServiceTestClient(t) diff --git a/backend/internal/service/payment_order_expiry_service.go b/backend/internal/service/payment_order_expiry_service.go index b292ef89ae..ed66e200e6 100644 --- a/backend/internal/service/payment_order_expiry_service.go +++ b/backend/internal/service/payment_order_expiry_service.go @@ -98,12 +98,12 @@ func (s *PaymentOrderExpiryService) runOnce() { defer release() reconcileCtx, cancel := context.WithTimeout(context.Background(), expiryCheckTimeout) - recovered, err := s.paymentSvc.ReconcilePendingWxpayOrders(reconcileCtx) + recovered, err := s.paymentSvc.ReconcilePendingPaymentOrders(reconcileCtx) cancel() if err != nil { - slog.Warn("[PaymentOrderExpiry] failed to reconcile pending wxpay orders", "error", err) + slog.Warn("[PaymentOrderExpiry] failed to reconcile pending payment orders", "error", err) } else if recovered > 0 { - slog.Info("[PaymentOrderExpiry] reconciled paid wxpay orders", "count", recovered) + slog.Info("[PaymentOrderExpiry] reconciled paid orders", "count", recovered) } expireCtx, cancel := context.WithTimeout(context.Background(), expiryCheckTimeout) diff --git a/backend/internal/service/payment_order_lifecycle.go b/backend/internal/service/payment_order_lifecycle.go index 46a2e00605..d534bf7b33 100644 --- a/backend/internal/service/payment_order_lifecycle.go +++ b/backend/internal/service/payment_order_lifecycle.go @@ -27,7 +27,7 @@ const ( checkPaidResultAlreadyPaid = "already_paid" checkPaidResultCancelled = "cancelled" - pendingWxpayReconcileLimit = 20 + pendingPaymentReconcileLimit = 20 ) type checkPaidOptions struct { @@ -303,9 +303,9 @@ func (s *PaymentService) VerifyOrderByOutTradeNo(ctx context.Context, outTradeNo return o, nil } -// ReconcilePendingWxpayOrders actively checks recent pending WeChat orders so -// missed provider notifications do not wait until order expiry to fulfill. -func (s *PaymentService) ReconcilePendingWxpayOrders(ctx context.Context) (int, error) { +// ReconcilePendingPaymentOrders actively checks recent pending Alipay and WeChat +// orders so missed provider notifications do not wait until order expiry to fulfill. +func (s *PaymentService) ReconcilePendingPaymentOrders(ctx context.Context) (int, error) { now := time.Now() orders, err := s.entClient.PaymentOrder.Query(). Where( @@ -316,13 +316,17 @@ func (s *PaymentService) ReconcilePendingWxpayOrders(ctx context.Context) (int, paymentorder.PaymentTypeHasPrefix(payment.TypeWxpay+"_"), paymentorder.ProviderKeyEQ(payment.TypeWxpay), paymentorder.ProviderKeyHasPrefix(payment.TypeWxpay+"_"), + paymentorder.PaymentTypeEQ(payment.TypeAlipay), + paymentorder.PaymentTypeHasPrefix(payment.TypeAlipay+"_"), + paymentorder.ProviderKeyEQ(payment.TypeAlipay), + paymentorder.ProviderKeyHasPrefix(payment.TypeAlipay+"_"), ), ). Order(dbent.Asc(paymentorder.FieldCreatedAt)). - Limit(pendingWxpayReconcileLimit). + Limit(pendingPaymentReconcileLimit). All(ctx) if err != nil { - return 0, fmt.Errorf("query pending wxpay orders: %w", err) + return 0, fmt.Errorf("query pending payment orders: %w", err) } recovered := 0 diff --git a/backend/internal/service/payment_order_lifecycle_test.go b/backend/internal/service/payment_order_lifecycle_test.go index 658a1806c6..c5a5f2fb9c 100644 --- a/backend/internal/service/payment_order_lifecycle_test.go +++ b/backend/internal/service/payment_order_lifecycle_test.go @@ -567,7 +567,7 @@ func TestCancelOrderStillClosesUnpaidUpstreamOrder(t *testing.T) { require.Equal(t, OrderStatusCancelled, reloaded.Status) } -func TestReconcilePendingWxpayOrdersBackfillsPaidOrder(t *testing.T) { +func TestReconcilePendingPaymentOrdersBackfillsPaidOrder(t *testing.T) { ctx := context.Background() client := newPaymentOrderLifecycleTestClient(t) @@ -655,7 +655,7 @@ func TestReconcilePendingWxpayOrdersBackfillsPaidOrder(t *testing.T) { providersLoaded: true, } - recovered, err := svc.ReconcilePendingWxpayOrders(ctx) + recovered, err := svc.ReconcilePendingPaymentOrders(ctx) require.NoError(t, err) require.Equal(t, 1, recovered) require.Equal(t, order.OutTradeNo, provider.lastQueryTradeNo) @@ -669,6 +669,59 @@ func TestReconcilePendingWxpayOrdersBackfillsPaidOrder(t *testing.T) { require.Len(t, redeemRepo.useCalls, 1) } +func TestReconcilePendingPaymentOrdersQueriesAlipayOrder(t *testing.T) { + ctx := context.Background() + client := newPaymentOrderLifecycleTestClient(t) + + user, err := client.User.Create(). + SetEmail("alipay-reconcile@example.com"). + SetPasswordHash("hash"). + SetUsername("alipay-reconcile-user"). + Save(ctx) + require.NoError(t, err) + + order, err := client.PaymentOrder.Create(). + SetUserID(user.ID). + SetUserEmail(user.Email). + SetUserName(user.Username). + SetAmount(50). + SetPayAmount(50). + SetFeeRate(0). + SetRechargeCode("ALIPAY-RECONCILE"). + SetOutTradeNo("sub2_alipay_reconcile"). + SetPaymentType(payment.TypeAlipay). + SetPaymentTradeNo(""). + SetOrderType(payment.OrderTypeBalance). + SetStatus(OrderStatusPending). + SetExpiresAt(time.Now().Add(time.Hour)). + SetClientIP("127.0.0.1"). + SetSrcHost("api.example.com"). + Save(ctx) + require.NoError(t, err) + + registry := payment.NewRegistry() + provider := &paymentOrderLifecycleQueryProvider{ + key: payment.TypeAlipay, + resp: &payment.QueryOrderResponse{ + TradeNo: order.OutTradeNo, + Status: payment.ProviderStatusPending, + }, + } + registry.Register(provider) + + svc := &PaymentService{ + entClient: client, + registry: registry, + providersLoaded: true, + } + + recovered, err := svc.ReconcilePendingPaymentOrders(ctx) + require.NoError(t, err) + require.Zero(t, recovered) + require.Equal(t, 1, provider.queryCalls) + require.Equal(t, order.OutTradeNo, provider.lastQueryTradeNo) +} + func TestVerifyOrderByOutTradeNoUsesOutTradeNoWhenPaymentTradeNoAlreadyExistsForAlipay(t *testing.T) { ctx := context.Background() client := newPaymentOrderLifecycleTestClient(t) diff --git a/backend/internal/service/plugin_package.go b/backend/internal/service/plugin_package.go index 2ef9130b0c..a6aceaf957 100644 --- a/backend/internal/service/plugin_package.go +++ b/backend/internal/service/plugin_package.go @@ -107,7 +107,14 @@ func (i *PluginPackageInstaller) Install(ctx context.Context, reader io.Reader, if err != nil { return nil, fmt.Errorf("插件包不是有效的 ZIP: %w", err) } - defer func() { _ = archive.Close() }() + archiveClosed := false + closeArchive := func() { + if !archiveClosed { + archiveClosed = true + _ = archive.Close() + } + } + defer closeArchive() manifest, _, signatureStatus, err := i.inspectArchive(&archive.Reader) if err != nil { return nil, err @@ -137,6 +144,8 @@ func (i *PluginPackageInstaller) Install(ctx context.Context, reader io.Reader, if err := i.extractArchive(ctx, &archive.Reader, manifest, extractPath); err != nil { return nil, err } + // Windows 不允许重命名仍被打开的文件,提交前先释放 ZIP 读取器。 + closeArchive() if err := os.Rename(extractPath, installPath); err != nil { return nil, fmt.Errorf("提交插件安装目录: %w", err) } diff --git a/backend/internal/service/plugin_package_test.go b/backend/internal/service/plugin_package_test.go index cd0e42de1a..194184dbc5 100644 --- a/backend/internal/service/plugin_package_test.go +++ b/backend/internal/service/plugin_package_test.go @@ -12,6 +12,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "testing" "github.com/Wei-Shaw/sub2api/internal/config" @@ -33,7 +34,9 @@ func TestPluginPackageInstallerInstallUnsignedDevelopmentPackage(t *testing.T) { assert.FileExists(t, installation.ArtifactPath) info, statErr := os.Stat(installation.BinaryPath) require.NoError(t, statErr) - assert.NotZero(t, info.Mode()&0o100) + if runtime.GOOS != "windows" { + assert.NotZero(t, info.Mode()&0o100) + } assert.Contains(t, installation.InstallPath, filepath.Join("installed", "com.example.openai-transport")) } diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index e0b344ac60..75b2fc6a9c 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/sha256" + "encoding/binary" "encoding/hex" "encoding/json" "fmt" @@ -44,6 +45,23 @@ var ( Mode: "chat", SupportsPromptCaching: true, } + openAIGPT6AstraFallbackPricing = &LiteLLMModelPricing{ + InputCostPerToken: 1e-05, + InputCostPerTokenPriority: 2e-05, + OutputCostPerToken: 5e-05, + OutputCostPerTokenPriority: 1e-04, + CacheCreationInputTokenCost: 1.25e-05, + CacheCreationInputTokenCostPriority: 2.5e-05, + CacheReadInputTokenCost: 1e-06, + CacheReadInputTokenCostPriority: 2e-06, + LongContextInputTokenThreshold: 272_000, + LongContextInputCostMultiplier: 2, + LongContextOutputCostMultiplier: 1.5, + SupportsServiceTier: true, + LiteLLMProvider: "openai", + Mode: "chat", + SupportsPromptCaching: true, + } openAIGPT56SolFallbackPricing = &LiteLLMModelPricing{ InputCostPerToken: 5e-06, InputCostPerTokenPriority: 1e-05, @@ -170,6 +188,9 @@ type PricingService struct { pricingData map[string]*LiteLLMModelPricing lastUpdated time.Time localHash string + // fallback/override 文件在最近一次成功重建时的内容指纹,定时器据此判断是否 + // 需要从本地目录缓存重建叠加层。 + customFilesHash string // 停止信号 stopCh chan struct{} @@ -216,14 +237,21 @@ func (s *PricingService) Stop() { logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Service stopped") } -// startUpdateScheduler 启动定时更新调度器 +// startUpdateScheduler 启动定时调度器:每个周期先做远程目录哈希同步(配置了 remote_url 时), +// 再比对 fallback/override 文件指纹做本地热重载(配置了任一文件时)。两者都未配置则不启动。 func (s *PricingService) startUpdateScheduler() { - if s == nil || s.cfg == nil || strings.TrimSpace(s.cfg.Pricing.RemoteURL) == "" { + if s == nil || s.cfg == nil { + return + } + remoteEnabled := strings.TrimSpace(s.cfg.Pricing.RemoteURL) != "" + watchCustom := s.hasCustomPricingFiles() + if !remoteEnabled { logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Remote sync disabled: pricing remote URL is empty") + } + if !remoteEnabled && !watchCustom { return } - // 定期检查哈希更新 hashInterval := time.Duration(s.cfg.Pricing.HashCheckIntervalMinutes) * time.Minute if hashInterval < time.Minute { hashInterval = 10 * time.Minute @@ -238,8 +266,13 @@ func (s *PricingService) startUpdateScheduler() { for { select { case <-ticker.C: - if err := s.syncWithRemote(); err != nil { - logger.LegacyPrintf("service.pricing", "[Pricing] Sync failed: %v", err) + if remoteEnabled { + if err := s.syncWithRemote(); err != nil { + logger.LegacyPrintf("service.pricing", "[Pricing] Sync failed: %v", err) + } + } + if watchCustom { + s.reloadIfCustomFilesChanged() } case <-s.stopCh: return @@ -247,7 +280,7 @@ func (s *PricingService) startUpdateScheduler() { } }() - logger.LegacyPrintf("service.pricing", "[Pricing] Update scheduler started (check every %v)", hashInterval) + logger.LegacyPrintf("service.pricing", "[Pricing] Update scheduler started (check every %v, remote sync=%t, custom file watch=%t)", hashInterval, remoteEnabled, watchCustom) } // checkAndUpdatePricing 检查并更新价格数据 @@ -348,6 +381,118 @@ func (s *PricingService) syncWithRemote() error { return nil } +// hasCustomPricingFiles 报告是否配置了 fallback/override 任一文件路径(不要求文件存在)。 +func (s *PricingService) hasCustomPricingFiles() bool { + if s == nil || s.cfg == nil { + return false + } + return strings.TrimSpace(s.cfg.Pricing.FallbackFile) != "" || strings.TrimSpace(s.cfg.Pricing.OverrideFile) != "" +} + +// customPricingFilesFingerprint 返回 fallback、override 两个文件当前内容的联合 sha256。 +// 每个文件以"长度前缀 + 正文"参与计算,不可读的文件按空正文处理;未配置任何文件返回空串。 +func (s *PricingService) customPricingFilesFingerprint() string { + if !s.hasCustomPricingFiles() { + return "" + } + h := sha256.New() + for _, path := range []string{s.cfg.Pricing.FallbackFile, s.cfg.Pricing.OverrideFile} { + var body []byte + if p := strings.TrimSpace(path); p != "" { + body, _ = os.ReadFile(p) + } + var size [8]byte + binary.BigEndian.PutUint64(size[:], uint64(len(body))) + _, _ = h.Write(size[:]) + _, _ = h.Write(body) + } + return hex.EncodeToString(h.Sum(nil)) +} + +// validateCustomPricingFiles 要求每个已配置且存在的 fallback/override 文件可读且为 JSON +// 对象,任一不满足即返回带路径的错误;文件不存在视为该层为空,属合法状态。 +func (s *PricingService) validateCustomPricingFiles() error { + for _, path := range []string{s.cfg.Pricing.FallbackFile, s.cfg.Pricing.OverrideFile} { + p := strings.TrimSpace(path) + if p == "" { + continue + } + body, err := os.ReadFile(p) + if os.IsNotExist(err) { + continue + } + if err != nil { + return err + } + var entries map[string]json.RawMessage + if err := json.Unmarshal(body, &entries); err != nil { + return fmt.Errorf("%s: %w", p, err) + } + } + return nil +} + +// reloadIfCustomFilesChanged 比对 fallback/override 文件指纹,与最近一次重建时不同则从 +// 本地目录缓存重建内存数据。文件被删除视为该层清空,照常重建;文件存在但不可读或不是 +// JSON 对象时保留当前数据且不更新指纹,下一轮会再次尝试并重复告警。目录正文与远程同步 +// 锚点(localHash)不受本路径影响。 +func (s *PricingService) reloadIfCustomFilesChanged() { + fingerprint := s.customPricingFilesFingerprint() + s.mu.RLock() + unchanged := fingerprint == s.customFilesHash + s.mu.RUnlock() + if unchanged { + return + } + if err := s.reloadCustomPricingLayers(); err != nil { + logger.LegacyPrintf("service.pricing", "[Pricing] Custom pricing file changed but reload failed: %v", err) + } +} + +// reloadCustomPricingLayers 读取本地目录缓存并重新叠加 fallback/override,只替换内存数据 +// 与叠加层指纹。 +func (s *PricingService) reloadCustomPricingLayers() error { + pricingFile := s.getPricingFilePath() + // 定价层文件可能在读取期间被替换。只有构建前后指纹一致时才提交, + // 否则丢弃这次混合快照并重试,避免短暂应用不匹配的 fallback/override。 + var data map[string]*LiteLLMModelPricing + var fingerprint string + var err error + for attempt := 0; attempt < 3; attempt++ { + if validateErr := s.validateCustomPricingFiles(); validateErr != nil { + return fmt.Errorf("validate custom pricing files: %w", validateErr) + } + before := s.customPricingFilesFingerprint() + body, readErr := os.ReadFile(pricingFile) + if readErr != nil { + return fmt.Errorf("read file failed: %w", readErr) + } + data, fingerprint, err = s.buildPricingData(body) + if err != nil { + return fmt.Errorf("parse pricing data: %w", err) + } + after := s.customPricingFilesFingerprint() + if validateErr := s.validateCustomPricingFiles(); validateErr != nil { + return fmt.Errorf("validate custom pricing files: %w", validateErr) + } + if before == after && after == fingerprint { + break + } + if attempt == 2 { + return fmt.Errorf("custom pricing files changed during reload") + } + } + + s.mu.Lock() + warnDroppedLongContextLadders(s.pricingData, data) + s.pricingData = data + s.customFilesHash = fingerprint + s.mu.Unlock() + + logger.LegacyPrintf("service.pricing", "[Pricing] Custom pricing files changed, reloaded %d models from %s", len(data), pricingFile) + return nil +} + // downloadPricingData 从远程下载价格数据 func (s *PricingService) downloadPricingData() error { remoteURL, err := s.validatePricingURL(s.cfg.Pricing.RemoteURL) @@ -382,13 +527,10 @@ func (s *PricingService) downloadPricingData() error { remoteHash[:min(8, len(remoteHash))], dataHashStr[:8]) } - // 解析JSON数据(使用灵活的解析方式) - data, err := s.parsePricingData(body) + data, customFilesHash, err := s.buildPricingData(body) if err != nil { return fmt.Errorf("parse pricing data: %w", err) } - data = s.mergeFallbackPricingData(data) - data = s.mergeOverrideOnlyModels(data) // 保存到本地文件 pricingFile := s.getPricingFilePath() @@ -413,6 +555,7 @@ func (s *PricingService) downloadPricingData() error { s.pricingData = data s.lastUpdated = time.Now() s.localHash = syncHash + s.customFilesHash = customFilesHash s.mu.Unlock() logger.LegacyPrintf("service.pricing", "[Pricing] Downloaded %d models successfully", len(data)) @@ -798,6 +941,20 @@ func (s *PricingService) mergeOverrideOnlyModels(data map[string]*LiteLLMModelPr return data } +// buildPricingData 解析目录正文并依次叠加 fallback、override 两层,返回合并结果与 +// 叠加层文件指纹。指纹在合并读取之前采样:并发改文件只会让存下的指纹落后于实际 +// 合并的数据、不会领先,下一轮定时比对因此会再次重建。 +func (s *PricingService) buildPricingData(body []byte) (map[string]*LiteLLMModelPricing, string, error) { + fingerprint := s.customPricingFilesFingerprint() + data, err := s.parsePricingData(body) + if err != nil { + return nil, "", err + } + data = s.mergeFallbackPricingData(data) + data = s.mergeOverrideOnlyModels(data) + return data, fingerprint, nil +} + // loadPricingData 从本地文件加载价格数据 func (s *PricingService) loadPricingData(filePath string) error { data, err := os.ReadFile(filePath) @@ -805,13 +962,10 @@ func (s *PricingService) loadPricingData(filePath string) error { return fmt.Errorf("read file failed: %w", err) } - // 使用灵活的解析方式 - pricingData, err := s.parsePricingData(data) + pricingData, customFilesHash, err := s.buildPricingData(data) if err != nil { return fmt.Errorf("parse pricing data: %w", err) } - pricingData = s.mergeFallbackPricingData(pricingData) - pricingData = s.mergeOverrideOnlyModels(pricingData) // 计算哈希 hash := sha256.Sum256(data) @@ -821,6 +975,7 @@ func (s *PricingService) loadPricingData(filePath string) error { warnDroppedLongContextLadders(s.pricingData, pricingData) s.pricingData = pricingData s.localHash = hashStr + s.customFilesHash = customFilesHash info, _ := os.Stat(filePath) if info != nil { @@ -1052,7 +1207,7 @@ func (s *PricingService) buildModelLookupCandidates(modelLower string) []string normalized := normalizeModelNameForPricing(modelLower) // A tier-specific entry should take precedence when the pricing catalog gains - // one later. Today Antigravity's Gemini 3.6 Flash tiers share the base rate, + // one later. Antigravity's Gemini Flash thinking tiers share the base rate, // so the normalized base remains the fallback after the exact aliases. candidates := rawCandidates if normalizeGeminiThinkingTierAlias(lastSegment(modelLower)) != lastSegment(modelLower) { @@ -1100,6 +1255,9 @@ func normalizeModelNameForPricing(model string) string { model = strings.TrimLeft(model, "/") if canonical := canonicalizeOpenAIModelAliasSpelling(model); canonical != "" { + if canonical == "gpt-6" { + return "gpt-6-astra" + } if canonical == "gpt-5.6" { return "gpt-5.6-sol" } @@ -1114,7 +1272,7 @@ func normalizeModelNameForPricing(model string) string { // normalizeGeminiThinkingTierAlias maps Antigravity's Gemini Flash // thinking-tier model IDs to the public base model. The tier controls reasoning // behavior, not the published token rate, so this keeps -high/-low/-medium and -// -tiered requests on the same price card as their base model. +// -tiered requests on the corresponding base model's price card. func normalizeGeminiThinkingTierAlias(model string) string { for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} { for _, tier := range []string{"-high", "-low", "-medium", "-tiered"} { @@ -1301,6 +1459,12 @@ func (s *PricingService) matchOpenAIModel(model string) *LiteLLMModelPricing { } } + if isOpenAIGPT6AstraModel(model) { + logger.With(zap.String("component", "service.pricing")). + Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-6-astra(static)")) + return openAIGPT6AstraFallbackPricing + } + if strings.HasPrefix(model, "gpt-5.6-sol") { logger.With(zap.String("component", "service.pricing")). Info(fmt.Sprintf("[Pricing] OpenAI fallback matched %s -> %s", model, "gpt-5.6-sol(static)")) diff --git a/backend/internal/service/pricing_service_hot_reload_test.go b/backend/internal/service/pricing_service_hot_reload_test.go new file mode 100644 index 0000000000..31a50ac145 --- /dev/null +++ b/backend/internal/service/pricing_service_hot_reload_test.go @@ -0,0 +1,217 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +const hotReloadCatalogJSON = `{ + "remote-model": {"litellm_provider": "test", "mode": "chat", + "input_cost_per_token": 1e-06, "output_cost_per_token": 2e-06} +}` + +func hotReloadModelJSON(name string, input, output float64) string { + return `"` + name + `": {"litellm_provider": "test", "mode": "chat", + "input_cost_per_token": ` + formatFloat(input) + `, "output_cost_per_token": ` + formatFloat(output) + `}` +} + +func formatFloat(v float64) string { + return strconv.FormatFloat(v, 'g', -1, 64) +} + +// newHotReloadPricingService 在数据目录里放好目录缓存与 fallback/override 文件并完成首次加载。 +// 传空串表示不配置对应文件。 +func newHotReloadPricingService(t *testing.T, fallbackJSON, overrideJSON string) *PricingService { + t.Helper() + dir := t.TempDir() + svc := &PricingService{cfg: &config.Config{}} + svc.cfg.Pricing.DataDir = dir + require.NoError(t, os.WriteFile(svc.getPricingFilePath(), []byte(hotReloadCatalogJSON), 0644)) + if fallbackJSON != "" { + svc.cfg.Pricing.FallbackFile = filepath.Join(dir, "fallback.json") + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(fallbackJSON), 0644)) + } + if overrideJSON != "" { + svc.cfg.Pricing.OverrideFile = filepath.Join(dir, "overrides.json") + require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(overrideJSON), 0644)) + } + require.NoError(t, svc.loadPricingData(svc.getPricingFilePath())) + return svc +} + +func TestPricingCustomFilesFingerprint(t *testing.T) { + svc := &PricingService{cfg: &config.Config{}} + require.Empty(t, svc.customPricingFilesFingerprint(), "未配置文件返回空串") + + dir := t.TempDir() + svc.cfg.Pricing.FallbackFile = filepath.Join(dir, "fallback.json") + missing := svc.customPricingFilesFingerprint() + require.NotEmpty(t, missing) + require.Equal(t, missing, svc.customPricingFilesFingerprint(), "同一状态下指纹稳定") + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{}`), 0644)) + present := svc.customPricingFilesFingerprint() + require.NotEqual(t, missing, present, "文件出现即视为变化") + + svc.cfg.Pricing.OverrideFile = filepath.Join(dir, "overrides.json") + require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(`{}`), 0644)) + require.NotEqual(t, present, svc.customPricingFilesFingerprint(), "override 内容参与指纹") +} + +func TestPricingHotReload_FallbackChangeRebuildsWithoutTouchingSyncAnchor(t *testing.T) { + svc := newHotReloadPricingService(t, `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, "") + require.InDelta(t, 4e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12) + require.Nil(t, svc.pricingData["custom-b"]) + require.NotEmpty(t, svc.customFilesHash) + anchor, updated := svc.localHash, svc.lastUpdated + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+ + hotReloadModelJSON("custom-a", 5e-6, 8e-6)+`,`+ + hotReloadModelJSON("custom-b", 1e-6, 3e-6)+`}`), 0644)) + svc.reloadIfCustomFilesChanged() + + require.InDelta(t, 5e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12, "改价即时生效") + require.NotNil(t, svc.pricingData["custom-b"], "新模型即时并入") + require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12, "目录条目不受影响") + require.Equal(t, anchor, svc.localHash, "热重载不得改动远程同步锚点") + require.Equal(t, updated, svc.lastUpdated) + require.Equal(t, svc.customPricingFilesFingerprint(), svc.customFilesHash) +} + +func TestPricingHotReload_UnchangedFilesSkipRebuild(t *testing.T) { + svc := newHotReloadPricingService(t, + `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, + `{"remote-model": {"input_cost_per_token": 7e-06}}`) + svc.pricingData["sentinel"] = &LiteLLMModelPricing{} + + svc.reloadIfCustomFilesChanged() + + require.Contains(t, svc.pricingData, "sentinel", "指纹未变时不得重建") +} + +func TestPricingHotReload_OverrideChangePatchesCatalogAndAddsModels(t *testing.T) { + svc := newHotReloadPricingService(t, "", `{"remote-model": {"input_cost_per_token": 7e-06}}`) + require.InDelta(t, 7e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12) + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(`{ + "remote-model": {"input_cost_per_token": 9e-06}, + `+hotReloadModelJSON("override-new-model", 5e-6, 1e-5)+`}`), 0644)) + svc.reloadIfCustomFilesChanged() + + require.InDelta(t, 9e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12) + require.InDelta(t, 2e-6, svc.pricingData["remote-model"].OutputCostPerToken, 1e-12, "未覆盖字段保持目录值") + require.NotNil(t, svc.pricingData["override-new-model"]) + + // 清空补丁:目录条目回到原价,补丁新增的模型随之消失。 + require.NoError(t, os.WriteFile(svc.cfg.Pricing.OverrideFile, []byte(`{}`), 0644)) + svc.reloadIfCustomFilesChanged() + + require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12) + require.Nil(t, svc.pricingData["override-new-model"]) +} + +func TestPricingHotReload_InvalidFileKeepsCurrentDataUntilFixed(t *testing.T) { + svc := newHotReloadPricingService(t, `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, "") + before := svc.customFilesHash + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{"custom-a": {"input_cost_per_token": `), 0644)) + svc.reloadIfCustomFilesChanged() + require.InDelta(t, 4e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12, "半写文件不得替换数据") + require.Equal(t, before, svc.customFilesHash, "指纹不更新,下一轮继续尝试") + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+hotReloadModelJSON("custom-a", 6e-6, 8e-6)+`}`), 0644)) + svc.reloadIfCustomFilesChanged() + require.InDelta(t, 6e-6, svc.pricingData["custom-a"].InputCostPerToken, 1e-12, "文件修好后正常重建") + require.NotEqual(t, before, svc.customFilesHash) +} + +// 删除文件等于清空该层:override 补丁撤销、fallback 独有模型消失,都在下一轮比对时生效, +// 且缺失状态被记录,之后不会每轮重建。 +func TestPricingHotReload_DeletedFileDropsItsLayer(t *testing.T) { + svc := newHotReloadPricingService(t, + `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, + `{"remote-model": {"input_cost_per_token": 7e-06}}`) + require.NotNil(t, svc.pricingData["custom-a"]) + require.InDelta(t, 7e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12) + + require.NoError(t, os.Remove(svc.cfg.Pricing.OverrideFile)) + svc.reloadIfCustomFilesChanged() + require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12, "删除 override 后目录条目回到原价") + require.NotNil(t, svc.pricingData["custom-a"], "fallback 层不受影响") + + require.NoError(t, os.Remove(svc.cfg.Pricing.FallbackFile)) + svc.reloadIfCustomFilesChanged() + require.Nil(t, svc.pricingData["custom-a"], "删除 fallback 后其独有模型消失") + require.InDelta(t, 1e-6, svc.pricingData["remote-model"].InputCostPerToken, 1e-12, "目录条目不受影响") + + svc.pricingData["sentinel"] = &LiteLLMModelPricing{} + svc.reloadIfCustomFilesChanged() + require.Contains(t, svc.pricingData, "sentinel", "缺失状态已记录,不得每轮重建") + + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`), 0644)) + svc.reloadIfCustomFilesChanged() + require.NotNil(t, svc.pricingData["custom-a"], "文件重新出现即恢复") +} + +type stubPricingRemoteClient struct{ body string } + +func (c stubPricingRemoteClient) FetchPricingJSON(context.Context, string) ([]byte, error) { + return []byte(c.body), nil +} + +func (c stubPricingRemoteClient) FetchHashText(context.Context, string) (string, error) { + return "", nil +} + +// 远程下载重建后指纹必须同步到当前文件内容,否则下一轮定时比对会多做一次无意义重载。 +func TestPricingHotReload_DownloadRefreshesFingerprint(t *testing.T) { + svc := newHotReloadPricingService(t, `{`+hotReloadModelJSON("custom-a", 4e-6, 8e-6)+`}`, "") + svc.cfg.Pricing.RemoteURL = "https://example.com/pricing.json" + svc.remoteClient = stubPricingRemoteClient{body: hotReloadCatalogJSON} + require.NoError(t, os.WriteFile(svc.cfg.Pricing.FallbackFile, []byte(`{`+hotReloadModelJSON("custom-b", 1e-6, 3e-6)+`}`), 0644)) + + require.NoError(t, svc.downloadPricingData()) + + require.NotNil(t, svc.pricingData["custom-b"]) + require.Nil(t, svc.pricingData["custom-a"]) + require.Equal(t, svc.customPricingFilesFingerprint(), svc.customFilesHash) + + svc.pricingData["sentinel"] = &LiteLLMModelPricing{} + svc.reloadIfCustomFilesChanged() + require.Contains(t, svc.pricingData, "sentinel", "下载已消化文件变化,不得再次重建") +} + +// 只配置了 fallback/override 而没有 remote_url 时调度器也要运行,否则文件改动无人比对。 +func TestPricingSchedulerStartsForCustomFilesWithoutRemoteURL(t *testing.T) { + svc := NewPricingService(&config.Config{Pricing: config.PricingConfig{ + RemoteURL: "", + FallbackFile: filepath.Join(t.TempDir(), "fallback.json"), + }}, nil) + + svc.startUpdateScheduler() + done := make(chan struct{}) + go func() { + svc.wg.Wait() + close(done) + }() + + select { + case <-done: + t.Fatal("custom file watch must keep the scheduler running") + case <-time.After(50 * time.Millisecond): + } + + svc.Stop() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("scheduler must exit after Stop") + } +} diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index bfbac756ff..0dce3e435c 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -77,6 +77,95 @@ func TestParsePricingData_ParsesPriorityAndServiceTierFields(t *testing.T) { require.True(t, pricing.SupportsServiceTier) } +const gpt6AstraCatalogJSON = `{ + "gpt-6-astra": { + "litellm_provider": "openai", + "mode": "chat", + "input_cost_per_token": 1e-05, + "input_cost_per_token_priority": 2e-05, + "output_cost_per_token": 5e-05, + "output_cost_per_token_priority": 1e-04, + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_priority": 2.5e-05, + "cache_read_input_token_cost": 1e-06, + "cache_read_input_token_cost_priority": 2e-06, + "input_cost_per_token_above_272k_tokens": 2e-05, + "output_cost_per_token_above_272k_tokens": 7.5e-05, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-05, + "cache_read_input_token_cost_above_272k_tokens": 2e-06 + } +}` + +func TestBillingServiceGPT6AstraUsesOfficialPricingAcrossTiersAndLongContext(t *testing.T) { + svc := NewBillingService(&config.Config{}, newStubPricingServiceFromJSON(t, gpt6AstraCatalogJSON)) + boundaryTokens := UsageTokens{InputTokens: 100_000, CacheCreationTokens: 100_000, CacheReadTokens: 72_000, OutputTokens: 10} + boundary, err := svc.CalculateCost("gpt-6-astra", boundaryTokens, 1) + require.NoError(t, err) + require.False(t, boundary.LongContextBillingApplied) + require.InDelta(t, 100_000*10e-6, boundary.InputCost, 1e-12) + require.InDelta(t, 100_000*12.5e-6, boundary.CacheCreationCost, 1e-12) + require.InDelta(t, 72_000*1e-6, boundary.CacheReadCost, 1e-12) + require.InDelta(t, 10*50e-6, boundary.OutputCost, 1e-12) + + tokens := UsageTokens{InputTokens: 100_000, CacheCreationTokens: 100_000, CacheReadTokens: 73_000, OutputTokens: 10} + tiers := []struct { + name string + serviceTier string + priceScale float64 + }{ + {name: "standard", priceScale: 1}, + {name: "fast", serviceTier: "priority", priceScale: 2}, + {name: "flex", serviceTier: "flex", priceScale: 0.5}, + } + for _, tier := range tiers { + t.Run(tier.name, func(t *testing.T) { + cost, err := svc.CalculateCostWithServiceTier("gpt-6-astra", tokens, 1, tier.serviceTier) + require.NoError(t, err) + require.True(t, cost.LongContextBillingApplied) + require.InDelta(t, 100_000*10e-6*tier.priceScale*2, cost.InputCost, 1e-12) + require.InDelta(t, 100_000*12.5e-6*tier.priceScale*2, cost.CacheCreationCost, 1e-12) + require.InDelta(t, 73_000*1e-6*tier.priceScale*2, cost.CacheReadCost, 1e-12) + require.InDelta(t, 10*50e-6*tier.priceScale*1.5, cost.OutputCost, 1e-12) + }) + } +} + +func TestGPT6AstraDedicatedFallbacksUseOfficialRates(t *testing.T) { + tests := []struct { + name string + svc *BillingService + }{ + {name: "pricing_service", svc: NewBillingService(&config.Config{}, &PricingService{pricingData: map[string]*LiteLLMModelPricing{}})}, + {name: "billing_service", svc: NewBillingService(&config.Config{}, nil)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pricing, err := tt.svc.GetModelPricing("gpt-6-astra") + require.NoError(t, err) + require.InDelta(t, 10e-6, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, 20e-6, pricing.InputPricePerTokenPriority, 1e-12) + require.InDelta(t, 50e-6, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, 100e-6, pricing.OutputPricePerTokenPriority, 1e-12) + require.InDelta(t, 12.5e-6, pricing.CacheCreationPricePerToken, 1e-12) + require.InDelta(t, 25e-6, pricing.CacheCreationPricePerTokenPriority, 1e-12) + require.InDelta(t, 1e-6, pricing.CacheReadPricePerToken, 1e-12) + require.InDelta(t, 2e-6, pricing.CacheReadPricePerTokenPriority, 1e-12) + require.Equal(t, 272_000, pricing.LongContextInputThreshold) + require.InDelta(t, 2.0, pricing.LongContextInputMultiplier, 1e-12) + require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12) + }) + } +} + +func TestPricingServiceBareGPT6AliasUsesAstra(t *testing.T) { + astraPricing := &LiteLLMModelPricing{InputCostPerToken: 123e-6, OutputCostPerToken: 456e-6} + pricingSvc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{"gpt-6-astra": astraPricing}} + for _, model := range []string{"gpt-6", "openai/gpt-6"} { + pricing := pricingSvc.GetModelPricing(model) + require.Same(t, astraPricing, pricing) + } +} + func TestBillingService_GPT56CacheWritePricingUsesOfficialMultiplier(t *testing.T) { tests := []struct { model string @@ -477,38 +566,37 @@ func TestGetModelPricing_OpenAICompactAliasUsesStaticFallback(t *testing.T) { require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12) } -func TestPricingService_Gemini36FlashThinkingTiersUseBasePricing(t *testing.T) { +func TestPricingService_GeminiFlashThinkingTiersUseBasePricing(t *testing.T) { basePricing := &LiteLLMModelPricing{ InputCostPerToken: 1.5e-6, OutputCostPerToken: 7.5e-6, CacheReadInputTokenCost: 0.15e-6, } - svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ - "gemini-3.6-flash": basePricing, - }} - - for _, model := range []string{ - "gemini-3.6-flash", - "gemini-3.6-flash-high", - "gemini-3.6-flash-low", - "gemini-3.6-flash-medium", - "gemini-3.6-flash-tiered", - } { - t.Run(model, func(t *testing.T) { - require.Same(t, basePricing, svc.GetModelPricing(model)) - }) + for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} { + svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{baseModel: basePricing}} + for _, tier := range []string{"", "-high", "-low", "-medium", "-tiered"} { + model := baseModel + tier + t.Run(model, func(t *testing.T) { + require.Same(t, basePricing, svc.GetModelPricing(model)) + require.Same(t, basePricing, svc.GetIdentifiedModelPricing("models/"+model)) + }) + } } } -func TestPricingService_Gemini36FlashTierSpecificPricingTakesPrecedence(t *testing.T) { +func TestPricingService_GeminiFlashTierSpecificPricingTakesPrecedence(t *testing.T) { basePricing := &LiteLLMModelPricing{InputCostPerToken: 1.5e-6} tierPricing := &LiteLLMModelPricing{InputCostPerToken: 2e-6} - svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ - "gemini-3.6-flash": basePricing, - "gemini-3.6-flash-low": tierPricing, - }} + for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} { + t.Run(baseModel, func(t *testing.T) { + svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + baseModel: basePricing, + baseModel + "-low": tierPricing, + }} - require.Same(t, tierPricing, svc.GetModelPricing("models/gemini-3.6-flash-low")) + require.Same(t, tierPricing, svc.GetModelPricing("models/"+baseModel+"-low")) + }) + } } func TestBillingService_Gemini36FlashThinkingTierFallbacksAreBillable(t *testing.T) { diff --git a/backend/internal/service/proxy_update_probe_invalidation_test.go b/backend/internal/service/proxy_update_probe_invalidation_test.go index ac5215e0e9..3568b503b6 100644 --- a/backend/internal/service/proxy_update_probe_invalidation_test.go +++ b/backend/internal/service/proxy_update_probe_invalidation_test.go @@ -57,11 +57,12 @@ func TestBothProxyUpdateServicesUseRepositoryUpdateBoundary(t *testing.T) { }, } svc := &adminServiceImpl{proxyRepo: repo} + warnDays := 7 _, err := svc.UpdateProxy(context.Background(), 9, &UpdateProxyInput{ Host: "new.example", FallbackMode: FallbackModeNone, - ExpiryWarnDays: 7, + ExpiryWarnDays: &warnDays, }) require.NoError(t, err) diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 3bd467f464..5b42f95951 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -366,12 +366,16 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc // otherwise a broad "rate limit" keyword rule can shorten a multi-hour // cooldown to a local temporary pause. if statusCode == http.StatusTooManyRequests && account.Platform == PlatformAnthropic { + // Fable may be rejected because the organization has no usage credits for + // this model. Anthropic reports that as 429, but it is a model entitlement + // failure rather than a shared account window exhaustion. + fableCreditsRequired := s.persistAnthropicFableCreditsRequired(ctx, account, headers, responseBody, firstRequestedModel(requestedModel)) // 7d_oi 是 Fable 模型专属的 7d 窗口:只标记模型级限流,账号对其他模型仍可调度。 fableLimited := s.persistAnthropicFableWindowLimit(ctx, account, headers) if s.persistAnthropicExhaustedWindowLimit(ctx, account, headers) { return false } - if fableLimited { + if fableCreditsRequired || fableLimited { return false } } @@ -1344,20 +1348,8 @@ func calculateOpenAI429ResetTime(headers http.Header) *time.Time { return &resetAt } - // 都未达到100%但收到429,使用较长的重置时间 - var maxResetSecs int - if normalized.Reset7dSeconds != nil && *normalized.Reset7dSeconds > maxResetSecs { - maxResetSecs = *normalized.Reset7dSeconds - } - if normalized.Reset5hSeconds != nil && *normalized.Reset5hSeconds > maxResetSecs { - maxResetSecs = *normalized.Reset5hSeconds - } - if maxResetSecs > 0 { - resetAt := now.Add(time.Duration(maxResetSecs) * time.Second) - slog.Info("openai_429_using_max_reset", "max_reset_seconds", maxResetSecs, "reset_at", resetAt) - return &resetAt - } - + // 未达到100%时,reset-after 只代表窗口信息,不能证明账号配额耗尽。 + // 这类瞬时429必须回到可配置的兜底路径,避免未耗尽账号被长时间排除。 return nil } @@ -1486,7 +1478,59 @@ func (s *RateLimitService) persistAnthropicExhaustedWindowLimit(ctx context.Cont return true } -const anthropicFableWindowReason = "anthropic_7d_oi_window_exhausted" +const ( + anthropicFableWindowReason = "anthropic_7d_oi_window_exhausted" + anthropicFableCreditsRequiredReason = "anthropic_fable_credits_required" +) + +// persistAnthropicFableCreditsRequired handles Anthropic's credits_required +// response for Fable. Although the upstream status is 429, this response only +// says that the organization cannot use Fable; marking the whole account rate +// limited would unnecessarily stop Sonnet, Opus, and Haiku scheduling. +func (s *RateLimitService) persistAnthropicFableCreditsRequired(ctx context.Context, account *Account, headers http.Header, responseBody []byte, requestedModel string) bool { + if s == nil || s.accountRepo == nil || account == nil { + return false + } + if !strings.EqualFold(strings.TrimSpace(gjson.GetBytes(responseBody, "error.details.error_code").String()), "credits_required") { + return false + } + + model := strings.TrimSpace(gjson.GetBytes(responseBody, "error.details.model").String()) + if model == "" { + model = strings.TrimSpace(requestedModel) + } + if !isAnthropicFableModel(model) { + return false + } + + now := time.Now() + resetAt, ok := parseAnthropicResetTimestamp(headers.Get("anthropic-ratelimit-unified-reset"), now, 366*24*time.Hour) + if !ok { + cooldown, enabled := s.get429FallbackCooldown(ctx, account) + if !enabled { + slog.Info("anthropic_fable_credits_required_cooldown_ignored", "account_id", account.ID) + return true + } + resetAt = now.Add(cooldown) + } + + if err := s.accountRepo.SetModelRateLimit(ctx, account.ID, anthropicFableRateLimitKey, resetAt, anthropicFableCreditsRequiredReason); err != nil { + slog.Warn("anthropic_fable_credits_required_rate_limit_set_failed", + "account_id", account.ID, + "scope", anthropicFableRateLimitKey, + "reset_at", resetAt, + "error", err) + // The response is still known to be Fable-specific. Do not widen a + // persistence failure into an account-level rate limit. + return true + } + slog.Info("anthropic_fable_credits_required_model_rate_limited", + "account_id", account.ID, + "scope", anthropicFableRateLimitKey, + "reset_at", resetAt, + "reset_in", time.Until(resetAt).Truncate(time.Second)) + return true +} // selectAnthropicFableWindowLimit parses the Anthropic 7d_oi per-model window // headers (the Fable-only 7d window, e.g. anthropic-ratelimit-unified-7d_oi-*). diff --git a/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go b/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go index a13e8fe333..328bf07fe6 100644 --- a/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go +++ b/backend/internal/service/ratelimit_service_anthropic_window_limit_test.go @@ -160,6 +160,82 @@ func TestHandleUpstreamError_Anthropic7dOiOnlyMarksModelRateLimit(t *testing.T) require.Equal(t, 0.41, repo.lastExtraUpdates["session_window_utilization"]) } +func TestHandleUpstreamError_AnthropicFableCreditsRequiredOnlyMarksModelRateLimit(t *testing.T) { + resetAt := time.Now().Add(29 * 24 * time.Hour).Truncate(time.Second) + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(resetAt.Unix(), 10)) + + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required","model":"claude-fable-5","disabled_reason":"org_level_disabled"},"message":"Usage credits are required for this model."}}`) + + shouldDisable := svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, body, "claude-fable-5") + + require.False(t, shouldDisable) + require.Zero(t, repo.rateLimitCalls, "Fable entitlement failure must not rate limit the whole account") + require.Zero(t, repo.sessionWindowCalls, "Fable entitlement failure must not rewrite the shared session window") + require.Equal(t, 1, repo.modelRateLimitCalls) + require.Equal(t, anthropicFableRateLimitKey, repo.lastModelRateLimitScope) + require.Equal(t, resetAt, repo.lastModelRateLimitReset) +} + +func TestHandleUpstreamError_AnthropicFableCreditsRequiredFallsBackToRequestedModel(t *testing.T) { + startedAt := time.Now() + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required"},"message":"Usage credits are required for this model."}}`) + + svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, body, "claude-fable-5[1m]") + + require.Zero(t, repo.rateLimitCalls) + require.Equal(t, 1, repo.modelRateLimitCalls) + require.Equal(t, anthropicFableRateLimitKey, repo.lastModelRateLimitScope) + require.True(t, repo.lastModelRateLimitReset.After(startedAt)) +} + +func TestHandleUpstreamError_AnthropicNonFableCreditsRequiredKeepsLegacyBehavior(t *testing.T) { + resetAt := time.Now().Add(2 * time.Hour).Truncate(time.Second) + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(resetAt.Unix(), 10)) + + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required","model":"claude-opus-5"},"message":"Usage credits are required for this model."}}`) + + svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, body, "claude-opus-5") + + require.Zero(t, repo.modelRateLimitCalls) + require.Equal(t, 1, repo.rateLimitCalls) + require.Equal(t, resetAt, repo.lastRateLimitReset) +} + +func TestHandleUpstreamError_AnthropicSharedWindowStillWinsWithFableCreditsRequired(t *testing.T) { + now := time.Now() + reset5h := now.Add(2 * time.Hour).Truncate(time.Second) + reset7d := now.Add(80 * time.Hour).Truncate(time.Second) + headers := http.Header{} + headers.Set("anthropic-ratelimit-unified-5h-status", "rejected") + headers.Set("anthropic-ratelimit-unified-5h-utilization", "1.0") + headers.Set("anthropic-ratelimit-unified-5h-reset", strconv.FormatInt(reset5h.Unix(), 10)) + headers.Set("anthropic-ratelimit-unified-7d-status", "allowed") + headers.Set("anthropic-ratelimit-unified-7d-reset", strconv.FormatInt(reset7d.Unix(), 10)) + headers.Set("anthropic-ratelimit-unified-reset", strconv.FormatInt(reset5h.Unix(), 10)) + + repo := &anthropicWindowLimitRepo{} + svc := NewRateLimitService(repo, nil, nil, nil, nil) + account := &Account{ID: 42, Type: AccountTypeOAuth, Platform: PlatformAnthropic} + body := []byte(`{"type":"error","error":{"details":{"error_code":"credits_required","model":"claude-fable-5"}}}`) + + svc.HandleUpstreamError(context.Background(), account, http.StatusTooManyRequests, headers, body, "claude-fable-5") + + require.Equal(t, 1, repo.modelRateLimitCalls, "Fable-specific failure should still be recorded") + require.Equal(t, 1, repo.rateLimitCalls, "an explicitly rejected shared window must still rate limit the account") + require.Equal(t, reset5h, repo.lastRateLimitReset) +} + func TestHandleUpstreamError_Anthropic5hWindowStillWinsOver7dOi(t *testing.T) { // 5h 窗口 rejected 时必须仍按账号级限流处理(用 5h reset),同时记录 Fable 模型限流。 now := time.Now() diff --git a/backend/internal/service/ratelimit_service_openai_test.go b/backend/internal/service/ratelimit_service_openai_test.go index 8969f7e622..ba4324d7f9 100644 --- a/backend/internal/service/ratelimit_service_openai_test.go +++ b/backend/internal/service/ratelimit_service_openai_test.go @@ -73,10 +73,9 @@ func TestCalculateOpenAI429ResetTime_5hExhausted(t *testing.T) { } } -func TestCalculateOpenAI429ResetTime_NeitherExhausted_UsesMax(t *testing.T) { +func TestCalculateOpenAI429ResetTime_NeitherExhausted_ReturnsNil(t *testing.T) { svc := &RateLimitService{} - // Neither limit at 100%, should use the longer reset time headers := http.Header{} headers.Set("x-codex-primary-used-percent", "80") headers.Set("x-codex-primary-reset-after-seconds", "100000") @@ -85,22 +84,7 @@ func TestCalculateOpenAI429ResetTime_NeitherExhausted_UsesMax(t *testing.T) { headers.Set("x-codex-secondary-reset-after-seconds", "5000") headers.Set("x-codex-secondary-window-minutes", "300") - before := time.Now() - resetAt := svc.calculateOpenAI429ResetTime(headers) - after := time.Now() - - if resetAt == nil { - t.Fatal("expected non-nil resetAt") - } - - // Should use the max (100000 seconds from 7d window) - expectedDuration := 100000 * time.Second - minExpected := before.Add(expectedDuration) - maxExpected := after.Add(expectedDuration) - - if resetAt.Before(minExpected) || resetAt.After(maxExpected) { - t.Errorf("resetAt %v not in expected range [%v, %v]", resetAt, minExpected, maxExpected) - } + require.Nil(t, svc.calculateOpenAI429ResetTime(headers)) } func TestCalculateOpenAI429ResetTime_NoCodexHeaders(t *testing.T) { diff --git a/backend/internal/service/redeem_admin_fulfillment_test.go b/backend/internal/service/redeem_admin_fulfillment_test.go new file mode 100644 index 0000000000..1e7a32c5b0 --- /dev/null +++ b/backend/internal/service/redeem_admin_fulfillment_test.go @@ -0,0 +1,59 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestAdminFulfillmentBypassesLimitAndKeepsRedeemAffiliate(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + userID := int64(42) + inviterID := int64(9001) + code := &RedeemCode{ + ID: 102, Code: "ADMIN-SUCCESS", Type: RedeemTypeBalance, Value: 20, Status: StatusUnused, + } + redeemRepo := &paymentFulfillmentRedeemRepo{ + paymentOrderLifecycleRedeemRepo: paymentOrderLifecycleRedeemRepo{ + codesByCode: map[string]*RedeemCode{code.Code: code}, + }, + } + userRepo := &mockUserRepo{getByIDUser: &User{ID: userID}} + userRepo.updateBalanceFn = func(context.Context, int64, float64) error { return nil } + cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxFailedAttempts} + affiliateRepo := &paymentFulfillmentAffiliateRepoStub{ + inviteeSummary: &AffiliateSummary{ + UserID: userID, AffCode: "INVITEE", InviterID: &inviterID, CreatedAt: time.Now().Add(-time.Hour), + }, + inviterSummary: &AffiliateSummary{ + UserID: inviterID, AffCode: "INVITER", CreatedAt: time.Now().Add(-2 * time.Hour), + }, + } + settingSvc := NewSettingService(&paymentFulfillmentSettingRepoStub{values: map[string]string{ + SettingKeyAffiliateEnabled: "true", + SettingKeyAffiliateRebateRate: "10", + SettingKeyAffiliateRebateFreezeHours: "0", + }}, nil) + affiliateSvc := NewAffiliateService(affiliateRepo, settingSvc, nil, nil) + svc := NewRedeemService(redeemRepo, userRepo, nil, cache, nil, client, nil, affiliateSvc) + + result, err := svc.RedeemForAdminFulfillment(ctx, userID, code.Code) + + require.NoError(t, err) + require.NotNil(t, result) + require.Zero(t, cache.getCalls) + require.Zero(t, cache.incrementCalls) + require.Equal(t, 1, cache.acquireCalls) + require.Equal(t, 1, cache.releaseCalls) + require.Len(t, redeemRepo.useCalls, 1) + require.Len(t, affiliateRepo.accrueCalls, 1) + require.Equal(t, inviterID, affiliateRepo.accrueCalls[0].inviterID) + require.Equal(t, userID, affiliateRepo.accrueCalls[0].inviteeUserID) + require.InDelta(t, 2, affiliateRepo.accrueCalls[0].amount, 1e-8) + require.Nil(t, affiliateRepo.accrueCalls[0].sourceOrderID) +} diff --git a/backend/internal/service/redeem_rate_limit_test.go b/backend/internal/service/redeem_rate_limit_test.go new file mode 100644 index 0000000000..7beb302935 --- /dev/null +++ b/backend/internal/service/redeem_rate_limit_test.go @@ -0,0 +1,176 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// redeemRateLimitCacheStub 实现 RedeemCache,记录各方法调用次数并允许注入错误。 +type redeemRateLimitCacheStub struct { + count int + getErr error + incrementErr error + getCalls int + incrementCalls int + acquireCalls int + releaseCalls int +} + +func (c *redeemRateLimitCacheStub) GetRedeemAttemptCount(context.Context, int64) (int, error) { + c.getCalls++ + return c.count, c.getErr +} + +func (c *redeemRateLimitCacheStub) IncrementRedeemAttemptCount(context.Context, int64) error { + c.incrementCalls++ + if c.incrementErr != nil { + return c.incrementErr + } + c.count++ + return nil +} + +func (c *redeemRateLimitCacheStub) AcquireRedeemLock(context.Context, string, time.Duration) (bool, error) { + c.acquireCalls++ + return true, nil +} + +func (c *redeemRateLimitCacheStub) ReleaseRedeemLock(context.Context, string) error { + c.releaseCalls++ + return nil +} + +func TestPublicRedeemAllowsLastAttemptBeforeLimit(t *testing.T) { + cache := &redeemRateLimitCacheStub{count: redeemMaxFailedAttempts - 1} + redeemRepo := &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}} + svc := &RedeemService{redeemRepo: redeemRepo, cache: cache} + + result, err := svc.Redeem(context.Background(), 42, "MISSING") + + require.Nil(t, result) + require.ErrorIs(t, err, ErrRedeemCodeNotFound) + require.Equal(t, redeemMaxFailedAttempts, cache.count) + require.Equal(t, 1, cache.getCalls) + require.Equal(t, 1, cache.incrementCalls) + require.Equal(t, 1, cache.acquireCalls) + require.Equal(t, 1, cache.releaseCalls) +} + +func TestPublicRedeemCountsOnlyConfiguredDomainFailures(t *testing.T) { + past := time.Now().Add(-time.Minute) + tests := []struct { + name string + code RedeemCode + input string + wantErr error + }{ + { + name: "not found", + code: RedeemCode{Code: "OTHER"}, + input: "MISSING", + wantErr: ErrRedeemCodeNotFound, + }, + { + name: "expired", + code: RedeemCode{ + ID: 1, Code: "EXPIRED", Type: RedeemTypeBalance, Value: 10, + Status: StatusUnused, ExpiresAt: &past, + }, + input: "EXPIRED", + wantErr: ErrRedeemCodeExpired, + }, + { + name: "used", + code: RedeemCode{ + ID: 2, Code: "USED", Type: RedeemTypeBalance, Value: 10, + Status: StatusUsed, + }, + input: "USED", + wantErr: ErrRedeemCodeUsed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cache := &redeemRateLimitCacheStub{} + svc := &RedeemService{ + redeemRepo: &redeemRejectRepo{code: tt.code}, + cache: cache, + } + + result, err := svc.Redeem(context.Background(), 42, tt.input) + + require.Nil(t, result) + require.ErrorIs(t, err, tt.wantErr) + require.Equal(t, 1, cache.getCalls) + require.Equal(t, 1, cache.incrementCalls) + require.Equal(t, 1, cache.count) + }) + } +} + +func TestPublicRedeemRateLimitFailsOpenWhenCounterReadFails(t *testing.T) { + cache := &redeemRateLimitCacheStub{ + count: redeemMaxFailedAttempts, + getErr: errors.New("redis unavailable"), + } + svc := &RedeemService{ + redeemRepo: &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}}, + cache: cache, + } + + result, err := svc.Redeem(context.Background(), 42, "MISSING") + + require.Nil(t, result) + require.ErrorIs(t, err, ErrRedeemCodeNotFound) + require.Equal(t, 1, cache.getCalls) + require.Equal(t, 1, cache.incrementCalls) +} + +func TestPublicRedeemCounterWriteFailurePreservesDomainError(t *testing.T) { + cache := &redeemRateLimitCacheStub{incrementErr: errors.New("redis unavailable")} + svc := &RedeemService{ + redeemRepo: &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}}, + cache: cache, + } + + result, err := svc.Redeem(context.Background(), 42, "MISSING") + + require.Nil(t, result) + require.ErrorIs(t, err, ErrRedeemCodeNotFound) + require.Equal(t, 1, cache.incrementCalls) + require.Zero(t, cache.count) +} + +func TestSuccessfulPublicRedeemDoesNotMutateFailureCounter(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + userID := int64(42) + code := &RedeemCode{ + ID: 101, Code: "PUBLIC-SUCCESS", Type: RedeemTypeBalance, Value: 10, Status: StatusUnused, + } + redeemRepo := &paymentOrderLifecycleRedeemRepo{ + codesByCode: map[string]*RedeemCode{code.Code: code}, + } + userRepo := &mockUserRepo{getByIDUser: &User{ID: userID}} + userRepo.updateBalanceFn = func(context.Context, int64, float64) error { return nil } + cache := &redeemRateLimitCacheStub{count: 7} + svc := NewRedeemService(redeemRepo, userRepo, nil, cache, nil, client, nil, nil) + + result, err := svc.Redeem(ctx, userID, code.Code) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, cache.getCalls) + require.Zero(t, cache.incrementCalls) + require.Equal(t, 7, cache.count) + require.Equal(t, 1, cache.acquireCalls) + require.Equal(t, 1, cache.releaseCalls) + require.Len(t, redeemRepo.useCalls, 1) +} diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index 8794872e3d..5e7cdee685 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -25,11 +25,17 @@ var ( ) const ( - redeemMaxErrorsPerHour = 20 - redeemRateLimitDuration = time.Hour + redeemMaxFailedAttempts = 30 redeemLockDuration = 10 * time.Second // 锁超时时间,防止死锁 ) +type redeemRateLimitPolicy uint8 + +const ( + enforceRedeemRateLimit redeemRateLimitPolicy = iota + bypassRedeemRateLimit +) + type ctxKeySkipRedeemAffiliate struct{} // ContextSkipRedeemAffiliate returns a context that suppresses the redeem-level @@ -337,7 +343,7 @@ func (s *RedeemService) checkRedeemRateLimit(ctx context.Context, userID int64) return nil } - if count >= redeemMaxErrorsPerHour { + if count >= redeemMaxFailedAttempts { return ErrRedeemRateLimited } @@ -386,9 +392,30 @@ func unsupportedRedeemTypeError(codeType string) error { // Redeem 使用兑换码 func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (*RedeemCode, error) { - // 检查限流 - if err := s.checkRedeemRateLimit(ctx, userID); err != nil { - return nil, err + return s.redeem(ctx, userID, code, enforceRedeemRateLimit) +} + +// redeemForPaymentFulfillment is restricted to trusted payment fulfillment. +// Payment retries must not be blocked by, or contribute to, a user's public +// redeem failure counter. All code validation and transactional updates remain +// identical to the public redemption path. +func (s *RedeemService) redeemForPaymentFulfillment(ctx context.Context, userID int64, code string) (*RedeemCode, error) { + return s.redeem(ContextSkipRedeemAffiliate(ctx), userID, code, bypassRedeemRateLimit) +} + +// RedeemForAdminFulfillment is restricted to trusted admin fulfillment. +// Admin retries must not be blocked by, or contribute to, a user's public +// redeem failure counter. Unlike payment fulfillment, the normal redeem-level +// affiliate rebate remains enabled. +func (s *RedeemService) RedeemForAdminFulfillment(ctx context.Context, userID int64, code string) (*RedeemCode, error) { + return s.redeem(ctx, userID, code, bypassRedeemRateLimit) +} + +func (s *RedeemService) redeem(ctx context.Context, userID int64, code string, rateLimitPolicy redeemRateLimitPolicy) (*RedeemCode, error) { + if rateLimitPolicy == enforceRedeemRateLimit { + if err := s.checkRedeemRateLimit(ctx, userID); err != nil { + return nil, err + } } // 获取分布式锁,防止同一兑换码并发使用 @@ -401,7 +428,9 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( redeemCode, err := s.redeemRepo.GetByCode(ctx, code) if err != nil { if errors.Is(err, ErrRedeemCodeNotFound) { - s.incrementRedeemErrorCount(ctx, userID) + if rateLimitPolicy == enforceRedeemRateLimit { + s.incrementRedeemErrorCount(ctx, userID) + } return nil, ErrRedeemCodeNotFound } return nil, fmt.Errorf("get redeem code: %w", err) @@ -409,11 +438,15 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( // 检查兑换码状态和码本身的过期时间 if redeemCode.IsExpired() { - s.incrementRedeemErrorCount(ctx, userID) + if rateLimitPolicy == enforceRedeemRateLimit { + s.incrementRedeemErrorCount(ctx, userID) + } return nil, ErrRedeemCodeExpired } if !redeemCode.CanUse() { - s.incrementRedeemErrorCount(ctx, userID) + if rateLimitPolicy == enforceRedeemRateLimit { + s.incrementRedeemErrorCount(ctx, userID) + } return nil, ErrRedeemCodeUsed } diff --git a/backend/internal/service/scheduler_snapshot_bulk_event_test.go b/backend/internal/service/scheduler_snapshot_bulk_event_test.go index 0f6003a01a..d8fdc063a0 100644 --- a/backend/internal/service/scheduler_snapshot_bulk_event_test.go +++ b/backend/internal/service/scheduler_snapshot_bulk_event_test.go @@ -115,7 +115,7 @@ func TestSchedulerBulkAccountEventScopesOpenAIRebuildToFreshPlatform(t *testing. } func TestSchedulerBulkAccountEventScopesCNRebuildToFreshPlatform(t *testing.T) { - for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek} { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { t.Run(platform, func(t *testing.T) { cache := newBulkEventSnapshotCache() repo := newBulkEventAccountRepo(&Account{ID: 1, Platform: platform, GroupIDs: []int64{12}}) diff --git a/backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go b/backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go index e52adbb4c8..e4489b81c7 100644 --- a/backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go +++ b/backend/internal/service/scheduler_snapshot_full_rebuild_lifecycle_test.go @@ -290,13 +290,14 @@ func TestSchedulerFullRebuildActiveTombstoneDoesNotBlockFollowingGroupEvent(t *t require.Equal(t, 1, activeCalls) require.Zero(t, fallbackCalls) require.Equal(t, []int64{groupID, groupID}, freshCalls) - require.Len(t, cache.tokens(), 36, "full rebuild and the following group event must each run fresh authority") + canonicalCount := len(canonical) + require.Len(t, cache.tokens(), 2*canonicalCount, "full rebuild and the following group event must each run fresh authority") _, reopenHeld := cache.lifecycleMutationLeaseStates() - require.Len(t, reopenHeld, 36) + require.Len(t, reopenHeld, 2*canonicalCount) for _, held := range reopenHeld { require.True(t, held) } - require.Equal(t, 30, accounts.callCount()) + require.Equal(t, 3*schedulerCanonicalAccountQueryCount(), accounts.callCount()) } func TestSchedulerFullRebuildGlobalReadErrorsFailBeforeMutationOrDB(t *testing.T) { @@ -367,15 +368,15 @@ func TestSchedulerFullRebuildFreshActivePreparesEveryTokenBeforeFirstDB(t *testi capturesAtFirstDB = cache.captureAttemptCount() held, reopenCount := cache.leaseHeldAndTokenCount() require.False(t, held) - require.Equal(t, 18, reopenCount) - require.Equal(t, 19, capturesAtFirstDB, "C(0) and the historical bucket must be captured before DB") + require.Equal(t, schedulerCanonicalBucketCount(), reopenCount) + require.Equal(t, schedulerCanonicalBucketCount()+1, capturesAtFirstDB, "C(0) and the historical bucket must be captured before DB") } svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard) require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test")) require.Equal(t, capturesAtFirstDB, cache.captureAttemptCount()) - require.Equal(t, 21, accounts.callCount()) - require.Equal(t, 11, accounts.groupCallCount(groupID)) + require.Equal(t, 2*schedulerCanonicalAccountQueryCount()+1, accounts.callCount()) + require.Equal(t, schedulerCanonicalAccountQueryCount()+1, accounts.groupCallCount(groupID)) _, historicalPublished := cache.counts(historical) require.Equal(t, 1, historicalPublished) activeCalls, fallbackCalls, freshCalls := groups.stats() @@ -398,7 +399,7 @@ func TestSchedulerFullRebuildOrdinaryCaptureErrorReturnsBeforeFirstDB(t *testing err := svc.rebuildFullSnapshot(context.Background(), "test") require.ErrorIs(t, err, wantErr) - require.Equal(t, 20, cache.captureAttemptCount(), "all canonical and ordinary captures must be attempted before returning") + require.Equal(t, schedulerCanonicalBucketCount()+2, cache.captureAttemptCount(), "all canonical and ordinary captures must be attempted before returning") require.Zero(t, accounts.callCount()) require.Zero(t, cache.totalSetAttempts()) } @@ -416,8 +417,8 @@ func TestSchedulerFullRebuildPreservesGroupZeroActiveHistoricalAndInvalidRegistr svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard) require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test")) - require.Equal(t, 39, cache.captureAttemptCount()) - require.Equal(t, 23, accounts.callCount()) + require.Equal(t, 2*schedulerCanonicalBucketCount()+3, cache.captureAttemptCount()) + require.Equal(t, 2*schedulerCanonicalAccountQueryCount()+3, accounts.callCount()) groups.mu.Lock() require.Equal(t, 1, groups.listCalls) groups.mu.Unlock() @@ -456,7 +457,7 @@ func TestSchedulerFullRebuildActiveTombstoneFreshInactiveOrMissingFiltersAllGrou require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test")) require.Zero(t, accounts.groupCallCount(groupID)) - require.Equal(t, 10, accounts.groupCallCount(0)) + require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.groupCallCount(0)) require.Empty(t, cache.tokens()) require.Equal(t, bucketStrings(append(canonical, historical)), bucketStrings(cache.retiredBuckets())) for _, bucket := range append(canonical, historical) { @@ -528,7 +529,7 @@ func TestSchedulerFullRebuildPartialLifecycleFailureReturnsBeforeDBAndRetries(t require.Equal(t, []int64{1, 2}, freshCalls) require.Zero(t, accounts.callCount()) require.Zero(t, cache.totalSetAttempts()) - require.Equal(t, 19, len(cache.retiredBuckets())) + require.Equal(t, schedulerCanonicalBucketCount()+1, len(cache.retiredBuckets())) groups.mu.Lock() delete(groups.freshErr, 2) @@ -537,8 +538,8 @@ func TestSchedulerFullRebuildPartialLifecycleFailureReturnsBeforeDBAndRetries(t require.NoError(t, svc.triggerFullRebuild("retry")) _, _, freshCalls = groups.stats() require.Equal(t, []int64{1, 2, 2, 3}, freshCalls) - require.Equal(t, 57, len(cache.retiredBuckets())) - require.Equal(t, 10, accounts.callCount()) + require.Equal(t, 3*(schedulerCanonicalBucketCount()+1), len(cache.retiredBuckets())) + require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount()) require.Empty(t, cache.tokens()) } @@ -561,14 +562,15 @@ func TestSchedulerFullRebuildActiveTombstoneLazyRecoveryDiscardsPartialCaptureTa capturesAtFirstDB = cache.captureAttemptCount() held, reopenCount := cache.leaseHeldAndTokenCount() require.False(t, held) - require.Equal(t, 18, reopenCount) - require.Equal(t, 25, capturesAtFirstDB) + require.Equal(t, len(canonical), reopenCount) + // C(0) + captures through the retired canonical index (inclusive) + historical ordinary bucket. + require.Equal(t, schedulerCanonicalBucketCount()+6+1, capturesAtFirstDB) } svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard) require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test")) require.Equal(t, capturesAtFirstDB, cache.captureAttemptCount()) - require.Equal(t, 21, accounts.callCount()) + require.Equal(t, 2*schedulerCanonicalAccountQueryCount()+1, accounts.callCount()) for _, bucket := range canonical { attempts, published := cache.counts(bucket) require.Equal(t, 1, attempts, "discarded pre-recovery tokens must never publish: %s", bucket.String()) @@ -600,9 +602,9 @@ func TestSchedulerFullRebuildSimpleModePreservesRegistryWithoutLifecycleAuthorit require.Zero(t, activeCalls) require.Zero(t, fallbackCalls) require.Empty(t, freshCalls) - require.Equal(t, 21, cache.captureAttemptCount()) - require.Equal(t, 13, accounts.callCount()) - require.Equal(t, 13, accounts.groupCallCount(0)) + require.Equal(t, schedulerCanonicalBucketCount()+len(registered), cache.captureAttemptCount()) + require.Equal(t, schedulerCanonicalAccountQueryCount()+len(registered), accounts.callCount()) + require.Equal(t, schedulerCanonicalAccountQueryCount()+len(registered), accounts.groupCallCount(0)) require.Empty(t, cache.retiredBuckets()) require.Empty(t, cache.tokens()) for _, bucket := range registered { @@ -632,11 +634,11 @@ func TestSchedulerFullRebuildFreshReopenLockBusyRetriesWithoutBlockingOrdinaryTa require.Zero(t, cache.currentWatermark()) _, groupZeroPublished := cache.counts(schedulerCanonicalBuckets(0)[0]) require.Equal(t, 1, groupZeroPublished, "ordinary tasks must still run when one strict Reopen task is busy") - require.Equal(t, 20, accounts.callCount()) + require.Equal(t, 2*schedulerCanonicalAccountQueryCount(), accounts.callCount()) svc.pollOutbox() require.Equal(t, int64(1), cache.currentWatermark()) - require.Equal(t, 40, accounts.callCount()) + require.Equal(t, 4*schedulerCanonicalAccountQueryCount(), accounts.callCount()) _, busyBucketPublished := cache.counts(canonical[0]) require.Equal(t, 1, busyBucketPublished) activeCalls, fallbackCalls, freshCalls := groups.stats() @@ -654,7 +656,7 @@ func TestSchedulerFullRebuildOrdinaryLockBusyKeepsExistingSkipSemantics(t *testi svc := newFullRebuildLifecycleService(cache, nil, accounts, groups, config.RunModeStandard) require.NoError(t, svc.rebuildFullSnapshot(context.Background(), "test")) - require.Equal(t, 10, accounts.callCount()) + require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount()) attempts, published := cache.counts(busyBucket) require.Zero(t, attempts) require.Zero(t, published) diff --git a/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go b/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go index f9e75ed158..7eda02f737 100644 --- a/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go +++ b/backend/internal/service/scheduler_snapshot_group_lifecycle_test.go @@ -325,18 +325,18 @@ func newGroupLifecycleTestService(cache SchedulerCache, accounts AccountReposito } func expectedGroupLifecycleBuckets(groupID int64) []SchedulerBucket { - platforms := schedulerSnapshotPlatforms() - buckets := make([]SchedulerBucket, 0, 18) - for _, platform := range platforms { - buckets = append(buckets, - SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeSingle}, - SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeForced}, - ) + return schedulerCanonicalBuckets(groupID) +} + +func schedulerCanonicalAccountQueryCount() int { + count := 0 + for _, platform := range schedulerSnapshotPlatforms() { + count++ if platform == PlatformAnthropic || platform == PlatformGemini { - buckets = append(buckets, SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeMixed}) + count++ } } - return buckets + return count } func bucketStrings(buckets []SchedulerBucket) map[string]struct{} { @@ -441,7 +441,7 @@ func TestSchedulerGroupLifecycleActiveReopensAndRebuildsAllCurrentBuckets(t *tes accounts.beforeLoad = func() { held, tokenCount := cache.leaseHeldAndTokenCount() require.False(t, held, "the group lifecycle lease must be released before the first account query") - require.Equal(t, 18, tokenCount, "all reopen tokens must be prepared before the first account query") + require.Equal(t, len(current), tokenCount, "all reopen tokens must be prepared before the first account query") } svc := newGroupLifecycleTestService(cache, accounts, groups, config.RunModeStandard) seen := make(map[batchSeenKey]struct{}) @@ -453,8 +453,8 @@ func TestSchedulerGroupLifecycleActiveReopensAndRebuildsAllCurrentBuckets(t *tes registered, err := cache.retirementRaceCache.ListBuckets(context.Background()) require.NoError(t, err) require.Contains(t, bucketStrings(registered), historical.String()) - require.Len(t, cache.tokens(), 18) - require.Equal(t, 10, accounts.callCount()) + require.Len(t, cache.tokens(), len(current)) + require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount()) require.Equal(t, 1, accounts.platformCallCount(PlatformOpenAI)) for _, bucket := range current { _, published := cache.counts(bucket) @@ -472,16 +472,16 @@ func TestSchedulerGroupLifecycleActiveReopensAndRebuildsAllCurrentBuckets(t *tes require.True(t, cache.releaseDeadline) require.NoError(t, cache.releaseCtxErr) _, reopenHeld := cache.lifecycleMutationLeaseStates() - require.Len(t, reopenHeld, 18) + require.Len(t, reopenHeld, len(current)) for _, held := range reopenHeld { require.True(t, held) } lockTTLs, unlockCalls := cache.lockStats() - require.Len(t, lockTTLs, 18) + require.Len(t, lockTTLs, len(current)) for _, ttl := range lockTTLs { require.Equal(t, 30*time.Second, ttl) } - require.Equal(t, 18, unlockCalls) + require.Equal(t, len(current), unlockCalls) requireLifecycleSeen(t, seen, groupID) } @@ -497,8 +497,8 @@ func TestSchedulerGroupLifecycleInactiveThenActiveAuthoritativelyReopens(t *test groups.set(&Group{ID: groupID, Status: StatusActive, Hydrated: true}, nil) require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{}))) - require.Len(t, cache.tokens(), 18) - require.Equal(t, 10, accounts.callCount()) + require.Len(t, cache.tokens(), schedulerCanonicalBucketCount()) + require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount()) for _, bucket := range expectedGroupLifecycleBuckets(groupID) { _, published := cache.counts(bucket) require.Equal(t, 1, published, bucket.String()) @@ -546,15 +546,16 @@ func TestSchedulerGroupLifecycleEpochPreventsABA(t *testing.T) { groups.set(&Group{ID: groupID, Status: StatusActive, Hydrated: true}, nil) require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{}))) firstActiveTokens := cache.tokens() - require.Len(t, firstActiveTokens, 18) + canonicalCount := schedulerCanonicalBucketCount() + require.Len(t, firstActiveTokens, canonicalCount) groups.set(&Group{ID: groupID, Status: StatusDisabled, Hydrated: true}, nil) require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{}))) groups.set(&Group{ID: groupID, Status: StatusActive, Hydrated: true}, nil) require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), make(map[batchSeenKey]struct{}))) allTokens := cache.tokens() - require.Len(t, allTokens, 36) - require.Greater(t, allTokens[18].Epoch, firstActiveTokens[0].Epoch) + require.Len(t, allTokens, 2*canonicalCount) + require.Greater(t, allTokens[canonicalCount].Epoch, firstActiveTokens[0].Epoch) require.ErrorIs(t, cache.SetSnapshot(context.Background(), firstActiveTokens[0].Bucket, firstActiveTokens[0], nil), ErrSchedulerBucketWriteFenced) } @@ -571,11 +572,11 @@ func TestSchedulerGroupLifecycleSeenIsIndependentAndDeduplicatesGroupEvents(t *t require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), seen)) require.Equal(t, 1, groups.callCount()) - require.Equal(t, 10, accounts.callCount()) + require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount()) requireLifecycleSeen(t, seen, groupID) require.NoError(t, svc.handleGroupEvent(context.Background(), ptrInt64(groupID), seen)) require.Equal(t, 1, groups.callCount()) - require.Equal(t, 10, accounts.callCount()) + require.Equal(t, schedulerCanonicalAccountQueryCount(), accounts.callCount()) } func TestSchedulerGroupLifecycleFailuresDoNotMarkSeen(t *testing.T) { diff --git a/backend/internal/service/scheduler_snapshot_retirement_test.go b/backend/internal/service/scheduler_snapshot_retirement_test.go index 6fa2b7ddc5..7b5a8368d3 100644 --- a/backend/internal/service/scheduler_snapshot_retirement_test.go +++ b/backend/internal/service/scheduler_snapshot_retirement_test.go @@ -176,7 +176,7 @@ func TestSchedulerFullRebuildCapturesAllRegistryTokensBeforeDBLoad(t *testing.T) } captures, reopens := cache.captureAndReopenCounts() - require.Equal(t, 36, captures, "group0 and active-group canonical tokens must be captured before the first DB load") + require.Equal(t, 2*schedulerCanonicalBucketCount(), captures, "group0 and active-group canonical tokens must be captured before the first DB load") require.Zero(t, reopens) require.NoError(t, cache.RetireBucket(context.Background(), queued)) _, err := cache.ReopenBucket(context.Background(), queued) diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index e38d0291ba..6ee67979e1 100644 --- a/backend/internal/service/scheduler_snapshot_service.go +++ b/backend/internal/service/scheduler_snapshot_service.go @@ -609,7 +609,7 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p } accountGroupIDs := s.normalizeGroupIDs(account.GroupIDs) switch account.Platform { - case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek: + case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: addPlatformGroups(account.Platform, accountGroupIDs) case PlatformAntigravity: // 批量更新可能刚关闭 mixed_scheduling,仍需清理两个兼容平台的旧快照。 @@ -824,8 +824,8 @@ func (s *SchedulerSnapshotService) rebuildByAccount(ctx context.Context, account return s.rebuildBuckets(ctx, buckets, reason) } -func schedulerSnapshotPlatforms() [8]string { - return [8]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} +func schedulerSnapshotPlatforms() [9]string { + return [9]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} } // 生命周期辅助函数有意排除 group0;full rebuild 构造 group0 canonical 集时必须显式调用 canonical helper。 @@ -836,9 +836,21 @@ func schedulerBucketsForGroup(groupID int64) []SchedulerBucket { return schedulerCanonicalBuckets(groupID) } -func schedulerCanonicalBuckets(groupID int64) []SchedulerBucket { - buckets := make([]SchedulerBucket, 0, 18) +func schedulerCanonicalBucketCount() int { + count := 0 for _, platform := range schedulerSnapshotPlatforms() { + count += 2 + if platform == PlatformAnthropic || platform == PlatformGemini { + count++ + } + } + return count +} + +func schedulerCanonicalBuckets(groupID int64) []SchedulerBucket { + platforms := schedulerSnapshotPlatforms() + buckets := make([]SchedulerBucket, 0, len(platforms)*2+2) + for _, platform := range platforms { buckets = append(buckets, SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeSingle}, SchedulerBucket{GroupID: groupID, Platform: platform, Mode: SchedulerModeForced}, @@ -855,7 +867,7 @@ func (s *SchedulerSnapshotService) rebuildByGroupIDs(ctx context.Context, groupI if len(groupIDs) == 0 { return nil } - buckets := make([]SchedulerBucket, 0, len(groupIDs)*18) + buckets := make([]SchedulerBucket, 0, len(groupIDs)*schedulerCanonicalBucketCount()) for _, platform := range schedulerSnapshotPlatforms() { buckets = append(buckets, s.bucketsForPlatform(platform, groupIDs, seen)...) } diff --git a/backend/internal/service/session_limit_cache.go b/backend/internal/service/session_limit_cache.go index 5482d61063..41f8fd5b0b 100644 --- a/backend/internal/service/session_limit_cache.go +++ b/backend/internal/service/session_limit_cache.go @@ -33,6 +33,12 @@ type SessionLimitCache interface { // 用于活跃会话保持活动状态 RefreshSession(ctx context.Context, accountID int64, sessionUUID string, idleTimeout time.Duration) error + // UnregisterSession 立即移除会话注册(不等待空闲超时) + // 用于请求最终失败的场景:上游从未真正服务该会话,不应继续占用会话槽, + // 否则 max_sessions 受限的账号会被失败请求的 session hash 卡满整个空闲窗口, + // 导致后续新会话在超时窗口内全部被拒 + UnregisterSession(ctx context.Context, accountID int64, sessionUUID string) error + // GetActiveSessionCount 获取当前活跃会话数 // 返回未过期的会话数量 GetActiveSessionCount(ctx context.Context, accountID int64) (int, error) diff --git a/backend/internal/service/session_limit_release_test.go b/backend/internal/service/session_limit_release_test.go new file mode 100644 index 0000000000..ba29ccec27 --- /dev/null +++ b/backend/internal/service/session_limit_release_test.go @@ -0,0 +1,121 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// sessionLimitReleaseCacheStub 记录 UnregisterSession 调用,用于验证释放逻辑 +type sessionLimitReleaseCacheStub struct { + SessionLimitCache + + unregistered map[int64][]string + err error +} + +func newSessionLimitReleaseCacheStub() *sessionLimitReleaseCacheStub { + return &sessionLimitReleaseCacheStub{ + unregistered: make(map[int64][]string), + } +} + +func (s *sessionLimitReleaseCacheStub) UnregisterSession(_ context.Context, accountID int64, sessionUUID string) error { + if s.err != nil { + return s.err + } + s.unregistered[accountID] = append(s.unregistered[accountID], sessionUUID) + return nil +} + +func newSessionLimitTestAccount() *Account { + return &Account{ + ID: 42, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Extra: map[string]any{"max_sessions": 1}, + } +} + +// TestReleaseAccountSession_ReleasesRegisteredSlot 验证: +// 对启用会话限制的 Anthropic OAuth 账号,ReleaseAccountSession 必须立即移除 +// 该账号上注册的会话(不等待空闲超时)。 +func TestReleaseAccountSession_ReleasesRegisteredSlot(t *testing.T) { + cache := newSessionLimitReleaseCacheStub() + svc := &GatewayService{sessionLimitCache: cache} + acc := newSessionLimitTestAccount() + + svc.ReleaseAccountSession(context.Background(), acc, "session-hash-1") + + require.Equal(t, []string{"session-hash-1"}, cache.unregistered[42], + "应立即移除注册的会话槽") +} + +// TestReleaseAccountSession_NoOpForInapplicableAccounts 验证: +// - 非 Anthropic OAuth/SetupToken 账号 +// - 未启用 max_sessions 的账号 +// - 空 sessionID +// 以上场景均为 no-op,不得触发 UnregisterSession。 +func TestReleaseAccountSession_NoOpForInapplicableAccounts(t *testing.T) { + apiKeyAcc := &Account{ + ID: 43, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Extra: map[string]any{"max_sessions": 1}, + } + noLimitAcc := &Account{ + ID: 44, + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + } + enabledAcc := newSessionLimitTestAccount() + + cases := []struct { + name string + account *Account + sessionID string + }{ + {"api_key_account", apiKeyAcc, "session-hash"}, + {"max_sessions_disabled", noLimitAcc, "session-hash"}, + {"empty_session_id", enabledAcc, ""}, + {"nil_account", nil, "session-hash"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cache := newSessionLimitReleaseCacheStub() + svc := &GatewayService{sessionLimitCache: cache} + svc.ReleaseAccountSession(context.Background(), tc.account, tc.sessionID) + require.Empty(t, cache.unregistered, "不适用账号不应触发释放") + }) + } +} + +// TestReleaseAccountSession_NilCacheAndErrorTolerance 验证: +// sessionLimitCache 不可用时 no-op;UnregisterSession 返回错误时不 panic(仅记录日志)。 +func TestReleaseAccountSession_NilCacheAndErrorTolerance(t *testing.T) { + // nil cache:no-op + svc := &GatewayService{} + svc.ReleaseAccountSession(context.Background(), newSessionLimitTestAccount(), "session-hash") + + // 底层错误:不 panic + cache := &sessionLimitReleaseCacheStub{err: errors.New("redis down")} + svc = &GatewayService{sessionLimitCache: cache} + svc.ReleaseAccountSession(context.Background(), newSessionLimitTestAccount(), "session-hash") +} + +// TestReleaseAccountSession_Idempotent 验证释放操作幂等,可安全重复调用 +// (failover 链上按次释放 + defer 兜底可能对同一账号重复释放)。 +func TestReleaseAccountSession_Idempotent(t *testing.T) { + cache := newSessionLimitReleaseCacheStub() + svc := &GatewayService{sessionLimitCache: cache} + acc := newSessionLimitTestAccount() + + svc.ReleaseAccountSession(context.Background(), acc, "session-hash") + svc.ReleaseAccountSession(context.Background(), acc, "session-hash") + + // 两次调用都透传到缓存层(Redis ZREM 本身幂等,重复移除无副作用) + require.Len(t, cache.unregistered[42], 2) +} diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 88b1ea4161..3827790fe8 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -1016,7 +1016,7 @@ func (s *SettingService) SetOpenAIFastPolicySettings(ctx context.Context, settin BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true, } validTiers := map[string]bool{ - OpenAIFastTierAny: true, OpenAIFastTierPriority: true, OpenAIFastTierFlex: true, + OpenAIFastTierAny: true, OpenAIFastTierPriority: true, OpenAIFastTierUltrafast: true, OpenAIFastTierFlex: true, } for i, rule := range settings.Rules { diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 36e7706dda..7fc929e9c6 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -191,8 +191,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyChannelMonitorDefaultIntervalSeconds: "60", SettingKeyChannelMonitorHideThroughput: "true", SettingKeyChannelMonitorShowQuota: "false", + SettingKeyChannelMonitorHideUserRanking: "false", - // Grok: safe defaults — no cross-vendor model rewrite unless operators enable it. + // Grok compatibility defaults: cross-client mapping stays enabled unless + // operators explicitly disable it. SettingKeyGrokDefaultTextModel: "grok-4.6", SettingKeyGrokCrossClientModelMapEnabled: "true", SettingKeyGrokDefaultBaseURLMode: GrokDefaultBaseURLModeCLI, @@ -803,6 +805,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // 配额展示默认关闭且 fail-closed:仅字面 "true" 视为开启 // (与 setting_public.go 公开读取路径保持一致)。 result.ChannelMonitorShowQuota = settings[SettingKeyChannelMonitorShowQuota] == "true" + result.ChannelMonitorHideUserRanking = isTrueSettingValue(settings[SettingKeyChannelMonitorHideUserRanking]) // Grok default mapping policy result.GrokDefaultTextModel = strings.TrimSpace(settings[SettingKeyGrokDefaultTextModel]) @@ -1009,6 +1012,15 @@ func isFalseSettingValue(value string) bool { } } +func isTrueSettingValue(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "true", "1", "on", "enabled": + return true + default: + return false + } +} + func normalizeVisibleMethodSettingSource(method, source string, enabled bool) (string, error) { _ = enabled source = strings.TrimSpace(source) diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go index 7072dd7c03..d985ce6dd6 100644 --- a/backend/internal/service/setting_public.go +++ b/backend/internal/service/setting_public.go @@ -232,6 +232,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyChannelMonitorDefaultIntervalSeconds, SettingKeyChannelMonitorHideThroughput, SettingKeyChannelMonitorShowQuota, + SettingKeyChannelMonitorHideUserRanking, SettingKeyAvailableChannelsEnabled, SettingKeyModelPlazaEnabled, SettingKeyModelPlazaRequireAuth, @@ -358,6 +359,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]), ChannelMonitorHideThroughput: !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]), ChannelMonitorShowQuota: settings[SettingKeyChannelMonitorShowQuota] == "true", + ChannelMonitorHideUserRanking: isTrueSettingValue(settings[SettingKeyChannelMonitorHideUserRanking]), AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", @@ -430,6 +432,9 @@ type ChannelMonitorRuntime struct { // snapshots; otherwise the user handler strips them server-side. // Parsed fail-closed (only literal "true" enables). Admin always sees them. ShowQuota bool + // HideUserRanking: when true, user-facing V2 views hide the user ranking tab + // and the /users payload. Parsed fail-open (only literal "true" hides it). + HideUserRanking bool } // ActiveProbesAllowed reports whether V1 active provider probes may run. @@ -459,6 +464,7 @@ func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMo SettingKeyChannelMonitorDefaultIntervalSeconds, SettingKeyChannelMonitorHideThroughput, SettingKeyChannelMonitorShowQuota, + SettingKeyChannelMonitorHideUserRanking, }) if err != nil { return ChannelMonitorRuntime{ @@ -474,6 +480,7 @@ func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMo DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]), HideThroughput: !isFalseSettingValue(vals[SettingKeyChannelMonitorHideThroughput]), ShowQuota: vals[SettingKeyChannelMonitorShowQuota] == "true", + HideUserRanking: isTrueSettingValue(vals[SettingKeyChannelMonitorHideUserRanking]), } } @@ -618,14 +625,17 @@ type PublicSettingsInjectionPayload struct { ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` // ChannelMonitorShowQuota gates the user-facing quota/balance display on // monitors; fail-closed (absent/false = hidden). Admin UI always shows it. - ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` - AvailableChannelsEnabled bool `json:"available_channels_enabled"` - ModelPlazaEnabled bool `json:"model_plaza_enabled"` - ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` - PluginManagementEnabled bool `json:"plugin_management_enabled"` - AffiliateEnabled bool `json:"affiliate_enabled"` - RiskControlEnabled bool `json:"risk_control_enabled"` - AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` + // ChannelMonitorHideUserRanking hides the user ranking tab and /users payload + // from non-admin channel-monitor v2 viewers; default false (visible). + ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"` + ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` + AvailableChannelsEnabled bool `json:"available_channels_enabled"` + ModelPlazaEnabled bool `json:"model_plaza_enabled"` + ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"` + PluginManagementEnabled bool `json:"plugin_management_enabled"` + AffiliateEnabled bool `json:"affiliate_enabled"` + RiskControlEnabled bool `json:"risk_control_enabled"` + AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"` } // GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection. @@ -700,6 +710,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput, ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota, + ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, ModelPlazaEnabled: settings.ModelPlazaEnabled, ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth, diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 3fa0d40426..316df8ffda 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -139,6 +139,21 @@ func TestSettingService_ChannelMonitorShowQuotaFailsClosed(t *testing.T) { } } +func TestSettingService_ChannelMonitorHideUserRankingDefaultsToVisible(t *testing.T) { + missing := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetChannelMonitorRuntime(context.Background()) + require.False(t, missing.HideUserRanking) + public, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetPublicSettings(context.Background()) + require.NoError(t, err) + require.False(t, public.ChannelMonitorHideUserRanking) + + for _, value := range []string{"true", "1", "on", "enabled"} { + runtime := NewSettingService(&settingPublicRepoStub{values: map[string]string{ + SettingKeyChannelMonitorHideUserRanking: value, + }}, &config.Config{}).GetChannelMonitorRuntime(context.Background()) + require.True(t, runtime.HideUserRanking, "value=%q", value) + } +} + func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t *testing.T) { repo := &settingPublicRepoStub{ values: map[string]string{ diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index 8928a63534..1a4793aa55 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -418,6 +418,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting } updates[SettingKeyChannelMonitorHideThroughput] = strconv.FormatBool(settings.ChannelMonitorHideThroughput) updates[SettingKeyChannelMonitorShowQuota] = strconv.FormatBool(settings.ChannelMonitorShowQuota) + updates[SettingKeyChannelMonitorHideUserRanking] = strconv.FormatBool(settings.ChannelMonitorHideUserRanking) // Grok model mapping policy if v := strings.TrimSpace(settings.GrokDefaultTextModel); v != "" { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 002560e822..8216535309 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -202,6 +202,7 @@ type SystemSettings struct { ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` + ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"` // Grok model mapping policy (admin settings; empty mapping falls back to these). GrokDefaultTextModel string `json:"grok_default_text_model"` @@ -382,6 +383,7 @@ type PublicSettings struct { ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"` ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"` + ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"` // Grok model mapping policy (admin settings). GrokDefaultTextModel string `json:"grok_default_text_model"` @@ -669,15 +671,17 @@ func DefaultBetaPolicySettings() *BetaPolicySettings { // OpenAI Fast Policy 策略常量 // OpenAI 的 "fast 模式" 通过请求体中的 service_tier 字段识别: // - "priority"(客户端可传 "fast",归一化为 "priority"):fast 模式 +// - "ultrafast":Codex/API 的 Ultrafast 档位 // - "flex":低优先级模式 // - 省略:normal 默认 // // 本策略复用 BetaPolicyAction*/BetaPolicyScope* 常量语义,只是匹配键从 // anthropic-beta header 换成 body 的 service_tier 字段。 const ( - OpenAIFastTierAny = "all" // 匹配任意已识别的 service_tier - OpenAIFastTierPriority = "priority" // 仅匹配 fast(priority) - OpenAIFastTierFlex = "flex" // 仅匹配 flex + OpenAIFastTierAny = "all" // 匹配任意已识别的 service_tier + OpenAIFastTierPriority = "priority" // 仅匹配 fast(priority) + OpenAIFastTierUltrafast = "ultrafast" // 仅匹配 ultrafast + OpenAIFastTierFlex = "flex" // 仅匹配 flex // OpenAIFastPolicyActionForcePriority 会保留 service_tier 字段并强制写成 // priority,用于把 flex/auto/default/scale 等已识别 tier 收敛为 fast。 @@ -686,7 +690,7 @@ const ( // OpenAIFastPolicyRule 单条 OpenAI fast/flex 策略规则 type OpenAIFastPolicyRule struct { - ServiceTier string `json:"service_tier"` // "priority" | "flex" | "auto" | "default" | "scale" | "all" + ServiceTier string `json:"service_tier"` // "priority" | "ultrafast" | "flex" | "auto" | "default" | "scale" | "all" Action string `json:"action"` // "pass" | "filter" | "block" | "force_priority" Scope string `json:"scope"` // "all" | "oauth" | "apikey" | "bedrock" UserIDs []int64 `json:"user_ids,omitempty"` // 空=所有 Sub2API 用户;非空=仅指定 API Key 所属用户 diff --git a/backend/internal/service/sticky_session_test.go b/backend/internal/service/sticky_session_test.go index 11ace7bd8e..02369b19ef 100644 --- a/backend/internal/service/sticky_session_test.go +++ b/backend/internal/service/sticky_session_test.go @@ -122,8 +122,8 @@ func TestShouldClearStickySession(t *testing.T) { { name: "overloaded account", account: &Account{ - Status: StatusActive, - Schedulable: true, + Status: StatusActive, + Schedulable: true, OverloadUntil: &future, }, requestedModel: "", diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index 5ab821cf2c..145761adf1 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -1013,7 +1013,7 @@ func IsUpstreamBillingProbeIdentity(platform, accountType string) bool { } switch platform { case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok, - PlatformKimi, PlatformZhipu, PlatformDeepseek: + PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: return true default: return false diff --git a/backend/internal/service/upstream_billing_probe_multiplatform_test.go b/backend/internal/service/upstream_billing_probe_multiplatform_test.go index 0855c131a9..d76e868052 100644 --- a/backend/internal/service/upstream_billing_probe_multiplatform_test.go +++ b/backend/internal/service/upstream_billing_probe_multiplatform_test.go @@ -16,7 +16,7 @@ import ( func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) { for _, platform := range []string{ PlatformOpenAI, PlatformGrok, PlatformAnthropic, PlatformGemini, PlatformAntigravity, - PlatformKimi, PlatformZhipu, PlatformDeepseek, + PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, } { require.True(t, IsUpstreamBillingProbeIdentity(platform, AccountTypeAPIKey), platform) require.True(t, isUpstreamBillingProbeAccount(&Account{Platform: platform, Type: AccountTypeAPIKey}), platform) diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index d3d88c4269..4b106ddfe2 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -24,18 +24,20 @@ const ( modelsDevRegistryTTL = 6 * time.Hour UpstreamModelMetadataExtraKey = "upstream_model_metadata" UpstreamModelMetadataIncompleteCode = "upstream_model_metadata_incomplete" + UpstreamModelMetadataPartialCode = "upstream_model_metadata_partial" ) type UpstreamModelMetadata struct { - ID string `json:"id"` - DisplayName string `json:"display_name,omitempty"` - Description string `json:"description,omitempty"` - Reasoning *bool `json:"reasoning,omitempty"` - DefaultReasoningLevel string `json:"default_reasoning_level,omitempty"` - SupportedReasoningLevels []string `json:"supported_reasoning_levels,omitempty"` - InputModalities []string `json:"input_modalities,omitempty"` - ContextWindow int64 `json:"context_window,omitempty"` - MaxOutputTokens int64 `json:"max_output_tokens,omitempty"` + ID string `json:"id"` + DisplayName string `json:"display_name,omitempty"` + Description string `json:"description,omitempty"` + Reasoning *bool `json:"reasoning,omitempty"` + DefaultReasoningLevel string `json:"default_reasoning_level,omitempty"` + SupportedReasoningLevels []string `json:"supported_reasoning_levels,omitempty"` + InputModalities []string `json:"input_modalities,omitempty"` + ContextWindow int64 `json:"context_window,omitempty"` + MaxOutputTokens int64 `json:"max_output_tokens,omitempty"` + CodexToolCapabilities map[string]json.RawMessage `json:"codex_tool_capabilities,omitempty"` } type UpstreamModelMetadataSnapshot struct { @@ -197,9 +199,16 @@ func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, a // SyncUpstreamModelCatalog fetches the account's live model list, enriches // missing capability fields from the provider registry used by the upstream, -// and persists a normalized account snapshot when metadata is available. +// and persists a normalized account snapshot when complete metadata is available. +// +// Persistence is per-model: models with complete capability fields are saved even +// when other IDs in the same sync remain incomplete. An incomplete warning is +// still returned so admins can tell ID sync succeeded without a full capability +// snapshot. When no model is complete, the existing account snapshot is left +// untouched. func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, account *Account) (*UpstreamModelCatalog, error) { models, body, err := s.fetchUpstreamModelList(ctx, account) + liveListAvailable := err == nil if err != nil { configuredModels := configuredUpstreamModelsForCapabilitySync(account) if !upstreamModelListEndpointUnsupported(err) || len(configuredModels) == 0 { @@ -222,10 +231,18 @@ func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, accou } } + // Capability enrichment also covers concrete model_mapping targets. Admins may + // whitelist models that the live /models list omitted; those still need registry + // metadata so Codex catalogs can advertise reasoning and modalities. + enrichIDs := dedupeAndSortModelIDs(append(append([]string{}, models...), configuredUpstreamModelsForCapabilitySync(account)...)) + // Dedicated image/video generators are not Codex agent catalog entries and often + // omit context windows in public registries. Keep them out of completeness checks + // so they do not mask successful agent-model capability sync. + capabilityIDs := capabilitySyncModelIDs(enrichIDs) + source := "upstream" - metadataIncomplete := upstreamCatalogNeedsRegistry(models, catalog.Metadata) - if metadataIncomplete { - if registryMetadata, registryErr := s.fetchModelsDevMetadata(ctx, account, models); registryErr == nil { + if upstreamCatalogNeedsRegistry(capabilityIDs, catalog.Metadata) { + if registryMetadata, registryErr := s.fetchModelsDevMetadata(ctx, account, enrichIDs); registryErr == nil { for modelID, fallback := range registryMetadata { current := catalog.Metadata[modelID] merged, changed := mergeUpstreamModelMetadata(current, fallback) @@ -243,25 +260,59 @@ func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, accou } } - if upstreamCatalogNeedsRegistry(models, catalog.Metadata) { - catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ - Code: UpstreamModelMetadataIncompleteCode, - Message: "Model IDs were synced, but capability metadata is incomplete.", - }) - return catalog, nil + completeMetadata := completeUpstreamModelMetadataSubset(capabilityIDs, catalog.Metadata) + persistedCapabilities := false + if len(completeMetadata) > 0 && account != nil && account.ID > 0 && s.accountRepo != nil { + // Retain known metadata only for models still listed or explicitly mapped. + if previous := account.GetUpstreamModelMetadataSnapshot(); previous != nil { + retainedModels := capabilityIDs + if !liveListAvailable { + retainedModels = append([]string(nil), capabilityIDs...) + for modelID := range previous.Models { + retainedModels = append(retainedModels, modelID) + } + } + for _, modelID := range retainedModels { + old, exists := previous.Models[modelID] + if !exists { + continue + } + if entry, ok := completeMetadata[modelID]; ok { + if entry.CodexToolCapabilities == nil { + entry.CodexToolCapabilities = make(map[string]json.RawMessage) + } + applyCodexToolCapabilities(entry.CodexToolCapabilities, old.CodexToolCapabilities, false) + completeMetadata[modelID] = entry + } else { + completeMetadata[modelID] = old + } + } + } + snapshot := UpstreamModelMetadataSnapshot{ + Source: source, + SyncedAt: time.Now().UTC().Format(time.RFC3339), + Models: completeMetadata, + } + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{UpstreamModelMetadataExtraKey: snapshot}); err != nil { + return nil, newUpstreamModelSyncInternalError("Failed to save upstream model metadata", err) + } + account.SetUpstreamModelMetadataSnapshot(snapshot) + persistedCapabilities = true } - if len(catalog.Metadata) == 0 || account == nil || account.ID <= 0 || s.accountRepo == nil { - return catalog, nil + + if upstreamCatalogNeedsRegistry(capabilityIDs, catalog.Metadata) { + if persistedCapabilities { + catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ + Code: UpstreamModelMetadataPartialCode, + Message: "Some model capabilities were saved; remaining models are still incomplete.", + }) + } else { + catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ + Code: UpstreamModelMetadataIncompleteCode, + Message: "Model IDs were synced, but capability metadata is incomplete.", + }) + } } - snapshot := UpstreamModelMetadataSnapshot{ - Source: source, - SyncedAt: time.Now().UTC().Format(time.RFC3339), - Models: catalog.Metadata, - } - if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{UpstreamModelMetadataExtraKey: snapshot}); err != nil { - return nil, newUpstreamModelSyncInternalError("Failed to save upstream model metadata", err) - } - account.SetUpstreamModelMetadataSnapshot(snapshot) return catalog, nil } @@ -293,6 +344,18 @@ func configuredUpstreamModelsForCapabilitySync(account *Account) []string { return dedupeAndSortModelIDs(models) } +func capabilitySyncModelIDs(modelIDs []string) []string { + filtered := make([]string, 0, len(modelIDs)) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if modelID == "" || isCodexDedicatedMediaModel(modelID) { + continue + } + filtered = append(filtered, modelID) + } + return filtered +} + func upstreamModelSyncAccountID(account *Account) int64 { if account == nil { return 0 @@ -311,13 +374,7 @@ func upstreamCatalogNeedsRegistry(models []string, metadata map[string]UpstreamM for _, modelID := range models { modelID = strings.TrimSpace(modelID) model, ok := metadata[modelID] - if !ok || !upstreamModelMetadataIsUseful(model) { - return true - } - if model.Reasoning == nil || len(model.InputModalities) == 0 || model.ContextWindow <= 0 { - return true - } - if *model.Reasoning && len(model.SupportedReasoningLevels) == 0 { + if !ok || !upstreamModelMetadataIsComplete(model) { return true } } @@ -330,10 +387,57 @@ func upstreamModelMetadataIsUseful(metadata UpstreamModelMetadata) bool { metadata.Reasoning != nil || len(metadata.SupportedReasoningLevels) > 0 || len(metadata.InputModalities) > 0 || + len(metadata.CodexToolCapabilities) > 0 || metadata.ContextWindow > 0 || metadata.MaxOutputTokens > 0 } +// upstreamModelMetadataIsComplete reports whether a snapshot entry is safe to +// persist and later prefer over local Codex name-based fallbacks. +func upstreamModelMetadataIsComplete(metadata UpstreamModelMetadata) bool { + if metadata.Reasoning == nil { + return false + } + if len(normalizeCodexInputModalities(metadata.InputModalities)) == 0 { + return false + } + if metadata.ContextWindow <= 0 { + return false + } + if *metadata.Reasoning && len(normalizeReasoningLevels(metadata.SupportedReasoningLevels)) == 0 { + return false + } + return true +} + +func completeUpstreamModelMetadataSubset( + modelIDs []string, + metadata map[string]UpstreamModelMetadata, +) map[string]UpstreamModelMetadata { + if len(metadata) == 0 { + return nil + } + complete := make(map[string]UpstreamModelMetadata) + for _, modelID := range modelIDs { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + entry, ok := metadata[modelID] + if !ok || !upstreamModelMetadataIsComplete(entry) { + continue + } + if strings.TrimSpace(entry.ID) == "" { + entry.ID = modelID + } + complete[modelID] = entry + } + if len(complete) == 0 { + return nil + } + return complete +} + func mergeUpstreamModelMetadata(primary, fallback UpstreamModelMetadata) (UpstreamModelMetadata, bool) { merged := primary changed := false @@ -529,6 +633,13 @@ func upstreamModelRegistryBaseURL(account *Account) string { } func matchModelsDevProvider(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) { + if provider, ok := matchModelsDevProviderByAPIURL(registry, accountBaseURL); ok { + return provider, true + } + return matchModelsDevProviderByKnownHost(registry, accountBaseURL) +} + +func matchModelsDevProviderByAPIURL(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) { accountBaseURL = normalizeModelRegistryBaseURL(accountBaseURL) if accountBaseURL == "" { return modelsDevProvider{}, false @@ -553,6 +664,44 @@ func matchModelsDevProvider(registry map[string]modelsDevProvider, accountBaseUR return best, bestScore >= 0 } +// matchModelsDevProviderByKnownHost covers first-party hosts whose models.dev +// entries omit the `api` field (notably the official OpenAI provider). Custom +// compatible gateways must still match by API URL so same-named models are not +// cross-attributed across vendors. +func matchModelsDevProviderByKnownHost(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) { + host := modelRegistryHostname(accountBaseURL) + if host == "" { + return modelsDevProvider{}, false + } + providerID := "" + switch host { + case "api.openai.com", "chatgpt.com": + providerID = "openai" + default: + return modelsDevProvider{}, false + } + provider, ok := registry[providerID] + if !ok || len(provider.Models) == 0 { + return modelsDevProvider{}, false + } + if strings.TrimSpace(provider.ID) == "" { + provider.ID = providerID + } + return provider, true +} + +func modelRegistryHostname(raw string) string { + normalized := normalizeModelRegistryBaseURL(raw) + if normalized == "" { + return "" + } + parsed, err := url.Parse(normalized) + if err != nil { + return "" + } + return strings.ToLower(strings.TrimSpace(parsed.Hostname())) +} + func normalizeModelRegistryBaseURL(raw string) string { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { @@ -791,7 +940,9 @@ func (s *AccountTestService) buildAnthropicUpstreamModelsRequest(ctx context.Con if authHeaderName != "" { req.Header.Set(authHeaderName, authHeaderValue) } else { - setAnthropicAPIKeyAuthHeader(req.Header, account, apiKeyAuthToken) + // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,其余保持 + // extra/default 行为。 + setAnthropicAPIKeyAuthHeader(req.Header, account, apiKeyAuthToken, normalizedBaseURL) } // 账号级请求头覆写:模型列表探测与真实转发保持一致的最终头 account.ApplyHeaderOverrides(req.Header) @@ -842,6 +993,12 @@ func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Contex if account.IsOpenAIOAuth() { return s.buildOpenAIOAuthUpstreamModelsRequest(ctx, account) } + return buildOpenAIAPIKeyModelsRequest(ctx, account, s.validateUpstreamBaseURL) +} + +// buildOpenAIAPIKeyModelsRequest is shared by admin discovery and public model +// listing. Codex content negotiation is intentionally absent from this request. +func buildOpenAIAPIKeyModelsRequest(ctx context.Context, account *Account, validateBaseURL func(string) (string, error)) (*http.Request, error) { if account.Type != AccountTypeAPIKey { return nil, newUpstreamModelSyncUnsupportedError( fmt.Sprintf("Unsupported OpenAI account type for upstream model sync: %s", account.Type), nil, @@ -858,7 +1015,7 @@ func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Contex if strings.TrimSpace(baseURL) == "" { baseURL = "https://api.openai.com" } - normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) + normalizedBaseURL, err := validateBaseURL(baseURL) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid OpenAI base URL", err) } @@ -1128,6 +1285,11 @@ func extractUpstreamModelCatalog(body []byte, grok bool) ([]string, map[string]U } models = append(models, modelID) entry := upstreamMetadataFromCapabilityEntry(modelID, capability) + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err == nil { + entry.CodexToolCapabilities = make(map[string]json.RawMessage) + applyCodexToolCapabilities(entry.CodexToolCapabilities, fields, true) + } if upstreamModelMetadataIsUseful(entry) { metadata[modelID] = entry } diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index b358406b8b..e0e9f263ec 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -545,8 +545,12 @@ func TestSyncUpstreamModelCatalogUsesConfiguredModelsWhenListEndpointUnsupported }, } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "old-live-model": {ID: "old-live-model", ContextWindow: 256000}, + }}) catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) require.NoError(t, err) + require.Contains(t, account.GetUpstreamModelMetadataSnapshot().Models, "old-live-model", "an unavailable model-list endpoint is not evidence of removal") require.Equal(t, []string{"glm-5.3"}, catalog.Models) require.Empty(t, catalog.Warnings) require.Len(t, upstream.requests, 2) @@ -795,6 +799,71 @@ func TestSyncUpstreamModelCatalogDoesNotPersistPartialMetadataWhenRegistryFails( require.Nil(t, repo.updates, "partial metadata must not replace a more complete persisted snapshot") } +// Scenario: 图片专用模型缺少 context 时,不阻止 agent 模型能力落库,也不误报整批失败。 +func TestSyncUpstreamModelCatalogIgnoresDedicatedMediaModelsForCompleteness(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[ + {"id":"gpt-6-astra","object":"model"}, + {"id":"gpt-image-2","object":"model"} + ]}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "openai": { + "id": "openai", + "models": { + "gpt-6-astra": { + "id": "gpt-6-astra", + "name": "GPT-6 Astra", + "reasoning": true, + "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}], + "modalities": {"input":["text","image"],"output":["text"]}, + "limit": {"context":1050000,"output":128000} + }, + "gpt-image-2": { + "id": "gpt-image-2", + "name": "gpt-image-2", + "reasoning": false, + "modalities": {"input":["text","image"],"output":["image"]}, + "limit": {"context":0,"output":0} + } + } + } + }`)), + }, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 113, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", + "model_mapping": map[string]any{ + "gpt-6-astra": "gpt-6-astra", + "gpt-image-2": "gpt-image-2", + }, + }, + }) + require.NoError(t, err) + require.Empty(t, catalog.Warnings, "media generators must not keep agent capability sync in a failed state") + require.NotNil(t, repo.updates) + + encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey]) + require.NoError(t, err) + var snapshot UpstreamModelMetadataSnapshot + require.NoError(t, json.Unmarshal(encoded, &snapshot)) + require.Contains(t, snapshot.Models, "gpt-6-astra") + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, snapshot.Models["gpt-6-astra"].SupportedReasoningLevels) + require.NotContains(t, snapshot.Models, "gpt-image-2") +} + func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) { t.Parallel() @@ -820,101 +889,299 @@ func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) { require.Contains(t, err.Error(), "response exceeds 8 bytes") } -func TestFetchUpstreamSupportedModelsParsesGrokAPIKeyResponse(t *testing.T) { +func TestMatchModelsDevProviderFallsBackToOpenAIProviderWithoutAPIField(t *testing.T) { t.Parallel() - upstream := &httpUpstreamRecorder{resp: &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"grok-4.5"},{"id":"grok-4.5"},{"id":"grok-imagine"}]}`)), - }} - svc := &AccountTestService{ - httpUpstream: upstream, - cfg: upstreamModelSyncTestConfig(), + registry := map[string]modelsDevProvider{ + "openai": { + ID: "openai", + Name: "OpenAI", + // Official models.dev entry currently omits `api`. + Models: map[string]modelsDevModel{ + "gpt-6-astra": {ID: "gpt-6-astra", Name: "GPT-6 Astra"}, + }, + }, + "opencode": { + ID: "opencode", + API: "https://opencode.ai/zen/v1", + Models: map[string]modelsDevModel{ + "x-preview-f-free": {ID: "x-preview-f-free", Name: "Ox"}, + }, + }, } - models, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{ - ID: 9, - Platform: PlatformGrok, - Type: AccountTypeAPIKey, + for _, baseURL := range []string{ + "https://api.openai.com", + "https://api.openai.com/v1", + "https://chatgpt.com/backend-api/codex", + } { + provider, ok := matchModelsDevProvider(registry, baseURL) + require.True(t, ok, baseURL) + require.Equal(t, "openai", provider.ID, baseURL) + require.Contains(t, provider.Models, "gpt-6-astra", baseURL) + } + + _, ok := matchModelsDevProvider(registry, "https://compatible.example/v1") + require.False(t, ok, "custom hosts must not inherit the official OpenAI provider by name") + + provider, ok := matchModelsDevProvider(registry, "https://opencode.ai/zen/v1") + require.True(t, ok) + require.Equal(t, "opencode", provider.ID) +} + +// Scenario: 官方 OpenAI ID-only /models + 无 api 字段的 models.dev openai 条目仍能补齐并落库。 +func TestSyncUpstreamModelCatalogEnrichesOfficialOpenAIHostWithoutRegistryAPIField(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[ + {"id":"gpt-6-astra","object":"model"}, + {"id":"gpt-5.6-sol","object":"model"} + ]}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "openai": { + "id": "openai", + "name": "OpenAI", + "models": { + "gpt-6-astra": { + "id": "gpt-6-astra", + "name": "GPT-6 Astra", + "description": "OpenAI GPT-6 Astra", + "reasoning": true, + "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}], + "modalities": {"input":["text","image","pdf"],"output":["text"]}, + "limit": {"context":1050000,"output":128000} + }, + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "reasoning": true, + "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}], + "modalities": {"input":["text","image"],"output":["text"]}, + "limit": {"context":1050000,"output":128000} + } + } + } + }`)), + }, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 110, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ - "api_key": "xai-key", - "base_url": "https://xai.example.com/v1", + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", }, }) require.NoError(t, err) - require.Equal(t, []string{"grok-4.5", "grok-imagine"}, models) - require.Equal(t, "https://xai.example.com/v1/models", upstream.lastReq.URL.String()) - require.Equal(t, "Bearer xai-key", upstream.lastReq.Header.Get("Authorization")) -} + require.Empty(t, catalog.Warnings) + require.Equal(t, []string{"gpt-5.6-sol", "gpt-6-astra"}, catalog.Models) -func TestFetchUpstreamSupportedModelsParsesGrokOAuthResponse(t *testing.T) { - t.Parallel() + astra := catalog.Metadata["gpt-6-astra"] + require.Equal(t, "GPT-6 Astra", astra.DisplayName) + require.NotNil(t, astra.Reasoning) + require.True(t, *astra.Reasoning) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, astra.SupportedReasoningLevels) + require.Equal(t, []string{"text", "image"}, astra.InputModalities) + require.Equal(t, int64(1_050_000), astra.ContextWindow) - upstream := &httpUpstreamRecorder{resp: &http.Response{ - StatusCode: http.StatusOK, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(`{"data":[{"model":"grok-4.5"},{"model":"grok-4.5"},{"modelId":"grok-build-0.1"}]}`)), - }} - svc := &AccountTestService{ - httpUpstream: upstream, - cfg: upstreamModelSyncTestConfig(), - grokTokenProvider: NewGrokTokenProvider(nil, nil), - } - - models, err := svc.FetchUpstreamSupportedModels(context.Background(), grokOAuthModelSyncTestAccount("")) + require.NotNil(t, repo.updates) + encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey]) require.NoError(t, err) - require.Equal(t, []string{"grok-4.5", "grok-build-0.1"}, models) - require.Equal(t, "https://cli-chat-proxy.grok.com/v1/models", upstream.lastReq.URL.String()) - require.Equal(t, "Bearer oauth-access-token", upstream.lastReq.Header.Get("Authorization")) - require.Equal(t, grokCLIVersion, upstream.lastReq.Header.Get("X-Grok-Client-Version")) - require.Equal(t, "interactive", upstream.lastReq.Header.Get("X-Grok-Client-Mode")) - require.Equal(t, "grok-user-id", upstream.lastReq.Header.Get("X-UserID")) - require.Equal(t, "grok-user@example.com", upstream.lastReq.Header.Get("X-Email")) + var snapshot UpstreamModelMetadataSnapshot + require.NoError(t, json.Unmarshal(encoded, &snapshot)) + require.Equal(t, "models.dev", snapshot.Source) + require.Equal(t, astra, snapshot.Models["gpt-6-astra"]) } -func TestBuildUpstreamModelsRequestGrokOAuthDoesNotSendIdentityToCustomBase(t *testing.T) { - t.Parallel() - - svc := &AccountTestService{ - cfg: upstreamModelSyncTestConfig(), - grokTokenProvider: NewGrokTokenProvider(nil, nil), - } - req, err := svc.buildUpstreamModelsRequest(context.Background(), grokOAuthModelSyncTestAccount("https://relay.example/v1")) - require.NoError(t, err) - require.Equal(t, "https://relay.example/v1/models", req.URL.String()) - require.Empty(t, req.Header.Get("X-UserID")) - require.Empty(t, req.Header.Get("X-Email")) -} - -func TestFetchUpstreamSupportedModelsDoesNotExposeUpstreamBody(t *testing.T) { - t.Parallel() - - upstream := &httpUpstreamRecorder{resp: &http.Response{ - StatusCode: http.StatusBadGateway, - Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: io.NopCloser(strings.NewReader(`{"error":"SECRET_TOKEN should not be exposed"}`)), +// Scenario: 同一批同步里部分模型能力完整时仍落库完整条目,并对不完整条目告警。 +func TestSyncUpstreamModelCatalogPersistsCompleteModelsWhenSomeRemainIncomplete(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"models":[ + { + "id":"complete-model", + "display_name":"Complete", + "reasoning":true, + "supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}], + "input_modalities":["text","image"], + "context_window":128000 + }, + { + "id":"incomplete-model", + "display_name":"Incomplete Only" + } + ]}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "provider": { + "id": "provider", + "api": "https://provider.example/v1", + "models": {} + } + }`)), + }, }} - svc := &AccountTestService{ - httpUpstream: upstream, - cfg: upstreamModelSyncTestConfig(), - } + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} - _, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{ - ID: 8, - Platform: PlatformOpenAI, - Type: AccountTypeAPIKey, + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 111, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"}, + }) + require.NoError(t, err) + require.Equal(t, []string{"complete-model", "incomplete-model"}, catalog.Models) + require.Equal(t, UpstreamModelMetadataPartialCode, catalog.Warnings[0].Code) + require.NotNil(t, repo.updates) + + encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey]) + require.NoError(t, err) + var snapshot UpstreamModelMetadataSnapshot + require.NoError(t, json.Unmarshal(encoded, &snapshot)) + require.Contains(t, snapshot.Models, "complete-model") + require.NotContains(t, snapshot.Models, "incomplete-model") + require.Equal(t, []string{"low", "high"}, snapshot.Models["complete-model"].SupportedReasoningLevels) +} + +// Scenario: 上游清单未包含管理员 mapping 目标时,仍按 mapping 补齐并写入快照。 +func TestSyncUpstreamModelCatalogEnrichesConfiguredMappingModelsMissingFromUpstreamList(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"gpt-5.6-sol","object":"model"}]}`)), + }, + { + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "openai": { + "id": "openai", + "models": { + "gpt-5.6-sol": { + "id": "gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "reasoning": true, + "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}], + "modalities": {"input":["text","image"],"output":["text"]}, + "limit": {"context":1050000,"output":128000} + }, + "gpt-6-astra": { + "id": "gpt-6-astra", + "name": "GPT-6 Astra", + "reasoning": true, + "reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}], + "modalities": {"input":["text","image"],"output":["text"]}, + "limit": {"context":1050000,"output":128000} + } + } + } + }`)), + }, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{ + ID: 112, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ - "api_key": "openai-key", - "base_url": "https://openai.example.com/v1", + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", + "model_mapping": map[string]any{ + "gpt-6-astra": "gpt-6-astra", + }, }, }) - require.Error(t, err) - require.NotContains(t, err.Error(), "SECRET_TOKEN") + require.NoError(t, err) + require.Equal(t, []string{"gpt-5.6-sol"}, catalog.Models, "UI model list stays upstream-only") + require.Empty(t, catalog.Warnings) + require.Contains(t, catalog.Metadata, "gpt-6-astra") + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, catalog.Metadata["gpt-6-astra"].SupportedReasoningLevels) - var syncErr *UpstreamModelSyncError - require.True(t, errors.As(err, &syncErr)) - require.Equal(t, UpstreamModelSyncErrorUpstream, syncErr.Kind) - require.NotContains(t, syncErr.SafeMessage(), "SECRET_TOKEN") - require.Contains(t, syncErr.SafeMessage(), "HTTP 502") + encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey]) + require.NoError(t, err) + var snapshot UpstreamModelMetadataSnapshot + require.NoError(t, json.Unmarshal(encoded, &snapshot)) + require.Contains(t, snapshot.Models, "gpt-5.6-sol") + require.Contains(t, snapshot.Models, "gpt-6-astra") +} + +func TestSyncUpstreamModelCatalogAstraPartialRefreshPreservesKnownCapabilities(t *testing.T) { + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":[ + {"id":"gpt-6-astra","supports_search_tool":false,"apply_patch_tool_type":null}, + {"id":"still-listed"},{"id":"gpt-image-2"} + ]}`))}, + {StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"openai":{"id":"openai","models":{ + "gpt-6-astra":{"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"modalities":{"input":["text","image"]},"limit":{"context":1050000,"output":128000}}, + "mapped-only":{"reasoning":false,"modalities":{"input":["text"]},"limit":{"context":64000}}, + "gpt-image-2":{"reasoning":false,"modalities":{"input":["text","image"]},"limit":{"context":0}} + }}}`))}, + }} + repo := &upstreamModelMetadataRepoStub{} + svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + account := &Account{ID: 114, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "test", "base_url": "https://api.openai.com/v1", + "model_mapping": map[string]any{"public-model": "mapped-only"}}, + } + old := UpstreamModelMetadata{ID: "still-listed", ContextWindow: 256000} + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{ + "still-listed": old, "removed": {ID: "removed", ContextWindow: 128000}, + "gpt-6-astra": {ID: "gpt-6-astra", CodexToolCapabilities: map[string]json.RawMessage{ + "supports_search_tool": json.RawMessage("true"), "apply_patch_tool_type": json.RawMessage(`"freeform"`), + "comp_hash": json.RawMessage(`"3000"`), + }}, + }}) + + catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account) + require.NoError(t, err) + require.Equal(t, UpstreamModelMetadataPartialCode, catalog.Warnings[0].Code) + require.NotContains(t, catalog.Models, "mapped-only", "capability enrichment must not change discovery") + snapshot := account.GetUpstreamModelMetadataSnapshot() + require.Equal(t, old, snapshot.Models["still-listed"]) + require.NotContains(t, snapshot.Models, "removed") + require.NotContains(t, snapshot.Models, "gpt-image-2") + require.Contains(t, snapshot.Models, "mapped-only") + astra := snapshot.Models["gpt-6-astra"] + require.Equal(t, int64(1050000), astra.ContextWindow) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, astra.SupportedReasoningLevels) + require.JSONEq(t, "false", string(astra.CodexToolCapabilities["supports_search_tool"])) + require.JSONEq(t, "null", string(astra.CodexToolCapabilities["apply_patch_tool_type"])) + require.JSONEq(t, `"3000"`, string(astra.CodexToolCapabilities["comp_hash"])) + require.NotNil(t, repo.updates) +} + +func TestMatchModelsDevProviderOfficialHostsWithoutAPI(t *testing.T) { + registry := map[string]modelsDevProvider{ + "openai": {ID: "openai", Models: map[string]modelsDevModel{"gpt-6-astra": {ID: "gpt-6-astra"}}}, + "relay": {ID: "relay", API: "https://relay.example/v1"}, + } + for _, baseURL := range []string{"https://api.openai.com/v1", "https://chatgpt.com/backend-api/codex"} { + provider, ok := matchModelsDevProvider(registry, baseURL) + require.True(t, ok) + require.Equal(t, "openai", provider.ID) + } + for _, baseURL := range []string{"https://api.openai.com.evil.example/v1", "https://unknown.example/v1"} { + _, ok := matchModelsDevProvider(registry, baseURL) + require.False(t, ok) + } + provider, ok := matchModelsDevProvider(registry, "https://relay.example/v1") + require.True(t, ok) + require.Equal(t, "relay", provider.ID) + metadata := map[string]UpstreamModelMetadata{"gpt-6-astra": { + Reasoning: new(bool), InputModalities: []string{"text"}, ContextWindow: 1050000, + }} + require.False(t, upstreamCatalogNeedsRegistry(capabilitySyncModelIDs([]string{"gpt-6-astra", "gpt-image-2"}), metadata)) } diff --git a/backend/internal/service/upstream_request_id.go b/backend/internal/service/upstream_request_id.go new file mode 100644 index 0000000000..0e4fcb6970 --- /dev/null +++ b/backend/internal/service/upstream_request_id.go @@ -0,0 +1,91 @@ +package service + +import ( + "net/http" + "strings" + "unicode/utf8" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "golang.org/x/net/http/httpguts" +) + +// AccountExtraUpstreamRequestIDHeader 是账户 extra 中的键,值为直接上游声明请求标识的响应头名。 +// 未指定时不记录上游请求标识。 +const AccountExtraUpstreamRequestIDHeader = "upstream_request_id_header" + +const ( + maxUpstreamRequestIDHeaderNameLen = 64 + // maxUsageUpstreamRequestIDLen 与 usage_logs.upstream_request_id VARCHAR(128) 对齐。 + maxUsageUpstreamRequestIDLen = 128 +) + +// UpstreamRequestIDHeaderName 返回账户指定的上游请求标识头名,未指定时为空串。 +func UpstreamRequestIDHeaderName(account *Account) string { + if account == nil { + return "" + } + return strings.TrimSpace(account.GetExtraString(AccountExtraUpstreamRequestIDHeader)) +} + +// UpstreamRequestIDFromHeaders 从直接上游的响应头解析请求标识。 +// 只读账户指定的头;账户未指定头名时恒为空串。 +func UpstreamRequestIDFromHeaders(account *Account, h http.Header) string { + if len(h) == 0 { + return "" + } + name := UpstreamRequestIDHeaderName(account) + if name == "" { + return "" + } + return strings.TrimSpace(h.Get(name)) +} + +// usageUpstreamRequestIDPtr 生成落库到 usage_logs.upstream_request_id 的值。 +// WS 轮次没有 HTTP 响应头,保持 nil;超长时截断到列宽而不是让整条用量行失败。 +func usageUpstreamRequestIDPtr(account *Account, h http.Header, wsMode bool) *string { + if wsMode { + return nil + } + id := UpstreamRequestIDFromHeaders(account, h) + if id == "" { + return nil + } + if len(id) > maxUsageUpstreamRequestIDLen { + id = id[:maxUsageUpstreamRequestIDLen] + for len(id) > 0 && !utf8.ValidString(id) { + id = id[:len(id)-1] + } + } + if id == "" { + return nil + } + return &id +} + +// ValidateUpstreamRequestIDHeaderExtra 校验并规范化 extra 中的上游请求标识头名: +// 必须是合法的 HTTP 头字段名且不超过 64 字节;空白值视为未指定并从 extra 中移除。 +func ValidateUpstreamRequestIDHeaderExtra(extra map[string]any) error { + if extra == nil { + return nil + } + raw, ok := extra[AccountExtraUpstreamRequestIDHeader] + if !ok || raw == nil { + return nil + } + name, ok := raw.(string) + if !ok { + return infraerrors.BadRequest("INVALID_UPSTREAM_REQUEST_ID_HEADER", + "upstream_request_id_header must be a string") + } + name = strings.TrimSpace(name) + if name == "" { + delete(extra, AccountExtraUpstreamRequestIDHeader) + return nil + } + if len(name) > maxUpstreamRequestIDHeaderNameLen || !httpguts.ValidHeaderFieldName(name) { + return infraerrors.BadRequest("INVALID_UPSTREAM_REQUEST_ID_HEADER", + "upstream_request_id_header must be a valid HTTP header name of at most 64 bytes") + } + extra[AccountExtraUpstreamRequestIDHeader] = name + return nil +} diff --git a/backend/internal/service/upstream_request_id_test.go b/backend/internal/service/upstream_request_id_test.go new file mode 100644 index 0000000000..bdeebaf198 --- /dev/null +++ b/backend/internal/service/upstream_request_id_test.go @@ -0,0 +1,78 @@ +package service + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUpstreamRequestIDFromHeaders_UnconfiguredAccountRecordsNothing(t *testing.T) { + h := http.Header{} + h.Set("X-Client-Request-ID", "sub2api-client") + h.Set("X-Request-ID", "sub2api-local") + h.Set("X-Oneapi-Request-Id", "oneapi-1") + h.Set("Request-Id", "req_official") + h.Set("xai-request-id", "xai-1") + h.Set("x-goog-request-id", "goog-1") + + require.Equal(t, "", UpstreamRequestIDFromHeaders(nil, h)) + for _, platform := range []string{PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok} { + require.Equal(t, "", UpstreamRequestIDFromHeaders(&Account{Platform: platform}, h), platform) + } + blank := &Account{Platform: PlatformOpenAI, Extra: map[string]any{AccountExtraUpstreamRequestIDHeader: " "}} + require.Equal(t, "", UpstreamRequestIDFromHeaders(blank, h)) +} + +func TestUpstreamRequestIDFromHeaders_ReadsOnlyConfiguredHeader(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{AccountExtraUpstreamRequestIDHeader: " x-oneapi-request-id "}, + } + h := http.Header{} + h.Set("X-Request-ID", "passthrough-from-real-upstream") + require.Equal(t, "", UpstreamRequestIDFromHeaders(account, h)) + + h.Set("X-Oneapi-Request-Id", " oneapi-2 ") + require.Equal(t, "oneapi-2", UpstreamRequestIDFromHeaders(account, h)) + require.Equal(t, "", UpstreamRequestIDFromHeaders(account, nil)) + + official := &Account{Platform: PlatformAnthropic, Extra: map[string]any{AccountExtraUpstreamRequestIDHeader: "request-id"}} + only := http.Header{} + only.Set("Request-Id", "req_official") + require.Equal(t, "req_official", UpstreamRequestIDFromHeaders(official, only)) +} + +func TestUsageUpstreamRequestIDPtr(t *testing.T) { + account := &Account{Extra: map[string]any{AccountExtraUpstreamRequestIDHeader: "X-Request-ID"}} + h := http.Header{} + h.Set("X-Request-ID", strings.Repeat("a", 200)) + require.Nil(t, usageUpstreamRequestIDPtr(account, h, true)) + require.Nil(t, usageUpstreamRequestIDPtr(account, http.Header{}, false)) + require.Nil(t, usageUpstreamRequestIDPtr(nil, h, false)) + require.Nil(t, usageUpstreamRequestIDPtr(&Account{}, h, false)) + + got := usageUpstreamRequestIDPtr(account, h, false) + require.NotNil(t, got) + require.Len(t, *got, maxUsageUpstreamRequestIDLen) +} + +func TestValidateUpstreamRequestIDHeaderExtra(t *testing.T) { + require.NoError(t, ValidateUpstreamRequestIDHeaderExtra(nil)) + require.NoError(t, ValidateUpstreamRequestIDHeaderExtra(map[string]any{})) + + blank := map[string]any{AccountExtraUpstreamRequestIDHeader: " "} + require.NoError(t, ValidateUpstreamRequestIDHeaderExtra(blank)) + _, present := blank[AccountExtraUpstreamRequestIDHeader] + require.False(t, present, "blank header name must be removed") + + valid := map[string]any{AccountExtraUpstreamRequestIDHeader: " X-Oneapi-Request-Id "} + require.NoError(t, ValidateUpstreamRequestIDHeaderExtra(valid)) + require.Equal(t, "X-Oneapi-Request-Id", valid[AccountExtraUpstreamRequestIDHeader]) + + require.Error(t, ValidateUpstreamRequestIDHeaderExtra(map[string]any{AccountExtraUpstreamRequestIDHeader: 1})) + require.Error(t, ValidateUpstreamRequestIDHeaderExtra(map[string]any{AccountExtraUpstreamRequestIDHeader: "X Request Id"})) + require.Error(t, ValidateUpstreamRequestIDHeaderExtra(map[string]any{AccountExtraUpstreamRequestIDHeader: "X-Request-Id:"})) + require.Error(t, ValidateUpstreamRequestIDHeaderExtra(map[string]any{AccountExtraUpstreamRequestIDHeader: strings.Repeat("x", 65)})) +} diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go index e20e9d56ca..64df9e2695 100644 --- a/backend/internal/service/usage_log.go +++ b/backend/internal/service/usage_log.go @@ -186,6 +186,10 @@ type UsageLog struct { // (e.g. the session_id / X-Session-Id headers). Nil when the client sent no // valid session header. It is never derived from prompt_cache_key or content. SessionID *string + // UpstreamRequestID 是直接上游在响应头中声明的请求标识,只读账户 + // extra.upstream_request_id_header 指定的头;账户未指定头名、WS 轮次 + // 与上游没有该头的路径为 nil。 + UpstreamRequestID *string // Cache TTL Override 标记(管理员强制替换了缓存 TTL 计费) CacheTTLOverridden bool diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 6e708de1d9..e7ff2dc4d3 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -267,6 +267,7 @@ func ProvideAccountTestService( tlsFPProfileService, ) service.agentIdentityWS = openAIGatewayService + service.SetOpenAIGatewayService(openAIGatewayService) service.SetSettingService(settingService) service.SetPluginManager(pluginManager) return service diff --git a/backend/internal/testutil/stubs.go b/backend/internal/testutil/stubs.go index 339d57d867..c9b37efb9f 100644 --- a/backend/internal/testutil/stubs.go +++ b/backend/internal/testutil/stubs.go @@ -139,6 +139,9 @@ func (c StubSessionLimitCache) RegisterSession(_ context.Context, _ int64, _ str func (c StubSessionLimitCache) RefreshSession(_ context.Context, _ int64, _ string, _ time.Duration) error { return nil } +func (c StubSessionLimitCache) UnregisterSession(_ context.Context, _ int64, _ string) error { + return nil +} func (c StubSessionLimitCache) GetActiveSessionCount(_ context.Context, _ int64) (int, error) { return 0, nil } diff --git a/backend/internal/util/urlvalidator/validator.go b/backend/internal/util/urlvalidator/validator.go index fc2b9bc4d7..882487b73b 100644 --- a/backend/internal/util/urlvalidator/validator.go +++ b/backend/internal/util/urlvalidator/validator.go @@ -162,6 +162,12 @@ func isAllowedHost(host string, allowlist []string) bool { return false } +// IsBlockedHost 报告 host 是否为 localhost、*.localhost,或回环、私网、链路本地、未指定地址的字面量 IP。 +// 只判断字面量,域名的解析结果由 ValidateResolvedIP 校验。 +func IsBlockedHost(host string) bool { + return isBlockedHost(strings.ToLower(strings.TrimSpace(host))) +} + func isBlockedHost(host string) bool { if host == "localhost" || strings.HasSuffix(host, ".localhost") { return true diff --git a/backend/internal/util/urlvalidator/validator_test.go b/backend/internal/util/urlvalidator/validator_test.go index bec9bb2110..5f401df170 100644 --- a/backend/internal/util/urlvalidator/validator_test.go +++ b/backend/internal/util/urlvalidator/validator_test.go @@ -73,3 +73,20 @@ func TestValidateHTTPURL(t *testing.T) { t.Fatalf("expected localhost to be blocked when allow_private_hosts is false") } } + +func TestIsBlockedHost(t *testing.T) { + for _, host := range []string{ + "localhost", "LOCALHOST", "foo.localhost", " 127.0.0.1 ", + "127.0.0.1", "::1", "10.0.0.5", "172.16.0.1", "192.168.1.1", + "169.254.169.254", "0.0.0.0", "::", "fe80::1", "fc00::1", "::ffff:127.0.0.1", + } { + if !IsBlockedHost(host) { + t.Fatalf("expected %q to be blocked", host) + } + } + for _, host := range []string{"example.com", "cdn.example.com", "93.184.216.34", "8.8.8.8", "2606:4700:4700::1111", ""} { + if IsBlockedHost(host) { + t.Fatalf("expected %q to be allowed", host) + } + } +} diff --git a/backend/migrations/232_add_usage_log_upstream_request_id.sql b/backend/migrations/232_add_usage_log_upstream_request_id.sql new file mode 100644 index 0000000000..917739c019 --- /dev/null +++ b/backend/migrations/232_add_usage_log_upstream_request_id.sql @@ -0,0 +1,5 @@ +-- usage_logs.upstream_request_id 记录直接上游在响应头中声明的请求标识, +-- 头名由账户 extra.upstream_request_id_header 指定,未指定时按默认识别链取值。 +-- NULL 表示历史行、WS 轮次或该路径没有上游请求标识。 +ALTER TABLE usage_logs + ADD COLUMN IF NOT EXISTS upstream_request_id VARCHAR(128); diff --git a/backend/migrations/233_add_usage_log_upstream_request_id_index_notx.sql b/backend/migrations/233_add_usage_log_upstream_request_id_index_notx.sql new file mode 100644 index 0000000000..8ebeeb2458 --- /dev/null +++ b/backend/migrations/233_add_usage_log_upstream_request_id_index_notx.sql @@ -0,0 +1,4 @@ +-- 只索引带上游请求标识的行;CONCURRENTLY 避免阻塞热表写入。 +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_usage_logs_upstream_request_id + ON usage_logs (upstream_request_id) + WHERE upstream_request_id IS NOT NULL; diff --git a/backend/migrations/234_channel_max_reasoning_effort_multiplier.sql b/backend/migrations/234_channel_max_reasoning_effort_multiplier.sql new file mode 100644 index 0000000000..07409f1162 --- /dev/null +++ b/backend/migrations/234_channel_max_reasoning_effort_multiplier.sql @@ -0,0 +1,11 @@ +ALTER TABLE channel_model_pricing + ADD COLUMN IF NOT EXISTS max_reasoning_effort_multiplier NUMERIC(10,4); + +COMMENT ON COLUMN channel_model_pricing.max_reasoning_effort_multiplier IS + 'Billing/quota multiplier applied when the forwarded reasoning effort is max'; + +ALTER TABLE channel_model_pricing + DROP CONSTRAINT IF EXISTS chk_channel_model_pricing_max_reasoning_effort_multiplier_positive; +ALTER TABLE channel_model_pricing + ADD CONSTRAINT chk_channel_model_pricing_max_reasoning_effort_multiplier_positive + CHECK (max_reasoning_effort_multiplier IS NULL OR max_reasoning_effort_multiplier > 0); diff --git a/backend/migrations/234_group_codex_models_manifest_config.sql b/backend/migrations/234_group_codex_models_manifest_config.sql new file mode 100644 index 0000000000..137a9424ef --- /dev/null +++ b/backend/migrations/234_group_codex_models_manifest_config.sql @@ -0,0 +1,5 @@ +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS codex_models_manifest_config JSONB NOT NULL DEFAULT '{}'::jsonb; + +COMMENT ON COLUMN groups.codex_models_manifest_config IS + 'Pinned-accounts Codex models manifest config for OpenAI groups: {"enabled":bool,"account_ids":[int64],"fallback_to_scheduler":bool}; when enabled the Codex /models manifest is fetched only from the pinned accounts'; diff --git a/backend/migrations/235_group_model_allowlist.sql b/backend/migrations/235_group_model_allowlist.sql new file mode 100644 index 0000000000..591b994b89 --- /dev/null +++ b/backend/migrations/235_group_model_allowlist.sql @@ -0,0 +1,22 @@ +-- 235: groups.models_list_config 更名为 model_allowlist,语义从「仅影响 /v1/models 展示」 +-- 升级为分组级模型白名单(同时约束模型列表接口与请求准入)。数据原样保留。 +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'groups' + AND column_name = 'models_list_config' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'groups' + AND column_name = 'model_allowlist' + ) THEN + ALTER TABLE groups RENAME COLUMN models_list_config TO model_allowlist; + END IF; +END +$$; + +COMMENT ON COLUMN groups.model_allowlist IS + 'Group model allowlist: constrains both model listing responses and request admission'; diff --git a/backend/migrations/236_group_model_allowlist_repair.sql b/backend/migrations/236_group_model_allowlist_repair.sql new file mode 100644 index 0000000000..0110225a06 --- /dev/null +++ b/backend/migrations/236_group_model_allowlist_repair.sql @@ -0,0 +1,52 @@ +-- 236: 收敛 groups 的模型白名单列,修复 235 未落地却已被记账的实例。 +-- +-- 235 只在「models_list_config 存在且 model_allowlist 不存在」时执行重命名, +-- 而迁移一旦写入 schema_migrations 就会按「文件名 + checksum」整份跳过,不再重跑。 +-- 因此只要数据库在 235 之后回到了旧结构——例如为了回滚到旧版本镜像而手工把列名 +-- 改回 models_list_config,或者用旧结构的备份做了部分恢复——应用依旧能正常启动, +-- 但每个关联 groups 的查询都会失败: +-- pq: column groups.model_allowlist does not exist +-- 表现为「API 密钥」「我的订阅」「管理端订阅列表」等页面加载失败(issue #6780)。 +-- +-- 本迁移可重放,并且用 regclass 解析表(跟随 search_path),不再假定 schema 为 public: +-- 1) 只有旧列 -> 重命名,数据原样保留; +-- 2) 两列并存 -> 新列仍是默认空值时,把旧列里的配置回填过来,旧列保留不动; +-- 3) 两列都没有 -> 按默认值补建新列。 +-- 结束后保证 groups.model_allowlist 一定存在,且为 NOT NULL DEFAULT '{}'。 +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_attribute + WHERE attrelid = 'groups'::regclass + AND attname = 'models_list_config' + AND NOT attisdropped + ) THEN + IF NOT EXISTS ( + SELECT 1 FROM pg_attribute + WHERE attrelid = 'groups'::regclass + AND attname = 'model_allowlist' + AND NOT attisdropped + ) THEN + ALTER TABLE groups RENAME COLUMN models_list_config TO model_allowlist; + ELSE + EXECUTE $backfill$ + UPDATE groups + SET model_allowlist = models_list_config + WHERE COALESCE(model_allowlist, '{}'::jsonb) = '{}'::jsonb + AND COALESCE(models_list_config, '{}'::jsonb) <> '{}'::jsonb + $backfill$; + END IF; + END IF; +END +$$; + +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS model_allowlist JSONB NOT NULL DEFAULT '{}'::jsonb; + +UPDATE groups SET model_allowlist = '{}'::jsonb WHERE model_allowlist IS NULL; + +ALTER TABLE groups ALTER COLUMN model_allowlist SET DEFAULT '{}'::jsonb; +ALTER TABLE groups ALTER COLUMN model_allowlist SET NOT NULL; + +COMMENT ON COLUMN groups.model_allowlist IS + 'Group model allowlist: constrains both model listing responses and request admission'; diff --git a/backend/migrations/237_add_minimax_platform.sql b/backend/migrations/237_add_minimax_platform.sql new file mode 100644 index 0000000000..308c331ebc --- /dev/null +++ b/backend/migrations/237_add_minimax_platform.sql @@ -0,0 +1,60 @@ +-- 把 MiniMax 加入国产供应商平台白名单: +-- 1. user_platform_quotas.platform CHECK +-- 2. composite_model_routes.target_platform CHECK +-- 3. channel_monitors / channel_monitor_request_templates.provider CHECK +-- +-- 与 224/226/227 同型:DROP IF EXISTS 后重建超集约束,存量行瞬时校验通过。 + +ALTER TABLE user_platform_quotas + DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check; + +ALTER TABLE user_platform_quotas + ADD CONSTRAINT user_platform_quotas_platform_check + CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', + 'kimi', 'zhipu', 'deepseek', 'minimax')); + +ALTER TABLE composite_model_routes + DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check; + +ALTER TABLE composite_model_routes + ADD CONSTRAINT composite_model_routes_target_platform_check + CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', + 'kimi', 'zhipu', 'deepseek', 'minimax')); + +DO $$ +DECLARE + monitor_constraint_def TEXT; + template_constraint_def TEXT; +BEGIN + SELECT pg_get_constraintdef(c.oid) + INTO monitor_constraint_def + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'channel_monitors' + AND c.conname = 'channel_monitors_provider_check'; + + IF monitor_constraint_def IS NULL OR position('minimax' IN monitor_constraint_def) = 0 THEN + ALTER TABLE channel_monitors + DROP CONSTRAINT IF EXISTS channel_monitors_provider_check; + ALTER TABLE channel_monitors + ADD CONSTRAINT channel_monitors_provider_check + CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok', + 'antigravity', 'kimi', 'zhipu', 'deepseek', 'minimax')); + END IF; + + SELECT pg_get_constraintdef(c.oid) + INTO template_constraint_def + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'channel_monitor_request_templates' + AND c.conname = 'channel_monitor_request_templates_provider_check'; + + IF template_constraint_def IS NULL OR position('minimax' IN template_constraint_def) = 0 THEN + ALTER TABLE channel_monitor_request_templates + DROP CONSTRAINT IF EXISTS channel_monitor_request_templates_provider_check; + ALTER TABLE channel_monitor_request_templates + ADD CONSTRAINT channel_monitor_request_templates_provider_check + CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok', + 'antigravity', 'kimi', 'zhipu', 'deepseek', 'minimax')); + END IF; +END $$; diff --git a/backend/migrations/group_model_allowlist_repair_migration_test.go b/backend/migrations/group_model_allowlist_repair_migration_test.go new file mode 100644 index 0000000000..1486243445 --- /dev/null +++ b/backend/migrations/group_model_allowlist_repair_migration_test.go @@ -0,0 +1,27 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGroupModelAllowlistRepairMigration(t *testing.T) { + content, err := FS.ReadFile("236_group_model_allowlist_repair.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + + // 三种残留状态都要收敛到 model_allowlist。 + require.Contains(t, sql, "ALTER TABLE groups RENAME COLUMN models_list_config TO model_allowlist") + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS model_allowlist JSONB NOT NULL DEFAULT '{}'::jsonb") + require.Contains(t, sql, "SET model_allowlist = models_list_config") + require.Contains(t, sql, "ALTER TABLE groups ALTER COLUMN model_allowlist SET NOT NULL") + require.Contains(t, sql, "COMMENT ON COLUMN groups.model_allowlist") + + // 235 用 table_schema = 'public' 判定列是否存在,而 ALTER TABLE 走的是 search_path; + // 修复迁移必须用 regclass 解析,两者才不会在非 public schema 上分叉。 + require.NotContains(t, sql, "table_schema = 'public'") + require.Contains(t, sql, "attrelid = 'groups'::regclass") +} diff --git a/backend/migrations/minimax_platform_migration_test.go b/backend/migrations/minimax_platform_migration_test.go new file mode 100644 index 0000000000..20cb0f773a --- /dev/null +++ b/backend/migrations/minimax_platform_migration_test.go @@ -0,0 +1,21 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMiniMaxPlatformMigration(t *testing.T) { + content, err := FS.ReadFile("237_add_minimax_platform.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, + "CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax'))") + require.Contains(t, sql, + "CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax'))") + require.Contains(t, sql, + "CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok', 'antigravity', 'kimi', 'zhipu', 'deepseek', 'minimax'))") +} diff --git a/deploy/.env.example b/deploy/.env.example index 76762e303b..2eaf8d09cb 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -36,6 +36,11 @@ APPLE_CONTAINER_SUB2API_IMAGE=weishaw/sub2api:latest APPLE_CONTAINER_POSTGRES_IMAGE=postgres:18-alpine APPLE_CONTAINER_REDIS_IMAGE=redis:8-alpine +# Optional fixed IPv4 CIDR for the private Apple container network. Leave empty +# to let Apple container select one. Set only when host tooling needs a stable +# network gateway, and choose a CIDR that does not overlap your LAN or VPN. +APPLE_CONTAINER_NETWORK_SUBNET= + # ----------------------------------------------------------------------------- # Logging Configuration # 日志配置 diff --git a/deploy/APPLE_CONTAINER.md b/deploy/APPLE_CONTAINER.md index 1133464a81..dfce11402b 100644 --- a/deploy/APPLE_CONTAINER.md +++ b/deploy/APPLE_CONTAINER.md @@ -105,6 +105,22 @@ APPLE_CONTAINER_POSTGRES_IMAGE=postgres:18-alpine APPLE_CONTAINER_REDIS_IMAGE=redis:8-alpine ``` +By default, Apple `container` selects the private IPv4 subnet for the managed +network. Set `APPLE_CONTAINER_NETWORK_SUBNET` only when host-side tooling needs +a stable network gateway: + +```dotenv +APPLE_CONTAINER_NETWORK_SUBNET= +``` + +Leave the setting empty to keep automatic subnet allocation. When setting it, +choose a CIDR that does not overlap the host LAN or VPN. The script applies the +setting only when it creates `sub2api-apple`. If the existing managed network +uses a different subnet, it stops without deleting any resources. To migrate +intentionally, run `./apple-container.sh destroy --yes` to remove the managed +containers and network while preserving named volumes, then run +`./apple-container.sh up`. + The normal `up` command recreates the application container, so application environment changes are applied immediately. Use `up --recreate` when changing PostgreSQL or Redis container images or Redis runtime configuration. Persistent data remains in named volumes. `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` are applied only when PostgreSQL initializes an empty data volume. Changing them in `.env` and recreating the container does not change an existing database. Rotate a password with `ALTER ROLE`, and plan explicit migrations for user or database changes. To intentionally initialize a new empty database, first back up the old one and use `destroy --volumes`. diff --git a/deploy/apple-container.sh b/deploy/apple-container.sh index 5de4d5cab1..cb646f1c62 100755 --- a/deploy/apple-container.sh +++ b/deploy/apple-container.sh @@ -31,6 +31,7 @@ POSTGRES_PASSWORD="" POSTGRES_DB="" REDIS_PASSWORD="" TZ_VALUE="" +NETWORK_SUBNET="" POSTGRES_ADDRESS="" REDIS_ADDRESS="" APP_ENV_FILE="" @@ -210,15 +211,28 @@ preflight_stack_ownership() { } ensure_network() { + local existing_subnet + local -a network_args + if resource_exists network "${NETWORK_NAME}"; then assert_resource_owned network "${NETWORK_NAME}" + if [[ -n "${NETWORK_SUBNET}" ]]; then + existing_subnet="$(inspect_resource network "${NETWORK_NAME}" | \ + plutil -extract 0.configuration.ipv4Subnet raw -o - -)" || \ + die "Unable to read the IPv4 subnet for ${NETWORK_NAME}." + if [[ "${existing_subnet}" != "${NETWORK_SUBNET}" ]]; then + die "Existing network '${NETWORK_NAME}' uses subnet '${existing_subnet}', but APPLE_CONTAINER_NETWORK_SUBNET is '${NETWORK_SUBNET}'. Run './apple-container.sh destroy --yes' to recreate the network while preserving volumes, then run 'up' again." + fi + fi return fi info "Creating network ${NETWORK_NAME}..." - container network create \ - --label "${STACK_LABEL_KEY}=${STACK_LABEL_VALUE}" \ - "${NETWORK_NAME}" >/dev/null + network_args=(--label "${STACK_LABEL_KEY}=${STACK_LABEL_VALUE}") + if [[ -n "${NETWORK_SUBNET}" ]]; then + network_args+=(--subnet "${NETWORK_SUBNET}") + fi + container network create "${network_args[@]}" "${NETWORK_NAME}" >/dev/null } ensure_volume() { @@ -410,6 +424,7 @@ prepare_environment() { POSTGRES_DB="$(read_env_value POSTGRES_DB sub2api)" REDIS_PASSWORD="$(read_env_value REDIS_PASSWORD)" TZ_VALUE="$(read_env_value TZ Asia/Shanghai)" + NETWORK_SUBNET="$(read_env_value APPLE_CONTAINER_NETWORK_SUBNET)" [[ -n "${BIND_HOST}" ]] || die "BIND_HOST must not be empty." validate_ipv4_address "${BIND_HOST}" diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index c8798ee171..0ff4c9086e 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -1063,22 +1063,27 @@ pricing: # Local data directory for caching # 本地数据缓存目录 data_dir: "./data" - # Fallback pricing file - # 备用定价文件 + # Fallback pricing file: fills in models missing from the catalog. Edits are + # detected by content hash at the next hash check and reloaded without restart. + # 备用定价文件:只补目录缺失的模型。修改后在下一次哈希检查时按内容哈希自动重载,无需重启。 fallback_file: "./resources/model-pricing/model_prices_and_context_window.json" # Override patch file (optional, highest priority): entries are shallow-merged # field-by-field over catalog/fallback data; a null field value removes that field. - # Model names must match catalog keys exactly. Edits take effect on restart or - # the next catalog download. + # Model names must match catalog keys exactly. Edits are detected by content hash + # at the next hash check and reloaded without restart. # 覆盖补丁文件(可选,优先级最高):条目按字段浅合并覆盖目录/回退数据, # 字段值为 null 表示删除该字段。模型名须与目录键完全一致。 - # 修改后重启或下次目录下载时生效。 + # 修改后在下一次哈希检查时按内容哈希自动重载,无需重启。 # override_file: "./data/model_pricing_overrides.json" # Update interval in hours # 更新间隔(小时) update_interval_hours: 24 - # Hash check interval in minutes - # 哈希检查间隔(分钟) + # Hash check interval in minutes. Each check compares the remote catalog hash and + # the local fallback/override file contents. Deleting a local file drops its + # entries at the next check; a present but non-JSON file keeps the previous data + # and is retried next check. + # 哈希检查间隔(分钟)。每次检查同时比对远程目录哈希与本地 fallback/override 文件内容; + # 删除本地文件会在下次检查时移除其条目,文件存在但不是 JSON 时保留旧数据、下次检查再试。 hash_check_interval_minutes: 10 # ============================================================================= diff --git a/deploy/tests/apple-container-test.sh b/deploy/tests/apple-container-test.sh index a12582104f..d1a3aa7c1e 100755 --- a/deploy/tests/apple-container-test.sh +++ b/deploy/tests/apple-container-test.sh @@ -48,6 +48,8 @@ assert_exists "${STATE_DIR}/containers/sub2api-apple" assert_exists "${STATE_DIR}/containers/sub2api-apple-postgres" assert_exists "${STATE_DIR}/containers/sub2api-apple-redis" assert_exists "${STATE_DIR}/running/sub2api-apple" +[[ ! -s "${STATE_DIR}/network-subnets/sub2api-apple" ]] || \ + fail "up passed a subnet when APPLE_CONTAINER_NETWORK_SUBNET was unset" "${SCRIPT}" status >/dev/null "${SCRIPT}" up --recreate @@ -62,6 +64,23 @@ assert_missing "${STATE_DIR}/containers/sub2api-apple" assert_missing "${STATE_DIR}/networks/sub2api-apple" assert_exists "${STATE_DIR}/volumes/sub2api-apple-data" +printf '\nAPPLE_CONTAINER_NETWORK_SUBNET=172.31.250.0/24\n' >>"${ENV_FILE}" +"${SCRIPT}" up +[[ "$(<"${STATE_DIR}/network-subnets/sub2api-apple")" == "172.31.250.0/24" ]] || \ + fail "up did not pass APPLE_CONTAINER_NETWORK_SUBNET to network creation" + +printf 'APPLE_CONTAINER_NETWORK_SUBNET=172.31.251.0/24\n' >>"${ENV_FILE}" +if mismatch_output="$("${SCRIPT}" up 2>&1)"; then + fail "up accepted a configured subnet that differs from the existing network" +fi +[[ "${mismatch_output}" == *"Existing network 'sub2api-apple' uses subnet '172.31.250.0/24'"* ]] || \ + fail "up did not explain the existing network subnet mismatch" +[[ "${mismatch_output}" == *"destroy --yes"* ]] || \ + fail "up did not provide the network migration command" +assert_exists "${STATE_DIR}/networks/sub2api-apple" +assert_exists "${STATE_DIR}/containers/sub2api-apple" + +"${SCRIPT}" destroy --yes "${SCRIPT}" up "${SCRIPT}" destroy --volumes --yes assert_missing "${STATE_DIR}/volumes/sub2api-apple-data" diff --git a/deploy/tests/fixtures/bin/container b/deploy/tests/fixtures/bin/container index a864111f27..3fd75457bb 100755 --- a/deploy/tests/fixtures/bin/container +++ b/deploy/tests/fixtures/bin/container @@ -7,6 +7,7 @@ mkdir -p \ "${STATE_DIR}/containers" \ "${STATE_DIR}/running" \ "${STATE_DIR}/networks" \ + "${STATE_DIR}/network-subnets" \ "${STATE_DIR}/volumes" \ "${STATE_DIR}/unowned/container" \ "${STATE_DIR}/unowned/network" \ @@ -34,10 +35,19 @@ inspect_resource() { local resource_name=$2 local label_value="apple-container" local address="192.168.65.4/24" + local subnet="192.168.65.0/24" if [[ -e "${STATE_DIR}/unowned/${resource_type}/${resource_name}" ]]; then label_value="other" fi + if [[ "${resource_type}" == "network" ]]; then + if [[ -s "${STATE_DIR}/network-subnets/${resource_name}" ]]; then + subnet="$(<"${STATE_DIR}/network-subnets/${resource_name}")" + fi + printf '[{"configuration":{"ipv4Subnet":"%s","labels":{"org.sub2api.stack":"%s"}}}]\n' \ + "${subnet}" "${label_value}" + return + fi case "${resource_name}" in sub2api-apple-postgres) address="192.168.65.2/24" ;; sub2api-apple-redis) address="192.168.65.3/24" ;; @@ -82,9 +92,26 @@ case "${command}" in echo default list_names "${STATE_DIR}/networks" ;; - create) touch "${STATE_DIR}/networks/$(last_argument "$@")" ;; + create) + network_name="$(last_argument "$@")" + subnet="" + while [[ $# -gt 0 ]]; do + case "$1" in + --subnet) + subnet=$2 + shift 2 + ;; + *) shift ;; + esac + done + touch "${STATE_DIR}/networks/${network_name}" + printf '%s' "${subnet}" >"${STATE_DIR}/network-subnets/${network_name}" + ;; inspect) inspect_resource network "${1}" ;; - delete) rm -f "${STATE_DIR}/networks/${1}" ;; + delete) + rm -f "${STATE_DIR}/networks/${1}" + rm -f "${STATE_DIR}/network-subnets/${1}" + ;; *) exit 1 ;; esac ;; diff --git a/docs/COMPOSITE_GROUPS.md b/docs/COMPOSITE_GROUPS.md index de6d0cf071..bf32c902bc 100644 --- a/docs/COMPOSITE_GROUPS.md +++ b/docs/COMPOSITE_GROUPS.md @@ -14,6 +14,10 @@ Composite groups can route to these concrete account platforms: - OpenAI - Antigravity - Grok +- Kimi +- Zhipu GLM +- DeepSeek +- MiniMax The selected concrete platform is used for account selection, user platform quota checks, post-usage billing, ops error platform attribution, channel @@ -68,6 +72,10 @@ Composite routing detects common public model IDs and provider-prefixed IDs: - `gpt-*`, `o*`, `codex-*`, `text-embedding-*`, `dall-e-*`, and `openai/*` route to OpenAI. - `grok-*` and `xai/grok-*` route to Grok. +- `kimi-*`, `moonshot-*`, and `k3` route to Kimi. +- `glm-*` routes to Zhipu GLM. +- `deepseek-*` routes to DeepSeek. +- `minimax-*`, `abab*`, and `minimax/*` route to MiniMax. Unknown or ambiguous model names fail closed with a client error instead of guessing a provider. diff --git a/frontend/package.json b/frontend/package.json index 8af7a4668b..ea14f61962 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vue-tsc -b && vite build", + "check:i18n": "vitest run src/i18n/__tests__/localeKeyCompleteness.spec.ts", + "build": "pnpm run check:i18n && vue-tsc -b && vite build", "preview": "vite preview", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", "lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", diff --git a/frontend/src/api/__tests__/admin.proxies.spec.ts b/frontend/src/api/__tests__/admin.proxies.spec.ts new file mode 100644 index 0000000000..46092b1bd9 --- /dev/null +++ b/frontend/src/api/__tests__/admin.proxies.spec.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { get } = vi.hoisted(() => ({ get: vi.fn() })) +vi.mock('@/api/client', () => ({ apiClient: { get } })) + +import { getAll, getAllWithCount, list } from '@/api/admin/proxies' + +describe.each([ + { name: 'paginated list', load: () => list(), wrap: (items: unknown[]) => ({ items, total: items.length, pages: 1 }) }, + { name: 'account selector', load: getAll, wrap: (items: unknown[]) => items }, + { name: 'account selector with counts', load: getAllWithCount, wrap: (items: unknown[]) => items }, +])('$name', ({ load, wrap }) => { + beforeEach(() => { get.mockReset() }) + + it.each(['', undefined, null, {}, { items: null }, { items: {} }])( + 'rejects an empty or malformed successful response: %j', + async (data) => { + get.mockResolvedValue({ data }) + await expect(load()).rejects.toThrow('Invalid proxy list response') + }, + ) + + it.each([{ items: [] }, { items: [{ id: 1, name: 'valid proxy' }] }])('preserves valid rows: %j', async ({ items }) => { + const data = wrap(items) + get.mockResolvedValue({ data }) + await expect(load()).resolves.toBe(data) + }) +}) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 8de2ed4996..9f4506182c 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -6,6 +6,7 @@ import { apiClient } from '../client' import type { Account, + AccountListItem, CreateAccountRequest, UpdateAccountRequest, PaginatedResponse, @@ -25,7 +26,9 @@ import type { UpstreamBillingProbeSettings, UpstreamBillingRatesResponse, OllamaCloudUsageSettings, - OllamaCloudUsageState + OllamaCloudUsageState, + GrokMediaEligibilityMode, + GrokMediaEligibilityState } from '@/types' /** @@ -53,8 +56,8 @@ export async function list( options?: { signal?: AbortSignal } -): Promise> { - const { data } = await apiClient.get>('/admin/accounts', { +): Promise> { + const { data } = await apiClient.get>('/admin/accounts', { params: { page, page_size: pageSize, @@ -68,7 +71,7 @@ export async function list( export interface AccountListWithEtagResult { notModified: boolean etag: string | null - data: PaginatedResponse | null + data: PaginatedResponse | null } export interface AccountUpstreamBillingRatesWithEtagResult { @@ -135,7 +138,7 @@ export async function listWithEtag( headers['If-None-Match'] = options.etag } - const response = await apiClient.get>('/admin/accounts', { + const response = await apiClient.get>('/admin/accounts', { params: { page, page_size: pageSize, @@ -237,6 +240,24 @@ export async function update(id: number, updates: UpdateAccountRequest): Promise return data } +export async function getGrokMediaEligibility(id: number): Promise { + const { data } = await apiClient.get( + `/admin/accounts/${id}/grok-media-eligibility` + ) + return data +} + +export async function updateGrokMediaEligibility( + id: number, + mode: GrokMediaEligibilityMode +): Promise { + const { data } = await apiClient.put( + `/admin/accounts/${id}/grok-media-eligibility`, + { mode } + ) + return data +} + /** * Check mixed-channel risk for account-group binding. */ @@ -1053,6 +1074,8 @@ export const accountsAPI = { create, duplicate, update, + getGrokMediaEligibility, + updateGrokMediaEligibility, checkMixedChannelRisk, delete: deleteAccount, toggleStatus, diff --git a/frontend/src/api/admin/antigravity.ts b/frontend/src/api/admin/antigravity.ts index 779fa9c1f8..7b351fab8e 100644 --- a/frontend/src/api/admin/antigravity.ts +++ b/frontend/src/api/admin/antigravity.ts @@ -29,6 +29,7 @@ export interface AntigravityTokenInfo { expires_at?: number | string expires_in?: number project_id?: string + plan_type?: string email?: string [key: string]: unknown } diff --git a/frontend/src/api/admin/channelMonitor.ts b/frontend/src/api/admin/channelMonitor.ts index bf9e1c88fb..b60f2bbcb2 100644 --- a/frontend/src/api/admin/channelMonitor.ts +++ b/frontend/src/api/admin/channelMonitor.ts @@ -14,6 +14,7 @@ export type Provider = | 'kimi' | 'zhipu' | 'deepseek' + | 'minimax' export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error' export type BodyOverrideMode = 'off' | 'merge' | 'replace' export type APIMode = 'chat_completions' | 'responses' diff --git a/frontend/src/api/admin/channels.ts b/frontend/src/api/admin/channels.ts index 3b355bc85f..d956bb92c7 100644 --- a/frontend/src/api/admin/channels.ts +++ b/frontend/src/api/admin/channels.ts @@ -50,6 +50,7 @@ export interface ChannelModelPricing { cache_read_price: number | null fast_multiplier?: number | null flex_multiplier?: number | null + max_reasoning_effort_multiplier?: number | null image_input_price: number | null image_output_price: number | null per_request_price: number | null @@ -179,6 +180,7 @@ export interface ModelDefaultPricing { cache_read_price?: number image_input_price?: number image_output_price?: number + max_reasoning_effort_multiplier?: number | null } export async function getModelDefaultPricing(model: string): Promise { diff --git a/frontend/src/api/admin/groups.ts b/frontend/src/api/admin/groups.ts index 2c9c80f5e6..6c6b717d75 100644 --- a/frontend/src/api/admin/groups.ts +++ b/frontend/src/api/admin/groups.ts @@ -103,15 +103,15 @@ export async function getById(id: number): Promise { } /** - * Get candidate models for custom /v1/models list. + * Get candidate models for the group model allowlist. * id=0 returns platform default models for create flow. */ -export async function getModelsListCandidates( +export async function getModelAllowlistCandidates( id: number, platform?: GroupPlatform ): Promise { const { data } = await apiClient.get<{ models: string[] }>( - `/admin/groups/${id}/models-list-candidates`, + `/admin/groups/${id}/model-allowlist-candidates`, { params: platform ? { platform } : undefined } @@ -477,7 +477,7 @@ export const groupsAPI = { getAllIncludingInactive, getLiveCapability, getById, - getModelsListCandidates, + getModelAllowlistCandidates, create, duplicate, update, diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index bb8b31eb28..d9f818b385 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -814,6 +814,7 @@ export interface OpsAggregationSettings { export interface OpsRuntimeLogConfig { level: 'debug' | 'info' | 'warn' | 'error' + persist_access_logs: boolean enable_sampling: boolean sampling_initial: number sampling_thereafter: number diff --git a/frontend/src/api/admin/proxies.ts b/frontend/src/api/admin/proxies.ts index d172bdfff5..ea5d995064 100644 --- a/frontend/src/api/admin/proxies.ts +++ b/frontend/src/api/admin/proxies.ts @@ -15,6 +15,12 @@ import type { AdminDataImportResult } from '@/types' +function assertProxyArray(value: unknown): asserts value is Proxy[] { + if (!Array.isArray(value)) { + throw new Error('Invalid proxy list response') + } +} + /** * List all proxies with pagination * @param page - Page number (default: 1) @@ -44,6 +50,7 @@ export async function list( }, signal: options?.signal }) + assertProxyArray(data?.items) return data } @@ -53,6 +60,7 @@ export async function list( */ export async function getAll(): Promise { const { data } = await apiClient.get('/admin/proxies/all') + assertProxyArray(data) return data } @@ -64,6 +72,7 @@ export async function getAllWithCount(): Promise { const { data } = await apiClient.get('/admin/proxies/all', { params: { with_count: 'true' } }) + assertProxyArray(data) return data } diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 29e1550c3b..7c976756df 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -38,17 +38,19 @@ export type SchedulingThresholdPlatformType = | "grok" | "kimi" | "zhipu" + | "minimax" export type AccountSchedulingThresholdsMap = Record // 与后端 AllowedSchedulingThresholdPlatforms 保持一致(deepseek 为余额型, -// 走余额检测而非用量阈值)。 +// 走余额检测而非用量阈值;minimax Coding/Token Plan 有 5h/weekly 窗口)。 export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] = [ "openai", "anthropic", "grok", "kimi", "zhipu", + "minimax", ] export function normalizeAccountSchedulingThresholdsMap( @@ -720,6 +722,7 @@ export interface SystemSettings { channel_monitor_default_interval_seconds: number; channel_monitor_hide_throughput?: boolean; channel_monitor_show_quota?: boolean; + channel_monitor_hide_user_ranking?: boolean; // Available Channels feature switch available_channels_enabled: boolean; @@ -1021,6 +1024,7 @@ export interface UpdateSettingsRequest { channel_monitor_default_interval_seconds?: number; channel_monitor_hide_throughput?: boolean; channel_monitor_show_quota?: boolean; + channel_monitor_hide_user_ranking?: boolean; // Available Channels feature switch available_channels_enabled?: boolean; @@ -1429,7 +1433,7 @@ export async function updateRectifierSettings( * Matches backend dto.OpenAIFastPolicyRule. */ export interface OpenAIFastPolicyRule { - service_tier: "all" | "priority" | "flex"; + service_tier: "all" | "priority" | "flex" | "ultrafast"; action: "pass" | "filter" | "block" | "force_priority"; scope: "all" | "oauth" | "apikey" | "bedrock"; user_ids?: number[]; diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index 6a88cc5ed8..5c3f83542e 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -31,6 +31,10 @@ export interface UserPricingInterval { cache_write_price: number | null cache_write_1h_price?: number | null cache_read_price: number | null + input_multiplier?: number | null + output_multiplier?: number | null + cache_write_multiplier?: number | null + cache_read_multiplier?: number | null per_request_price: number | null } @@ -41,6 +45,7 @@ export interface UserSupportedModelPricing { cache_write_price: number | null cache_write_1h_price?: number | null cache_read_price: number | null + max_reasoning_effort_multiplier?: number | null image_input_price: number | null image_output_price: number | null per_request_price: number | null diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts index e96a621934..5096efbe82 100644 --- a/frontend/src/api/modelPlaza.ts +++ b/frontend/src/api/modelPlaza.ts @@ -46,7 +46,7 @@ export interface PlazaTimePricing { export interface PlazaModel { name: string platform: string - /** 实收口径的展示定价:多档时 intervals 为各档绝对单价(已由计费服务折算);均为标准时段价。 */ + /** 实收口径的展示定价:档位可提供绝对单价或相对基础价倍率;均为标准时段价。 */ pricing: UserSupportedModelPricing | null official_pricing: PlazaOfficialPricing | null /** 仅多档模型返回。 */ diff --git a/frontend/src/components/TurnstileWidget.vue b/frontend/src/components/TurnstileWidget.vue index 2f0022bfe6..e04bd0aa3f 100644 --- a/frontend/src/components/TurnstileWidget.vue +++ b/frontend/src/components/TurnstileWidget.vue @@ -1,11 +1,19 @@ + + diff --git a/frontend/src/components/account/UsageProgressBar.vue b/frontend/src/components/account/UsageProgressBar.vue index 75c84a8305..1f7cde1645 100644 --- a/frontend/src/components/account/UsageProgressBar.vue +++ b/frontend/src/components/account/UsageProgressBar.vue @@ -22,6 +22,14 @@ > U ${{ formatUserCost }} + + {{ t('admin.accounts.usageWindow.estimatedTotalCost', { cost: estimatedTotalCost.toFixed(2) }) }} + @@ -67,6 +75,7 @@ const props = withDefaults( resetsAt?: string | null color: 'indigo' | 'emerald' | 'purple' | 'amber' windowStats?: WindowStats | null + estimatedTotalCost?: number | null showNowWhenIdle?: boolean remainingCapacity?: boolean /** fixed: 定宽居中徽章(账号页纵向对齐);auto: 限宽截断左对齐(监控页组合标签) */ diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts index 5ee8e5805a..954b6e15ec 100644 --- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts +++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts @@ -154,7 +154,7 @@ describe('AccountUsageCell', () => { expect(updatedAccount?.ollama_cloud_usage?.auto_refresh_enabled).toBe(false) }) - it.each(['kimi', 'zhipu', 'deepseek'] as const)( + it.each(['kimi', 'zhipu', 'deepseek', 'minimax'] as const)( '%s apikey 账号 Ollama Cloud eligible 时渲染 Ollama 用量单元格并跳过 CN 子单元格', async (platform) => { const wrapper = mount(AccountUsageCell, { @@ -441,6 +441,84 @@ describe('AccountUsageCell', () => { expect(wrapper.text()).toContain('7d|36|900') }) + it('仅为 OpenAI OAuth 7d 窗口计算预计总费用', async () => { + getUsage.mockResolvedValue({ + five_hour: { + utilization: 25, + resets_at: null, + remaining_seconds: 0, + window_stats: { requests: 1, tokens: 100, cost: 2 } + }, + seven_day: { + utilization: 40, + resets_at: null, + remaining_seconds: 0, + window_stats: { requests: 2, tokens: 200, cost: 12 } + } + }) + + const wrapper = mount(AccountUsageCell, { + props: { + account: makeAccount({ id: 6752, platform: 'openai', type: 'oauth' }) + }, + global: { + stubs: { + UsageProgressBar: { + props: ['label', 'estimatedTotalCost'], + template: '
{{ label }}|{{ estimatedTotalCost ?? "none" }}
' + }, + AccountQuotaInfo: true + } + } + }) + + await flushPromises() + + expect(wrapper.text()).toContain('5h|none') + expect(wrapper.text()).toContain('7d|30') + }) + + it.each([ + { id: 6801, utilization: 0, cost: 12 }, + { id: 6802, utilization: -1, cost: 12 }, + { id: 6803, utilization: Number.NaN, cost: 12 }, + { id: 6804, utilization: 40, cost: 0 }, + { id: 6805, utilization: 40, cost: Number.POSITIVE_INFINITY } + ])('OpenAI OAuth 7d 输入无效时不显示预计总费用 (%o)', async ({ id, utilization, cost }) => { + getUsage.mockResolvedValue({ + seven_day: { + utilization, + resets_at: null, + remaining_seconds: 0, + window_stats: { requests: 1, tokens: 100, cost } + } + }) + + const wrapper = mount(AccountUsageCell, { + props: { + account: makeAccount({ + id, + platform: 'openai', + type: 'oauth' + }) + }, + global: { + stubs: { + UsageProgressBar: { + props: ['label', 'estimatedTotalCost'], + template: '
{{ label }}|{{ estimatedTotalCost ?? "none" }}
' + }, + AccountQuotaInfo: true + } + } + }) + + await flushPromises() + + expect(wrapper.text()).toContain('7d|none') + expect(wrapper.text()).not.toMatch(/Infinity|NaN/) + }) + it('OpenAI OAuth 有现成快照时,手动刷新信号会触发 usage 重拉', async () => { getUsage.mockResolvedValue({ five_hour: { diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index faf319addf..817f0a729d 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -217,7 +217,7 @@ describe('BulkEditAccountModal', () => { expect(wrapper.findAll('[data-testid="grok-base-url-preset"]').length).toBe(0) }) - it.each(['kimi', 'zhipu', 'deepseek'])('全部目标为 %s API Key 时展示请求头覆写', (platform) => { + it.each(['kimi', 'zhipu', 'deepseek', 'minimax'])('全部目标为 %s API Key 时展示请求头覆写', (platform) => { const wrapper = mountModal({ selectedPlatforms: [platform], selectedTypes: ['apikey'] @@ -226,7 +226,7 @@ describe('BulkEditAccountModal', () => { expect(wrapper.find('#bulk-edit-header-override-enabled').exists()).toBe(true) }) - it.each(['kimi', 'zhipu', 'deepseek'])('目标为 %s OAuth 时不展示请求头覆写', (platform) => { + it.each(['kimi', 'zhipu', 'deepseek', 'minimax'])('目标为 %s OAuth 时不展示请求头覆写', (platform) => { const wrapper = mountModal({ selectedPlatforms: [platform], selectedTypes: ['oauth'] diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 876e719fd7..6c182d5713 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -1,6 +1,6 @@ import { defineComponent } from 'vue' import { flushPromises, mount } from '@vue/test-utils' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { createAccountMock, @@ -212,6 +212,50 @@ describe('CreateAccountModal OpenAI long-context billing', () => { createOpenAICodexPATMock.mockReset().mockResolvedValue({}) }) + afterEach(() => vi.useRealTimers()) + + it('sets month and year expiry presets without submitting the account form', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-01-31T12:34:00')) + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('expiry account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + const input = wrapper.get('input[type="datetime-local"]') + + for (const [label, expected] of [ + ['payment.oneMonth', '2026-02-28T12:34'], + ['payment.oneYear', '2027-01-31T12:34'], + ]) { + const button = wrapper.findAll('button').find((candidate) => candidate.text() === label)! + expect(button.attributes('type')).toBe('button') + await button.trigger('click') + expect(input.element.value).toBe(expected) + expect(createAccountMock).not.toHaveBeenCalled() + } + + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + expect(createAccountMock.mock.calls[0]?.[0]?.expires_at).toBe(new Date('2027-01-31T12:34:00').getTime() / 1000) + wrapper.unmount() + }) + + it('allows a manually entered expiry to override a preset before account creation', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('custom expiry account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + await selectButtonByText(wrapper, 'payment.oneMonth') + await wrapper.get('input[type="datetime-local"]').setValue('2030-04-15T09:20') + + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + expect(createAccountMock.mock.calls[0]?.[0]?.expires_at).toBe(new Date('2030-04-15T09:20:00').getTime() / 1000) + wrapper.unmount() + }) + it('hides only the redundant account toggle when every selected group enables tier pricing', async () => { authIsSimpleMode.value = false const wrapper = mountModal([ @@ -247,6 +291,48 @@ describe('CreateAccountModal OpenAI long-context billing', () => { expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false) }) + it('omits the upstream request id header from extra when left empty', async () => { + await submitApiKeyAccount('openai') + + expect(createAccountMock).toHaveBeenCalledTimes(1) + expect(createAccountMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('upstream_request_id_header') + }) + + it('sends the trimmed upstream request id header in extra when filled', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('openai account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + await wrapper.get('[data-testid="upstream-request-id-header"]').setValue(' X-Oneapi-Request-Id ') + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + + expect(createAccountMock).toHaveBeenCalledTimes(1) + expect(createAccountMock.mock.calls[0]?.[0]?.extra?.upstream_request_id_header).toBe('X-Oneapi-Request-Id') + }) + + it('omits images_url_to_b64_json from extra by default', async () => { + await submitApiKeyAccount('openai') + + expect(createAccountMock).toHaveBeenCalledTimes(1) + expect(createAccountMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('images_url_to_b64_json') + }) + + it('sends images_url_to_b64_json in extra when the toggle is enabled', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('openai account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + await wrapper.get('[data-testid="openai-images-url-to-b64-json-toggle"]').trigger('click') + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + + expect(createAccountMock).toHaveBeenCalledTimes(1) + expect(createAccountMock.mock.calls[0]?.[0]?.extra?.images_url_to_b64_json).toBe(true) + }) + it('persists upstream model metadata after creating an account from preview', async () => { const wrapper = mountModal() await selectButtonByText(wrapper, 'OpenAI') @@ -404,6 +490,28 @@ describe('CreateAccountModal OpenAI long-context billing', () => { }) }) + it('submits adaptive MiniMax protocol endpoints', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'MiniMax') + await wrapper.get('form#create-account-form input[type="text"]').setValue('MiniMax adaptive') + await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-minimax') + + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + + expect(createAccountMock).toHaveBeenCalledTimes(1) + expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({ + account_mode: 'payg', + api_protocol: 'adaptive', + base_url: 'https://api.minimaxi.com/v1', + api_base_urls: { + chat_completions: 'https://api.minimaxi.com/v1', + anthropic: 'https://api.minimaxi.com/anthropic', + responses: 'https://api.minimaxi.com/v1' + } + }) + }) + it('uses the edited adaptive Chat endpoint when previewing upstream models', async () => { const wrapper = mountModal() await selectButtonByText(wrapper, 'Kimi') diff --git a/frontend/src/components/account/__tests__/EditAccountModal.grokMediaEligibility.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.grokMediaEligibility.spec.ts new file mode 100644 index 0000000000..81f95c7392 --- /dev/null +++ b/frontend/src/components/account/__tests__/EditAccountModal.grokMediaEligibility.spec.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import { mount } from '@vue/test-utils' + +const { + updateAccountMock, + getEligibilityMock, + updateEligibilityMock, + showErrorMock, + authIsSimpleMode +} = vi.hoisted(() => ({ + updateAccountMock: vi.fn(), + getEligibilityMock: vi.fn(), + updateEligibilityMock: vi.fn(), + showErrorMock: vi.fn(), + authIsSimpleMode: { value: true } +})) + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ showError: showErrorMock, showSuccess: vi.fn(), showInfo: vi.fn() }) +})) +vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ get isSimpleMode() { return authIsSimpleMode.value } }) })) +vi.mock('@/api/admin', () => ({ + adminAPI: { + accounts: { + update: updateAccountMock, + getGrokMediaEligibility: getEligibilityMock, + updateGrokMediaEligibility: updateEligibilityMock, + checkMixedChannelRisk: vi.fn().mockResolvedValue({ has_risk: false }) + }, + settings: { + getWebSearchEmulationConfig: vi.fn().mockResolvedValue({ enabled: false, providers: [] }), + getSettings: vi.fn().mockResolvedValue({}) + }, + tlsFingerprintProfiles: { list: vi.fn().mockResolvedValue([]) } + } +})) +vi.mock('@/api/admin/accounts', () => ({ getAntigravityDefaultModelMapping: vi.fn() })) +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { ...actual, useI18n: () => ({ t: (key: string) => key }) } +}) + +import EditAccountModal from '../EditAccountModal.vue' + +const BaseDialogStub = defineComponent({ + props: { show: { type: Boolean, default: false } }, + template: '
' +}) + +const account = (platform = 'grok', type = 'oauth', extra: Record = {}) => ({ + id: 12, name: 'Grok', notes: '', platform, type, + credentials: { expires_at: '2027-01-01T00:00:00Z', token_type: 'Bearer' }, + credentials_status: { has_access_token: true, has_refresh_token: true }, extra, + proxy_id: null, concurrency: 1, priority: 1, rate_multiplier: 1, status: 'active', + group_ids: [], expires_at: null, auto_pause_on_expired: false +}) + +function mountModal(value = account()) { + return mount(EditAccountModal, { + props: { show: true, account: value, proxies: [], groups: [] }, + global: { stubs: { + BaseDialog: BaseDialogStub, Select: true, Icon: true, ProxySelector: true, + GroupSelector: true, ModelWhitelistSelector: true + } } + }) +} + +describe('EditAccountModal Grok media eligibility', () => { + beforeEach(() => { + authIsSimpleMode.value = true + updateAccountMock.mockReset() + getEligibilityMock.mockReset() + updateEligibilityMock.mockReset() + showErrorMock.mockReset() + updateAccountMock.mockResolvedValue(account()) + getEligibilityMock.mockResolvedValue({ account_id: 12, mode: 'auto', eligible: false, reason: 'billing_inconclusive' }) + updateEligibilityMock.mockImplementation(async (_id: number, mode: string) => ({ + account_id: 12, mode, eligible: mode === 'enabled', reason: mode === 'enabled' ? 'override_enabled' : 'override_disabled' + })) + }) + + it('shows only for Grok OAuth and fills the current decision', async () => { + const wrapper = mountModal() + await vi.waitFor(() => expect(getEligibilityMock).toHaveBeenCalledWith(12)) + expect(wrapper.find('[data-testid="grok-media-eligibility-card"]').exists()).toBe(true) + expect(wrapper.get('[data-testid="grok-media-eligibility-mode"]').element.value).toBe('auto') + expect(wrapper.get('[data-testid="grok-media-eligibility-status"]').text()).toContain('billing_inconclusive') + expect(mountModal(account('grok', 'apikey')).find('[data-testid="grok-media-eligibility-card"]').exists()).toBe(false) + expect(mountModal(account('openai', 'oauth')).find('[data-testid="grok-media-eligibility-card"]').exists()).toBe(false) + }) + + it('updates the dedicated endpoint only when the mode changes', async () => { + const wrapper = mountModal() + await vi.waitFor(() => expect(getEligibilityMock).toHaveBeenCalled()) + await wrapper.get('[data-testid="grok-media-eligibility-mode"]').setValue('enabled') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + await vi.waitFor(() => expect(updateEligibilityMock).toHaveBeenCalledWith(12, 'enabled')) + }) + + it('reports partial-save errors when the dedicated endpoint fails', async () => { + updateEligibilityMock.mockRejectedValueOnce(new Error('eligibility failed')) + const wrapper = mountModal() + await vi.waitFor(() => expect(getEligibilityMock).toHaveBeenCalled()) + await wrapper.get('[data-testid="grok-media-eligibility-mode"]').setValue('disabled') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + await vi.waitFor(() => expect(showErrorMock).toHaveBeenCalledWith('admin.accounts.grokMediaEligibility.partialSave')) + expect(getEligibilityMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index d81ffacebe..9f9b68a539 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent } from 'vue' import { mount } from '@vue/test-utils' @@ -302,7 +302,7 @@ function buildOpenAIOAuthParentAccount() { } as any } -function mountModal(account = buildAccount()) { +function mountModal(account = buildAccount(), renderGroupSelector = false) { return mount(EditAccountModal, { props: { show: true, @@ -316,7 +316,7 @@ function mountModal(account = buildAccount()) { Select: SelectStub, Icon: true, ProxySelector: true, - GroupSelector: GroupSelectorStub, + GroupSelector: renderGroupSelector ? false : GroupSelectorStub, ModelWhitelistSelector: ModelWhitelistSelectorStub } } @@ -328,6 +328,96 @@ describe('EditAccountModal', () => { authIsSimpleMode.value = true }) + afterEach(() => vi.useRealTimers()) + + it('sets expiry presets from now instead of extending the saved expiry', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2028-02-29T12:34:00')) + const account = buildAccount() + account.expires_at = new Date('2030-06-15T09:00:00').getTime() / 1000 + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + const wrapper = mountModal(account) + const input = wrapper.get('input[type="datetime-local"]') + + for (const [label, expected] of [ + ['payment.oneMonth', '2028-03-29T12:34'], + ['payment.oneYear', '2029-02-28T12:34'], + ]) { + const button = wrapper.findAll('button').find((candidate) => candidate.text() === label)! + expect(button.attributes('type')).toBe('button') + await button.trigger('click') + expect(input.element.value).toBe(expected) + expect(updateAccountMock).not.toHaveBeenCalled() + } + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock.mock.calls[0]?.[1]?.expires_at).toBe(new Date('2029-02-28T12:34:00').getTime() / 1000) + wrapper.unmount() + }) + + it('can clear a selected expiry preset before saving the account', async () => { + const account = buildAccount() + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + const wrapper = mountModal(account) + const button = wrapper.findAll('button').find((candidate) => candidate.text() === 'payment.oneYear')! + await button.trigger('click') + const input = wrapper.get('input[type="datetime-local"]') + expect(input.element.value).not.toBe('') + await input.setValue('') + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock.mock.calls[0]?.[1]?.expires_at).toBe(0) + wrapper.unmount() + }) + + it('allows removing assigned inactive groups and undoing the selection before saving', async () => { + authIsSimpleMode.value = false + const account = buildAccount() + const activeGroup = { + id: 1, + name: 'Active group', + platform: 'openai', + status: 'active', + subscription_type: 'standard', + rate_multiplier: 1 + } + const inactiveGroup = { ...activeGroup, id: 2, name: 'Paused group', status: 'inactive' } + account.group_ids = [1, 2] + account.groups = [ + { ...activeGroup, name: 'Outdated name' }, + inactiveGroup, + inactiveGroup, + { ...inactiveGroup, id: 3, name: 'Unassigned paused group' } + ] + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account, true) + await wrapper.setProps({ groups: [activeGroup] as any }) + const selector = wrapper.get('[data-tour="account-form-groups"]') + expect(selector.findAll('input[type="checkbox"]').map(input => input.attributes('value'))) + .toEqual(['1', '2']) + expect(selector.text()).toContain('Active group') + expect(selector.text()).not.toContain('Outdated name') + const pausedCheckbox = selector.get('input[value="2"]') + expect(pausedCheckbox.element.checked).toBe(true) + + await pausedCheckbox.setValue(false) + expect(selector.get('input[value="2"]').element.checked).toBe(false) + await pausedCheckbox.setValue(true) + expect(pausedCheckbox.element.checked).toBe(true) + await pausedCheckbox.setValue(false) + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.group_ids).toEqual([1]) + expect(account.group_ids).toEqual([1, 2]) + }) + it('reopening the same account rehydrates the OpenAI whitelist from props', async () => { const account = buildAccount() updateAccountMock.mockReset() @@ -675,6 +765,85 @@ describe('EditAccountModal', () => { ) }) + it('writes the upstream request id header into extra only when it changes', async () => { + const account = buildAccount() + account.extra = { openai_compact_mode: 'force_on' } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const untouched = mountModal(account) + await untouched.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.upstream_request_id_header).toBeUndefined() + + updateAccountMock.mockClear() + const wrapper = mountModal(account) + await wrapper.get('[data-testid="upstream-request-id-header"]').setValue(' X-Oneapi-Request-Id ') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).toMatchObject({ + openai_compact_mode: 'force_on', + upstream_request_id_header: 'X-Oneapi-Request-Id' + }) + }) + + it('removes the upstream request id header from extra when cleared', async () => { + const account = buildAccount() + account.extra = { upstream_request_id_header: 'X-Request-ID' } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + expect((wrapper.get('[data-testid="upstream-request-id-header"]').element as HTMLInputElement).value).toBe('X-Request-ID') + await wrapper.get('[data-testid="upstream-request-id-header"]').setValue('') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).toBeDefined() + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('upstream_request_id_header') + }) + + it('writes images_url_to_b64_json into extra when toggled on', async () => { + const account = buildAccount() + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const toggle = wrapper.get('[data-testid="openai-images-url-to-b64-json-toggle"]') + expect(toggle.attributes('aria-checked')).toBe('false') + await toggle.trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.images_url_to_b64_json).toBe(true) + }) + + it('removes images_url_to_b64_json from extra when toggled off', async () => { + const account = buildAccount() + account.extra = { images_url_to_b64_json: true } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + const toggle = wrapper.get('[data-testid="openai-images-url-to-b64-json-toggle"]') + expect(toggle.attributes('aria-checked')).toBe('true') + await toggle.trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).toBeDefined() + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('images_url_to_b64_json') + }) + it('hides the Codex namespace flatten toggle for non-OAuth OpenAI accounts', async () => { const account = buildAccount() const wrapper = mountModal(account) diff --git a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts index 928ab6e721..3ddea14420 100644 --- a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts +++ b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts @@ -153,6 +153,41 @@ describe('ModelWhitelistSelector', () => { expect(showSuccess).not.toHaveBeenCalled() }) + it('shows success and a partial warning when some capabilities were saved', async () => { + syncUpstreamModels.mockResolvedValue({ + models: ['gpt-6-astra', 'gpt-image-2'], + warnings: [ + { + code: 'upstream_model_metadata_partial', + message: 'Some model capabilities were saved; remaining models are still incomplete.' + } + ] + }) + const wrapper = mount(ModelWhitelistSelector, { + props: { + modelValue: [], + platform: 'openai', + accountId: 46 + }, + global: { + stubs: { + ModelIcon: true + } + } + }) + + const syncButton = wrapper + .findAll('button') + .find(button => button.text() === 'admin.accounts.syncUpstreamModels') + expect(syncButton).toBeDefined() + await syncButton!.trigger('click') + await flushPromises() + + expect(wrapper.emitted('update:modelValue')).toEqual([[['gpt-6-astra', 'gpt-image-2']]]) + expect(showSuccess).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsSuccess') + expect(showWarning).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsMetadataPartial') + }) + it('reports a successful preview so account creation can persist metadata', async () => { syncUpstreamModelsPreview.mockResolvedValue({ models: ['x-preview-f-free'], diff --git a/frontend/src/components/account/__tests__/UpstreamRequestIdHeaderField.spec.ts b/frontend/src/components/account/__tests__/UpstreamRequestIdHeaderField.spec.ts new file mode 100644 index 0000000000..363a883ee7 --- /dev/null +++ b/frontend/src/components/account/__tests__/UpstreamRequestIdHeaderField.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import UpstreamRequestIdHeaderField from '../UpstreamRequestIdHeaderField.vue' + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string, params?: Record) => + params?.platform ? `${key}:${String(params.platform)}` : key, + }), + } +}) + +function mountField(props: { platform?: string; type?: string; modelValue?: string }) { + return mount(UpstreamRequestIdHeaderField, { + props, + global: { + stubs: { Teleport: true }, + }, + }) +} + +describe('UpstreamRequestIdHeaderField', () => { + it('lists relay and official header examples for API key accounts', () => { + const wrapper = mountField({ platform: 'openai', type: 'apikey' }) + const text = wrapper.text() + + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.intro') + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.examplesTitle') + expect(text).toContain('X-Client-Request-ID') + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.sub2apiNote') + expect(text).toContain('X-Oneapi-Request-Id') + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.official:OpenAI') + expect(text).toContain('x-request-id') + }) + + it('lists only the official header for accounts that connect to the platform directly', () => { + const wrapper = mountField({ platform: 'anthropic', type: 'oauth' }) + const text = wrapper.text() + + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.official:Anthropic') + expect(text).toContain('request-id') + expect(text).not.toContain('X-Client-Request-ID') + expect(text).not.toContain('X-Oneapi-Request-Id') + }) + + it('omits the examples section when no header is known for the platform', () => { + const wrapper = mountField({ platform: 'kimi', type: 'oauth' }) + const text = wrapper.text() + + expect(text).toContain('admin.accounts.upstreamRequestIdHeaderHelp.intro') + expect(text).not.toContain('admin.accounts.upstreamRequestIdHeaderHelp.examplesTitle') + expect(wrapper.findAll('code')).toHaveLength(0) + }) + + it('binds the header name through v-model', async () => { + const wrapper = mountField({ platform: 'openai', type: 'apikey', modelValue: 'X-Request-ID' }) + const input = wrapper.get('[data-testid="upstream-request-id-header"]') + expect((input.element as HTMLInputElement).value).toBe('X-Request-ID') + + await input.setValue('X-Oneapi-Request-Id') + expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual(['X-Oneapi-Request-Id']) + }) +}) diff --git a/frontend/src/components/account/__tests__/accountExpiry.spec.ts b/frontend/src/components/account/__tests__/accountExpiry.spec.ts new file mode 100644 index 0000000000..7d243a8434 --- /dev/null +++ b/frontend/src/components/account/__tests__/accountExpiry.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { getAccountExpiryTimestamp } from '../accountExpiry' + +describe('getAccountExpiryTimestamp', () => { + it.each([ + ['2026-01-31T12:34:56.789', 1, '2026-02-28T12:34:56'], + ['2028-01-31T12:34:56.789', 1, '2028-02-29T12:34:56'], + ['2028-02-29T12:34:56.789', 12, '2029-02-28T12:34:56'], + ['2026-12-31T12:34:56.789', 1, '2027-01-31T12:34:56'], + // With TZ=Australia/Melbourne, October 1 contains a DST gap; avoid using it as an intermediate date. + ['2028-10-31T02:30:00', 1, '2028-11-30T02:30:00'], + ])('adds %s by %i calendar months with month-end clamping', (input, months, expected) => { + const now = new Date(input) + const originalTimestamp = now.getTime() + + expect(getAccountExpiryTimestamp(months, now)).toBe(new Date(expected).getTime() / 1000) + expect(now.getTime()).toBe(originalTimestamp) + }) +}) diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts index d7326631e1..caadfa3fa9 100644 --- a/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts +++ b/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts @@ -3,9 +3,10 @@ import { describe, expect, it } from 'vitest' import { cnSupportsNativeResponses, defaultCNAdaptiveBaseUrls } from '../credentialsBuilder' describe('cnSupportsNativeResponses', () => { - it('is true for DeepSeek and Kimi only', () => { + it('is true for DeepSeek, Kimi, and MiniMax', () => { expect(cnSupportsNativeResponses('deepseek')).toBe(true) expect(cnSupportsNativeResponses('kimi')).toBe(true) + expect(cnSupportsNativeResponses('minimax')).toBe(true) expect(cnSupportsNativeResponses('zhipu')).toBe(false) expect(cnSupportsNativeResponses('openai')).toBe(false) }) @@ -45,4 +46,14 @@ describe('defaultCNAdaptiveBaseUrls', () => { responses: 'https://api.deepseek.com' }) }) + + it('uses the same MiniMax CN endpoints for payg and coding', () => { + const expected = { + chat_completions: 'https://api.minimaxi.com/v1', + anthropic: 'https://api.minimaxi.com/anthropic', + responses: 'https://api.minimaxi.com/v1' + } + expect(defaultCNAdaptiveBaseUrls('minimax', 'payg')).toEqual(expected) + expect(defaultCNAdaptiveBaseUrls('minimax', 'coding')).toEqual(expected) + }) }) diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts index e5cee32ad0..9af2b9049f 100644 --- a/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts +++ b/frontend/src/components/account/__tests__/credentialsBuilder.spec.ts @@ -107,7 +107,7 @@ describe('isHeaderOverrideCapable', () => { }) it('kimi/zhipu/deepseek only support apikey accounts', () => { - for (const platform of ['kimi', 'zhipu', 'deepseek']) { + for (const platform of ['kimi', 'zhipu', 'deepseek', 'minimax']) { expect(isHeaderOverrideCapable(platform, 'apikey')).toBe(true) expect(isHeaderOverrideCapable(platform, 'oauth')).toBe(false) } diff --git a/frontend/src/components/account/accountExpiry.ts b/frontend/src/components/account/accountExpiry.ts new file mode 100644 index 0000000000..33665d340c --- /dev/null +++ b/frontend/src/components/account/accountExpiry.ts @@ -0,0 +1,7 @@ +/** Set an expiry from local calendar months, clamping month-end dates. */ +export function getAccountExpiryTimestamp(months: number, now = new Date()): number { + const expiry = new Date(now.getTime()) + const lastDay = new Date(expiry.getFullYear(), expiry.getMonth() + months + 1, 0).getDate() + expiry.setMonth(expiry.getMonth() + months, Math.min(expiry.getDate(), lastDay)) + return Math.floor(expiry.getTime() / 1000) +} diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts index 0ed581567c..a41a7b5873 100644 --- a/frontend/src/components/account/credentialsBuilder.ts +++ b/frontend/src/components/account/credentialsBuilder.ts @@ -42,7 +42,8 @@ export function isHeaderOverrideCapable(platform: string, type: string): boolean platform === 'openai' || platform === 'kimi' || platform === 'zhipu' || - platform === 'deepseek' + platform === 'deepseek' || + platform === 'minimax' ) { return type === 'apikey' } @@ -255,14 +256,19 @@ export const GROK_BASE_URL_PRESETS: GrokBaseUrlPreset[] = [ // 两者正交。同协议请求零转换直通,跨协议组合才走转换链。 export type CnAccountMode = 'payg' | 'coding' +export type CnProviderPlatform = 'kimi' | 'zhipu' | 'deepseek' | 'minimax' -/** deepseek / kimi 支持原生 responses;adaptive 会按入站协议选择原生端点。 */ +/** deepseek / kimi / minimax 支持原生 responses;adaptive 会按入站协议选择原生端点。 */ export type CnApiProtocol = 'adaptive' | 'chat_completions' | 'anthropic' | 'responses' export type CnNativeApiProtocol = Exclude -/** DeepSeek 与 Kimi(按量付费 / Coding Plan)提供原生 Responses 端点。 */ +export function isCNProviderPlatform(platform: string): platform is CnProviderPlatform { + return platform === 'kimi' || platform === 'zhipu' || platform === 'deepseek' || platform === 'minimax' +} + +/** DeepSeek、Kimi 与 MiniMax 提供原生 Responses 端点。 */ export function cnSupportsNativeResponses(platform: string): boolean { - return platform === 'deepseek' || platform === 'kimi' + return platform === 'deepseek' || platform === 'kimi' || platform === 'minimax' } export interface CnBaseUrlPreset { @@ -274,7 +280,7 @@ export interface CnBaseUrlPreset { } /** 各供应商按账号类型 × API 协议分档的快捷端点(点击快速填充,输入框仍可自由填写)。 */ -export const CN_BASE_URL_PRESETS: Record<'kimi' | 'zhipu' | 'deepseek', CnBaseUrlPreset[]> = { +export const CN_BASE_URL_PRESETS: Record = { kimi: [ { mode: 'payg', protocol: 'chat_completions', label: 'Moonshot', url: 'https://api.moonshot.cn/v1' }, { mode: 'payg', protocol: 'anthropic', label: 'Moonshot Anthropic', url: 'https://api.moonshot.cn/anthropic' }, @@ -293,6 +299,20 @@ export const CN_BASE_URL_PRESETS: Record<'kimi' | 'zhipu' | 'deepseek', CnBaseUr { mode: 'payg', protocol: 'chat_completions', label: 'DeepSeek', url: 'https://api.deepseek.com' }, { mode: 'payg', protocol: 'anthropic', label: 'DeepSeek Anthropic', url: 'https://api.deepseek.com/anthropic' }, { mode: 'payg', protocol: 'responses', label: 'DeepSeek Responses', url: 'https://api.deepseek.com' } + ], + minimax: [ + { mode: 'payg', protocol: 'chat_completions', label: 'MiniMax CN', url: 'https://api.minimaxi.com/v1' }, + { mode: 'payg', protocol: 'anthropic', label: 'MiniMax CN Anthropic', url: 'https://api.minimaxi.com/anthropic' }, + { mode: 'payg', protocol: 'responses', label: 'MiniMax CN Responses', url: 'https://api.minimaxi.com/v1' }, + { mode: 'payg', protocol: 'chat_completions', label: 'MiniMax Intl', url: 'https://api.minimax.io/v1' }, + { mode: 'payg', protocol: 'anthropic', label: 'MiniMax Intl Anthropic', url: 'https://api.minimax.io/anthropic' }, + { mode: 'payg', protocol: 'responses', label: 'MiniMax Intl Responses', url: 'https://api.minimax.io/v1' }, + { mode: 'coding', protocol: 'chat_completions', label: 'MiniMax Coding CN', url: 'https://api.minimaxi.com/v1' }, + { mode: 'coding', protocol: 'anthropic', label: 'MiniMax Coding CN Anthropic', url: 'https://api.minimaxi.com/anthropic' }, + { mode: 'coding', protocol: 'responses', label: 'MiniMax Coding CN Responses', url: 'https://api.minimaxi.com/v1' }, + { mode: 'coding', protocol: 'chat_completions', label: 'MiniMax Coding Intl', url: 'https://api.minimax.io/v1' }, + { mode: 'coding', protocol: 'anthropic', label: 'MiniMax Coding Intl Anthropic', url: 'https://api.minimax.io/anthropic' }, + { mode: 'coding', protocol: 'responses', label: 'MiniMax Coding Intl Responses', url: 'https://api.minimax.io/v1' } ] } @@ -310,11 +330,13 @@ export function defaultCNBaseUrl( return 'https://open.bigmodel.cn/api/anthropic' case 'deepseek': return 'https://api.deepseek.com/anthropic' + case 'minimax': + return 'https://api.minimaxi.com/anthropic' default: return '' } } - // responses:Kimi / DeepSeek 的 base 与 chat_completions 相同(端点路径差异由后端处理)。 + // responses:Kimi / DeepSeek / MiniMax 的 base 与 chat_completions 相同(端点路径差异由后端处理)。 switch (platform) { case 'kimi': return mode === 'coding' ? 'https://api.kimi.com/coding/v1' : 'https://api.moonshot.cn/v1' @@ -324,6 +346,8 @@ export function defaultCNBaseUrl( : 'https://open.bigmodel.cn/api/paas/v4' case 'deepseek': return 'https://api.deepseek.com' + case 'minimax': + return 'https://api.minimaxi.com/v1' default: return '' } @@ -331,7 +355,7 @@ export function defaultCNBaseUrl( /** 返回自适应模式下需要配置的原生协议及其默认端点。 */ export function defaultCNAdaptiveBaseUrls( - platform: 'kimi' | 'zhipu' | 'deepseek', + platform: CnProviderPlatform, mode: CnAccountMode ): Record { return { @@ -346,7 +370,7 @@ export function defaultCNAdaptiveBaseUrls( // 共用,避免多处复制条件后一处改另一处漏改。 export function cnQuotaCellVisible(platform: string, accountMode: string): boolean { - return (platform === 'kimi' || platform === 'zhipu') && accountMode === 'coding' + return (platform === 'kimi' || platform === 'zhipu' || platform === 'minimax') && accountMode === 'coding' } export function cnBalanceCellVisible(platform: string, accountMode: string): boolean { diff --git a/frontend/src/components/admin/account/AccountActionMenu.vue b/frontend/src/components/admin/account/AccountActionMenu.vue index 14922a37b7..0523dcad02 100644 --- a/frontend/src/components/admin/account/AccountActionMenu.vue +++ b/frontend/src/components/admin/account/AccountActionMenu.vue @@ -1,11 +1,12 @@ diff --git a/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue b/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue index 4b80d73fca..ba9755db89 100644 --- a/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue +++ b/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue @@ -136,7 +136,7 @@