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..20c0668b82 100644 --- a/README.md +++ b/README.md @@ -816,7 +816,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/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 0c62199f16..ee1372d33a 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.2.1 +0.2.2 diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 5ec4892e16..e9977ede0f 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -189,10 +189,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 +205,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/group.go b/backend/ent/group.go index 91f0761670..5510c5aa1a 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -134,8 +134,8 @@ 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 表示不限制;设置后接管该分组用户的限流 @@ -258,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.FieldCodexModelsManifestConfig, 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) @@ -662,12 +662,12 @@ 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: @@ -1007,8 +1007,8 @@ 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)) diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 76719581d0..66dbdc412d 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -130,8 +130,8 @@ 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. @@ -280,7 +280,7 @@ var Columns = []string{ FieldRequirePrivacySet, FieldDefaultMappedModel, FieldMessagesDispatchModelConfig, - FieldModelsListConfig, + FieldModelAllowlist, FieldCodexModelsManifestConfig, FieldRpmLimit, FieldMaxReasoningEffort, @@ -412,8 +412,8 @@ 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. diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 0e75ad78c1..7f4c650708 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -788,16 +788,16 @@ 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 } @@ -1175,9 +1175,9 @@ 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 @@ -1381,8 +1381,8 @@ 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"`)} @@ -1673,9 +1673,9 @@ 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) @@ -2795,15 +2795,15 @@ 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 } @@ -4054,17 +4054,17 @@ 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() }) } @@ -5500,17 +5500,17 @@ 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() }) } diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index 19177d4bd4..091466452f 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -1073,16 +1073,16 @@ 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 } @@ -1876,8 +1876,8 @@ 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) @@ -3267,16 +3267,16 @@ 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 } @@ -4100,8 +4100,8 @@ 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) diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 0c08716e1a..b6d6b8bf2f 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -960,7 +960,7 @@ 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: ""}, diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 522bbd3a21..cfa09d102f 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -22164,7 +22164,7 @@ 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 @@ -25164,40 +25164,40 @@ 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. @@ -26093,8 +26093,8 @@ 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) @@ -26242,8 +26242,8 @@ 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: @@ -26383,8 +26383,8 @@ 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: @@ -26809,12 +26809,12 @@ 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) @@ -27554,8 +27554,8 @@ 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() diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 456f5248de..5a718fabca 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -1183,10 +1183,10 @@ 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. diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 46c2214491..2d39e5724a 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -262,10 +262,10 @@ 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"}). 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_handler.go b/backend/internal/handler/admin/account_handler.go index fac8a1a3b3..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"` @@ -212,6 +215,110 @@ type AccountListItemWithConcurrency struct { 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"` @@ -247,9 +354,14 @@ func (h *AccountHandler) accountListResponseFromService(account *service.Account 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 { @@ -677,9 +789,13 @@ func (h *AccountHandler) List(c *gin.Context) { accountResponse := h.accountResponseFromService(acc) if lite { accountResponse = h.accountListResponseFromService(acc) + if h.isSimpleMode() { + accountResponse.GroupIDs = filterSimpleModeGroupIDs(accountResponse.GroupIDs, simpleModeCompositeServiceGroupIDs(acc)) + } } item := AccountWithConcurrency{ Account: accountResponse, + simpleMode: h.isSimpleMode(), CurrentConcurrency: concurrencyCounts[acc.ID], SchedulerScore: schedulerScores[acc.ID], SchedulerScores: schedulerGroupScores[acc.ID], @@ -1942,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 @@ -2656,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_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_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/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/group_handler.go b/backend/internal/handler/admin/group_handler.go index 980d320291..5a1b352473 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,13 +92,94 @@ 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"` @@ -153,7 +239,7 @@ 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 = 不限制) @@ -228,7 +314,7 @@ 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 表示未提供不改动 @@ -262,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") @@ -276,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 } @@ -287,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])) @@ -297,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 @@ -312,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 @@ -332,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 @@ -356,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 @@ -374,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 @@ -427,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" @@ -446,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])) @@ -456,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") @@ -468,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") @@ -496,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()) @@ -568,7 +715,7 @@ 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, @@ -581,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") @@ -634,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") @@ -645,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, @@ -701,7 +861,7 @@ 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, @@ -714,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 @@ -738,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") @@ -757,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) @@ -771,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") @@ -782,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") @@ -806,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") @@ -827,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") @@ -849,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") @@ -877,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") @@ -900,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") @@ -925,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_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/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/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 ffa2408bcd..ae01687bdb 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -81,15 +81,19 @@ type userSupportedModelPricing struct { // 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 用户可见的支持模型条目。 @@ -295,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 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_test.go b/backend/internal/handler/composite_platform_test.go index d9a642ce06..af5726e217 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -152,6 +152,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)) diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 62b1affa2d..bb29600c67 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -159,7 +159,7 @@ 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, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index b2aa2d5ac2..7c952af6d2 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -189,7 +189,7 @@ 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"` diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 941a73f53c..a073f0f336 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1134,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 { @@ -1151,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 } @@ -1239,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 @@ -1250,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 @@ -1295,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 @@ -1314,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 @@ -1412,72 +1424,19 @@ 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: @@ -1551,10 +1510,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, }) } 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..4facbfe790 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"}, }, @@ -704,7 +803,7 @@ 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"}, }, @@ -918,7 +1017,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 +1068,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 +1119,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 +1160,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 +1205,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 +1242,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 +1279,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 db2f5dd380..d8ec24670b 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -46,14 +46,34 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { return } - // 强制 antigravity 模式:返回 antigravity 支持的模型列表 - if forcePlatform == service.PlatformAntigravity { - c.JSON(http.StatusOK, antigravity.FallbackGeminiModelsList()) - 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 } - if models, ok := customGeminiModelsList(apiKey.Group); ok { - c.JSON(http.StatusOK, models) + // 强制 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 } @@ -63,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) @@ -77,21 +97,64 @@ 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) } -func customGeminiModelsList(group *service.Group) (gemini.ModelsListResponse, bool) { - if group == nil || !group.CustomModelsListEnabled() { - return gemini.ModelsListResponse{}, false +// 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 } - models := make([]gemini.Model, 0, len(group.ModelsListConfig.Models)) - for _, modelID := range group.ModelsListConfig.Models { - models = append(models, gemini.FallbackModel(modelID)) + rawModels, hasModels := envelope["models"] + if !hasModels { + return body, false, true } - return gemini.ModelsListResponse{Models: models}, 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: diff --git a/backend/internal/handler/gemini_v1beta_handler_test.go b/backend/internal/handler/gemini_v1beta_handler_test.go index 9d86731686..2bf25a6b96 100644 --- a/backend/internal/handler/gemini_v1beta_handler_test.go +++ b/backend/internal/handler/gemini_v1beta_handler_test.go @@ -9,40 +9,21 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" - "github.com/Wei-Shaw/sub2api/internal/pkg/gemini" "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_CustomGroupListUsesNativeResponse(t *testing.T) { - gin.SetMode(gin.TestMode) - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil) - c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{ - Group: &service.Group{ - Platform: service.PlatformGemini, - ModelsListConfig: service.GroupModelsListConfig{ - Enabled: true, - Models: []string{"gemini-2.5-pro", "models/gemini-custom"}, - }, - }, - }) - - (&GatewayHandler{}).GeminiV1BetaListModels(c) - - require.Equal(t, http.StatusOK, rec.Code) - var got gemini.ModelsListResponse - require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) - require.Equal(t, []gemini.Model{ - gemini.FallbackModel("gemini-2.5-pro"), - gemini.FallbackModel("models/gemini-custom"), - }, got.Models) +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_ForcedAntigravityIgnoresCustomGroupList(t *testing.T) { +func TestGeminiV1BetaListModels_ForcedAntigravityAppliesAllowlist(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -50,7 +31,7 @@ func TestGeminiV1BetaListModels_ForcedAntigravityIgnoresCustomGroupList(t *testi c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{ Group: &service.Group{ Platform: service.PlatformGemini, - ModelsListConfig: service.GroupModelsListConfig{ + ModelAllowlist: service.GroupModelAllowlist{ Enabled: true, Models: []string{"gemini-custom"}, }, @@ -63,19 +44,15 @@ func TestGeminiV1BetaListModels_ForcedAntigravityIgnoresCustomGroupList(t *testi require.Equal(t, http.StatusOK, rec.Code) var got antigravity.GeminiModelsListResponse require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) - require.Equal(t, antigravity.FallbackGeminiModelsList(), got) + require.Empty(t, got.Models) } -func TestCustomGeminiModelsList_DisabledKeepsExistingFlow(t *testing.T) { - group := &service.Group{ - ModelsListConfig: service.GroupModelsListConfig{ - Enabled: false, - Models: []string{"gemini-2.5-pro"}, - }, - } - - _, ok := customGeminiModelsList(group) - require.False(t, ok) +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 层的平台路由逻辑不变量 diff --git a/backend/internal/handler/openai_codex_models_handler.go b/backend/internal/handler/openai_codex_models_handler.go index 73d8951ca9..777eeebdb1 100644 --- a/backend/internal/handler/openai_codex_models_handler.go +++ b/backend/internal/handler/openai_codex_models_handler.go @@ -16,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) { @@ -34,28 +35,10 @@ 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 { - return - } - h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest") - return - } - if configured { - writeCodexModelsManifestResponse(c, configuredManifest) - return - } - // 固定账号分支:开启后只用选定账号拉取 manifest,不经过调度器; // 全部不可用/全部失败时按 FallbackToScheduler 决定回退调度器或返回错误。 if apiKey.Group.Platform == service.PlatformOpenAI && - apiKey.Group.CodexModelsManifestConfig.Enabled && - len(apiKey.Group.CodexModelsManifestConfig.AccountIDs) > 0 { + apiKey.Group.CodexModelsManifestConfig.Enabled { pinnedManifest, pinnedAccount, pinnedErr := h.gatewayService.FetchPinnedCodexModelsManifest( c.Request.Context(), apiKey.Group, @@ -84,7 +67,26 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { if c.Request.Context().Err() != nil { return } - writeCodexModelsManifestResponse(c, pinnedManifest) + writeOpenAIModelsResponse(c, pinnedManifest) + 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 } } @@ -133,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 @@ -141,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 89d3763602..ea31545631 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -163,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"}, }, @@ -181,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()) @@ -232,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"}, }, @@ -240,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"}, }, @@ -292,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{ @@ -354,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) { @@ -912,7 +958,7 @@ func TestCodexModelsPinnedAccountsStillApplyCustomModelsListFilter(t *testing.T) group := &service.Group{ ID: 84, Platform: service.PlatformOpenAI, - ModelsListConfig: service.GroupModelsListConfig{ + ModelAllowlist: service.GroupModelAllowlist{ Enabled: true, Models: []string{"model-b"}, }, 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_handler.go b/backend/internal/handler/openai_gateway_handler.go index d4e34e3b7b..741379e631 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" @@ -2025,10 +2026,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 @@ -2348,6 +2349,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 { @@ -2728,8 +2739,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, @@ -2743,10 +2754,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) } @@ -2763,6 +2773,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) @@ -3311,6 +3332,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) @@ -3670,6 +3697,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 diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 54747cfdbd..d86672dd1e 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -1916,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 @@ -1924,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 { @@ -2843,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) @@ -2997,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) @@ -3028,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) @@ -3038,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/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/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/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 6227ea8f4b..192c4cda39 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -150,15 +150,15 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map SessionID: generateStableSessionID(contents), } - // 针对 Gemini Reasoning 模型(如 gemini-3.1-pro-high等)过滤强制空 ToolConfig - isReasoning := IsGeminiReasoningModel(targetModel) - if !isReasoning || len(tools) > 0 { - // 总是设置 toolConfig,与官方客户端一致 - innerRequest.ToolConfig = &GeminiToolConfig{ - FunctionCallingConfig: &GeminiFunctionCallingConfig{ - Mode: "VALIDATED", - }, - } + // toolConfig must always be present: upstream rejects requests without it, + // including reasoning models called without any tools. + // 总是设置 toolConfig,与官方客户端一致。 + // 注意:buildTools 会在客户端 function tools 存在时丢弃 googleSearch/codeExecution + // (issue #6464),因此这里不再注入 includeServerSideToolInvocations。 + innerRequest.ToolConfig = &GeminiToolConfig{ + FunctionCallingConfig: &GeminiFunctionCallingConfig{ + Mode: "VALIDATED", + }, } if systemInstruction != nil { diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index 353e2bac42..99d61867c9 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -645,3 +645,38 @@ func TestGeminiToolConfig_DropsBuiltinsWhenClientFunctionsPresent(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_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/claude/cli_version_test.go b/backend/internal/pkg/claude/cli_version_test.go index 043df2b554..89f8db33a8 100644 --- a/backend/internal/pkg/claude/cli_version_test.go +++ b/backend/internal/pkg/claude/cli_version_test.go @@ -9,7 +9,7 @@ func TestIsSupportedCLIVersion(t *testing.T) { want bool }{ {"内置基线本身", CLICurrentVersion, true}, - {"高于基线的补丁位", "2.1.251", true}, + {"高于基线的补丁位", "2.1.259", true}, {"高于基线的次版本", "2.2.0", true}, {"高于基线的主版本", "3.0.0", true}, {"低于基线", "2.1.219", false}, @@ -41,8 +41,8 @@ func TestResolveCLIVersion(t *testing.T) { }{ {"未配置时用内置基线", "", CLICurrentVersion}, {"只有空白等同未配置", " ", CLICurrentVersion}, - {"合法覆盖生效", "2.1.251", "2.1.251"}, - {"两侧空白被裁掉", " 2.1.251 ", "2.1.251"}, + {"合法覆盖生效", "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}, diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index df9b7613b2..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 / @@ -80,7 +81,7 @@ const DefaultCacheControlTTL = "5m" // // ⚠️ 读取实际生效的版本号请用 CLIVersion(),它会叠加 SUB2API_CLAUDE_CLI_VERSION 覆盖。 // 直接引用本常量只在"表达内置基线"时才正确(例如覆盖值的下限校验)。 -const CLICurrentVersion = "2.1.220" +const CLICurrentVersion = "2.1.258" // FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表, // 用于 OAuth 账号伪装成 Claude Code 时使用。 @@ -99,6 +100,7 @@ func FullClaudeCodeMimicryBetas() []string { BetaPromptCachingScope, BetaEffort, BetaContextManagement, + BetaThinkingBindingControls, BetaExtendedCacheTTL, } } 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 5ee5a3dc7a..f81fe3b60d 100644 --- a/backend/internal/pkg/openai/constants.go +++ b/backend/internal/pkg/openai/constants.go @@ -56,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 @@ -69,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 { @@ -78,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 @@ -87,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/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/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 974899652e..dd2d1dbc71 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -214,7 +214,7 @@ 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, @@ -1017,7 +1017,7 @@ 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, 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/fixtures_integration_test.go b/backend/internal/repository/fixtures_integration_test.go index d8d808f90b..98e0085e2f 100644 --- a/backend/internal/repository/fixtures_integration_test.go +++ b/backend/internal/repository/fixtures_integration_test.go @@ -92,6 +92,7 @@ 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). diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 79d0b4bac2..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,7 @@ 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). @@ -289,7 +322,7 @@ 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). @@ -426,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)) @@ -807,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) @@ -830,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 } @@ -850,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 { @@ -1020,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() @@ -1032,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/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/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index d6a96e5ecf..2fe0a1c227 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1476,7 +1476,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 +1789,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 +2945,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..0ac09dbd73 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) 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..e2db4fb6fe 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,6 +41,10 @@ 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, @@ -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 ad1ad82a1b..d36dc6ecff 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1840,9 +1840,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" @@ -1868,7 +1869,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_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index c25a66f674..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,7 @@ import ( // upstreamModel 是最终发往上游的模型 ID。 // totalCost 是本次请求的客户计费(倍率前),用于优先级 2。 // serviceTier 是最终参与用户计费的 OpenAI 服务层级,用于优先级 3。 +// pricingAt 与本次客户计费使用同一时刻,避免跨峰谷请求的成本与售价错位。 // reasoningEffort 是最终转发等级;Fable 5.1 max 默认按 3 倍额度消耗。 func resolveAccountStatsCost( ctx context.Context, @@ -29,6 +31,7 @@ func resolveAccountStatsCost( requestCount int, totalCost float64, serviceTier string, + pricingAt time.Time, reasoningEfforts ...string, ) *float64 { reasoningEffort := "" @@ -61,7 +64,7 @@ func resolveAccountStatsCost( // 优先级 3:模型定价文件(LiteLLM)默认价格 if billingService != nil { - return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, reasoningEffort) + return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, pricingAt, reasoningEffort) } return nil @@ -69,20 +72,26 @@ func resolveAccountStatsCost( // tryModelFilePricing 使用模型定价文件(LiteLLM/fallback)中的价格计算费用。 // 与用户计费共用同一条定价管线,避免这里维护第二份"单价 × token 数"实现后, -// 每加一个定价特性都要手工镜像一次。channelPricing 为 nil,保持优先级 3 的 -// 语义:只取模型定价文件,不引入渠道自定义定价。 -func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, reasoningEfforts ...string) *float64 { +// 每加一个定价特性都要手工镜像一次。解析器不配置渠道或分组,保持优先级 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.CalculateCostWithServiceTier( - model, tokens, 1, normalizeBillingServiceTier(serviceTier), - ) + 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 } - applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(model, reasoningEffort, nil)) return &breakdown.TotalCost } @@ -253,6 +262,7 @@ func applyAccountStatsCost( upstreamModel, requestedModel string, tokens UsageTokens, totalCost float64, + pricingAt time.Time, ) { model := upstreamModel if model == "" { @@ -271,6 +281,6 @@ func applyAccountStatsCost( reasoningEffort = *usageLog.ReasoningEffort } usageLog.AccountStatsCost = resolveAccountStatsCost( - ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, reasoningEffort, + 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 a346f4405f..7d4d8c7d03 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -472,7 +472,7 @@ 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) @@ -483,8 +483,8 @@ func TestTryModelFilePricing_Fable51MaxEffortUsesTripleQuota(t *testing.T) { "claude-fable-5-1": {InputPricePerToken: 0.001}, }) tokens := UsageTokens{InputTokens: 100} - standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", "xhigh") - max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", "max") + 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) @@ -503,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. @@ -542,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) }) @@ -572,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。 @@ -583,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) } @@ -593,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) } @@ -605,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) } @@ -622,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 @@ -644,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 // --------------------------------------------------------------------------- @@ -661,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) } @@ -677,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) } @@ -694,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) } @@ -725,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 @@ -747,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) @@ -765,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) } @@ -792,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 @@ -813,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) @@ -832,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) @@ -856,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) } @@ -873,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) } @@ -906,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) @@ -934,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 4e91fa40bc..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. @@ -512,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 } @@ -804,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 { @@ -935,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 { @@ -1352,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 @@ -1524,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 698e63df8c..1123a47619 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 } @@ -308,7 +355,30 @@ 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") } @@ -474,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, @@ -528,7 +604,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn RequirePrivacySet: input.RequirePrivacySet, DefaultMappedModel: input.DefaultMappedModel, MessagesDispatchModelConfig: normalizeOpenAIMessagesDispatchModelConfig(input.MessagesDispatchModelConfig), - ModelsListConfig: normalizeGroupModelsListConfig(input.ModelsListConfig), + ModelAllowlist: modelAllowlist, // 固定账号 manifest 配置:账号绑定发生在分组创建之后,创建路径禁止开启, // 成员关系无从校验(前端创建对话框也不展示)。 CodexModelsManifestConfig: normalizeCodexModelsManifestConfig(platform, input.CodexModelsManifestConfig), @@ -669,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 @@ -909,8 +994,12 @@ 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 @@ -1070,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) @@ -1078,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 } @@ -1116,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 } @@ -1123,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 } @@ -1130,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 } @@ -1142,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 } @@ -1156,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 } @@ -1175,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 ba149013ca..859d19d6f2 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -150,9 +150,9 @@ 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{}, @@ -166,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 @@ -191,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_service.go b/backend/internal/service/admin_service.go index 562b337fb4..47c45701fd 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,7 +293,7 @@ type CreateGroupInput struct { RequireOAuthOnly bool RequirePrivacySet bool MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig - ModelsListConfig GroupModelsListConfig + ModelAllowlist GroupModelAllowlist // CodexModelsManifestConfig 固定账号 manifest 配置;创建路径禁止开启,仅编辑可配置。 CodexModelsManifestConfig GroupCodexModelsManifestConfig // RPMLimit 分组 RPM 上限(0 = 不限制) @@ -353,7 +374,7 @@ type UpdateGroupInput struct { RequireOAuthOnly *bool RequirePrivacySet *bool MessagesDispatchModelConfig *OpenAIMessagesDispatchModelConfig - ModelsListConfig *GroupModelsListConfig + ModelAllowlist *GroupModelAllowlist // CodexModelsManifestConfig nil 表示不修改;非 openai 平台会被归一化为关闭。 CodexModelsManifestConfig *GroupCodexModelsManifestConfig // RPMLimit 分组 RPM 上限(0 = 不限制),nil 表示未提供不改动。 @@ -658,9 +679,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 @@ -702,6 +725,7 @@ type userGroupRateBatchReader interface { // NewAdminService creates a new AdminService func NewAdminService( + cfg *config.Config, userRepo UserRepository, groupRepo AdminGroupRepository, accountRepo AdminAccountRepository, @@ -726,9 +750,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_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 872e15144b..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") } 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/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 09055b8890..474c0d85f1 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -107,8 +107,8 @@ 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"` - // CodexModelsManifestConfig 与 ModelsListConfig 一样在认证快照分组里透传, + ModelAllowlist GroupModelAllowlist `json:"model_allowlist,omitempty"` + // CodexModelsManifestConfig 与 ModelAllowlist 一样在认证快照分组里透传, // Codex /models handler 直接读认证分组对象。 CodexModelsManifestConfig GroupCodexModelsManifestConfig `json:"codex_models_manifest_config,omitempty"` diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index f05b8909a2..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 = 23 // v23: group codex_models_manifest_config field +const apiKeyAuthSnapshotVersion = 24 // v24: group model_allowlist field (renamed from models_list_config, enforcing semantics) type apiKeyAuthCacheConfig struct { l1Size int @@ -422,7 +422,7 @@ 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, @@ -524,7 +524,7 @@ 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, 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/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 bc558420a6..f09f606eb7 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -586,7 +586,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 @@ -924,9 +937,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"] } diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 94ee14ca2a..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", diff --git a/backend/internal/service/channel_monitor_checker.go b/backend/internal/service/channel_monitor_checker.go index 71177db0bd..23388405a0 100644 --- a/backend/internal/service/channel_monitor_checker.go +++ b/backend/internal/service/channel_monitor_checker.go @@ -198,7 +198,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}, }) 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/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index 99ff3b267c..d9393e5a0c 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -325,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 @@ -348,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_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 fb745d69b3..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") @@ -522,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_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 9a3ca9fb88..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, diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index bd05f9e1ca..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) } @@ -250,10 +250,10 @@ func (s *GatewayService) SelectAccountWithLoadAwareness(ctx context.Context, gro return needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, account, requestedModel) } - // 获取模型路由配置(anthropic 目标平台;composite 分组按目标平台判断) + // 获取模型路由配置(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", @@ -865,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) @@ -876,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 } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 54ebcfde18..97e35fdb1b 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1452,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 675cf40001..4d8e74c162 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -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 e99c545694..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) != "" { diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 0cc8467523..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, ) } 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 36b3a34a63..bb6804a823 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -12,7 +12,6 @@ import ( ) type OpenAIMessagesDispatchModelConfig = domain.OpenAIMessagesDispatchModelConfig -type GroupModelsListConfig = domain.GroupModelsListConfig type GroupCodexModelsManifestConfig = domain.GroupCodexModelsManifestConfig type ReasoningEffortMapping = domain.ReasoningEffortMapping @@ -107,8 +106,8 @@ type Group struct { RequirePrivacySet bool // 调度时仅允许 privacy 已成功设置的账号(OpenAI/Antigravity/Anthropic/Gemini) DefaultMappedModel string MessagesDispatchModelConfig OpenAIMessagesDispatchModelConfig - ModelsListConfig GroupModelsListConfig - // CodexModelsManifestConfig 开启后,该分组的 Codex /models manifest 请求只用 + ModelAllowlist GroupModelAllowlist + // CodexModelsManifestConfig 开启后,普通模型列表与 Codex manifest 优先使用 // 固定账号列表拉取并合并,不经过调度器(仅 openai 平台)。 CodexModelsManifestConfig GroupCodexModelsManifestConfig @@ -142,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_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/identity_service.go b/backend/internal/service/identity_service.go index 2f9e9d0b3f..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 ( @@ -75,6 +79,38 @@ func isAcceptableFingerprintUserAgent(ua string) bool { 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.CLIVersion() + " (external, cli)", @@ -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/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 6b2a87b263..b1618acf78 100644 --- a/backend/internal/service/model_plaza_service.go +++ b/backend/internal/service/model_plaza_service.go @@ -269,6 +269,7 @@ func plazaPricingFromSchedule(raw *ChannelModelPricing, sched *ContextPricingSch 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) @@ -280,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/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/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_alpha_search.go b/backend/internal/service/openai_alpha_search.go index caff28208b..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 不能证明账号的模型调用 @@ -175,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 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 656919fd8c..445b3b7899 100644 --- a/backend/internal/service/openai_codex_model_metadata.go +++ b/backend/internal/service/openai_codex_model_metadata.go @@ -9,6 +9,7 @@ import ( 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 { @@ -18,7 +19,7 @@ func applyCodexToolCapabilities(dst, src map[string]json.RawMessage, overwrite b if len(value) == 0 { continue } - // These five Codex fields are nullable booleans or strings, never arbitrary objects. + // 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")) { @@ -89,10 +90,23 @@ func accountCodexToolCapabilities(account *Account, modelID string) map[string]j 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) { @@ -100,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 @@ -110,7 +127,7 @@ func groupCodexModelMetadata( compositeRoutesAvailable, ) if !resolved { - if codexExplicitModelTargetsConflict(accounts, modelID) { + if !routed && codexExplicitModelTargetsConflict(accounts, modelID) { return codexModelMetadataOverride{ reasoningConflict: true, inputModalitiesConflict: true, @@ -125,14 +142,21 @@ 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 @@ -141,6 +165,9 @@ func groupCodexModelMetadata( if account.Platform != platform { continue } + if routed && !containsInt64(routedAccountIDs, account.ID) { + continue + } var lookupModel string if explicitClaims { if !codexExplicitModelMappingClaims(*account, modelID) { @@ -396,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 55bc233ed2..cb757a1faa 100644 --- a/backend/internal/service/openai_codex_model_metadata_test.go +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -9,6 +9,58 @@ import ( "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{ @@ -30,7 +82,7 @@ func TestAstraCodexToolCapabilitiesUseAccountScopeAndSharedDeclarations(t *testi {"implemented chat bridge", []Account{bridge}, true}, } { t.Run(tt.name, func(t *testing.T) { - body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"public-astra"}, tt.accounts, nil, true) + 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"]) @@ -48,7 +100,7 @@ func TestAstraCodexToolCapabilitiesUseAccountScopeAndSharedDeclarations(t *testi "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, true) + 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"]) @@ -200,7 +252,7 @@ func TestBuildCodexModelsManifestForGroupAdvertisesSearchOnlyForChatBridgeRoutes t.Run(tc.name, func(t *testing.T) { t.Parallel() body, err := buildCodexModelsManifestForAccounts( - PlatformOpenAI, []string{"company-coding-model"}, tc.accounts, nil, true, + PlatformOpenAI, []string{"company-coding-model"}, tc.accounts, nil, nil, true, ) require.NoError(t, err) models := decodeCodexManifestModels(t, body) @@ -585,7 +637,7 @@ func TestAstraCodexToolCapabilitiesKeepAPIKeyResponsesLiteGuard(t *testing.T) { 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, 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) @@ -593,4 +645,100 @@ func TestAstraCodexToolCapabilitiesKeepAPIKeyResponsesLiteGuard(t *testing.T) { 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 index 4f45f3a1e8..b74b3eafdc 100644 --- a/backend/internal/service/openai_codex_models_pinned.go +++ b/backend/internal/service/openai_codex_models_pinned.go @@ -114,18 +114,53 @@ func mergeCodexModelsManifestBodies(bodies [][]byte) ([]byte, error) { // - 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) (*CodexModelsManifest, *Account, error) { +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, nil, ErrNoPinnedCodexModelsAccounts + return nil, ErrNoPinnedCodexModelsAccounts } cfg := group.CodexModelsManifestConfig if group.Platform != PlatformOpenAI || !cfg.Enabled || len(cfg.AccountIDs) == 0 { - return nil, nil, ErrNoPinnedCodexModelsAccounts + return nil, ErrNoPinnedCodexModelsAccounts } members, err := s.accountRepo.ListByGroup(ctx, group.ID) if err != nil { - return nil, nil, fmt.Errorf("load pinned codex models manifest accounts: %w", err) + return nil, fmt.Errorf("load pinned codex models manifest accounts: %w", err) } memberByID := make(map[int64]Account, len(members)) for _, member := range members { @@ -145,70 +180,46 @@ func (s *OpenAIGatewayService) FetchPinnedCodexModelsManifest(ctx context.Contex usable = append(usable, member) } if len(usable) == 0 { - return nil, nil, ErrNoPinnedCodexModelsAccounts + return nil, ErrNoPinnedCodexModelsAccounts } - bodies := make([][]byte, len(usable)) + results := make([]pinnedOpenAIModelsResult, len(usable)) fetchErrs := make([]error, len(usable)) - // 各自独立完成、不取消兄弟请求,错误按下标收集,普通 WaitGroup 足够。 var fetchGroup sync.WaitGroup for i := range usable { - account := usable[i] - index := i fetchGroup.Add(1) go func() { defer fetchGroup.Done() - manifest, fetchErr := s.FetchCodexModelsManifest(ctx, &account, clientVersion, "") - if fetchErr != nil { - fetchErrs[index] = fetchErr - return - } - if completeErr := s.CompleteAPIKeyCodexModelsManifestForClient(manifest, &account); completeErr != nil { - fetchErrs[index] = completeErr - return - } - bodies[index] = manifest.Body + response, err := fetch(ctx, &usable[i]) + results[i] = pinnedOpenAIModelsResult{account: &usable[i], response: response} + fetchErrs[i] = err }() } fetchGroup.Wait() - - successBodies := make([][]byte, 0, len(usable)) - successAccounts := make([]*Account, 0, len(usable)) - failedIDs := make([]int64, 0) - for i := range usable { - if fetchErrs[i] != nil || bodies[i] == nil { - failedIDs = append(failedIDs, usable[i].ID) - continue - } - successBodies = append(successBodies, bodies[i]) - successAccounts = append(successAccounts, &usable[i]) + if err := ctx.Err(); err != nil { + return nil, err } - if len(successBodies) == 0 { - var lastErr error - for i := range fetchErrs { + 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_CODEX_MODELS_UPSTREAM_FAILED", "pinned codex models manifest accounts all failed") + lastErr = infraerrors.New(http.StatusBadGateway, "OPENAI_MODELS_UPSTREAM_FAILED", "pinned model discovery accounts all failed") } - return nil, nil, lastErr + return nil, lastErr } if len(failedIDs) > 0 { - slog.Warn("codex_models_manifest_pinned_partial_failure", - "group_id", group.ID, - "failed_account_ids", failedIDs, - ) + slog.Warn("openai_models_pinned_partial_failure", "group_id", group.ID, "failed_account_ids", failedIDs) } - - merged, err := mergeCodexModelsManifestBodies(successBodies) - if err != nil { - return nil, nil, fmt.Errorf("merge pinned codex models manifests: %w", err) - } - firstAccount := *successAccounts[0] - return &CodexModelsManifest{ - Body: merged, - ETag: codexModelsManifestBodyETag(merged), - }, &firstAccount, nil + return successes, nil } diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 9e537a9c78..c5d3f8b331 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -30,32 +30,34 @@ import ( var chatgptCodexModelsURL = "https://chatgpt.com/backend-api/codex/models" const ( - codexModelsManifestCacheBodyLimit = 1 << 20 - // codexModelsManifestCacheMaxEntries 上限按「账号数 × 客户端版本数 × 代理形态」 + openAIModelsCacheBodyLimit = 1 << 20 + // openAIModelsCacheMaxEntries 上限按「账号数 × 客户端版本数 × 代理形态」 // 估算:缓存同时覆盖 OAuth 与 API Key 账号,且缓存键含 Authorization 与 // Version 头,不同客户端版本各自占一条;64 条在大规模部署下会被淘汰导致 // 额外上游请求。单条清单通常几十 KB,512 条最坏内存占用在几十 MB 量级。 - codexModelsManifestCacheMaxEntries = 512 + openAIModelsCacheMaxEntries = 512 // 三段时效:≤TTL 为新鲜(直接返回缓存,零上游请求);TTL 到 StaleTTL 之间 // 乐观返回旧值并后台单飞刷新(携带上游 ETag,304 续期);超过 StaleTTL 丢弃 // 缓存同步等待刷新。TTL 取 1 分钟:manifest 变化低频,1 分钟内同账号重复 // 请求完全吸收;StaleTTL 取 5 分钟,控制旧内容最长可见时间在分钟级。 - codexModelsManifestCacheTTL = 60 * time.Second - codexModelsManifestCacheStaleTTL = 5 * time.Minute + 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{}{} @@ -82,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 } @@ -100,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 @@ -110,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 } @@ -136,6 +142,7 @@ func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( PlatformOpenAI, configuredModels, catalog, + group, nil, true, ) @@ -145,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), } @@ -169,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 { @@ -179,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) @@ -277,16 +295,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 @@ -361,6 +379,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"` @@ -492,6 +511,11 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr 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 } @@ -602,7 +626,7 @@ func configuredCodexGPTReasoningLevels(modelID string) []configuredCodexReasonin 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", @@ -806,6 +830,7 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup( effectivePlatform, modelIDs, catalog, + group, compositeRoutes, compositeRoutesAvailable, ) @@ -815,6 +840,7 @@ func buildCodexModelsManifestForAccounts( effectivePlatform string, modelIDs []string, accounts []Account, + group *Group, compositeRoutes []CompositeModelRoute, compositeRoutesAvailable bool, ) ([]byte, error) { @@ -852,6 +878,7 @@ func buildCodexModelsManifestForAccounts( effectivePlatform, modelID, accounts, + group, compositeRoutes, compositeRoutesAvailable, ); ok { @@ -888,13 +915,15 @@ 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 @@ -1186,6 +1215,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") } } @@ -1212,6 +1248,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", @@ -1263,6 +1309,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 @@ -1283,11 +1331,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] @@ -1311,10 +1357,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 { @@ -1477,7 +1521,7 @@ func isRetryableCodexModelsManifestTransportError(err error) bool { return false } -type codexModelsManifestRequest struct { +type openAIModelsRequest struct { url string headers http.Header proxyURL string @@ -1486,61 +1530,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 { @@ -1553,16 +1599,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), } } @@ -1572,7 +1618,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") } @@ -1660,7 +1706,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc proxyURL = account.Proxy.URL() } - request := codexModelsManifestRequest{ + request := openAIModelsRequest{ url: requestURL.String(), headers: headers, proxyURL: proxyURL, @@ -1671,11 +1717,11 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc useAPIKeyUpstream: useAPIKeyUpstream, } if useAPIKeyUpstream { - return s.fetchCachedCodexModelsManifest(ctx, request, s.fetchCodexModelsManifestUpstreamForRequest(request), ifNoneMatch) + return s.fetchCachedOpenAIModels(ctx, request, s.fetchCodexModelsManifestUpstreamForRequest(request), ifNoneMatch) } // OAuth 账号同样经过账号级缓存;闭包保留 agent identity 任务恢复逻辑, // 错误时仍交给 handleCodexModelsManifestAccountAuthError 处理账号状态。 - oauthFetch := func(fetchCtx context.Context, ifNoneMatch string) (*CodexModelsManifest, error) { + 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) @@ -1699,7 +1745,7 @@ func (s *OpenAIGatewayService) FetchCodexModelsManifest(ctx context.Context, acc setOpenAIChatGPTAccountHeaders(request.headers, credAccount) return s.fetchCodexModelsManifestUpstream(fetchCtx, request, ifNoneMatch) } - return s.fetchCachedCodexModelsManifest(ctx, request, oauthFetch, ifNoneMatch) + return s.fetchCachedOpenAIModels(ctx, request, oauthFetch, ifNoneMatch) } func isAgentIdentityTaskInvalidCodexModelsError(err error) bool { @@ -1739,18 +1785,18 @@ func (s *OpenAIGatewayService) handleCodexModelsManifestAccountAuthError(ctx con s.handleOpenAIAccountUpstreamError(ctx, account, upstreamErr.statusCode, headers, upstreamErr.body) } -func (s *OpenAIGatewayService) fetchCachedCodexModelsManifest(ctx context.Context, request codexModelsManifestRequest, fetch func(ctx context.Context, ifNoneMatch string) (*CodexModelsManifest, error), 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.refreshCachedCodexModelsManifest(cacheKey, request, fetch) - 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(): @@ -1759,19 +1805,19 @@ func (s *OpenAIGatewayService) fetchCachedCodexModelsManifest(ctx context.Contex 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) refreshCachedCodexModelsManifest(cacheKey string, request codexModelsManifestRequest, fetch func(ctx context.Context, ifNoneMatch string) (*CodexModelsManifest, error)) <-chan singleflight.Result { - return s.codexModelsManifestCache.refresh.DoChan(cacheKey, func() (any, error) { +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.codexModelsManifestCache.get(cacheKey, time.Now()) + cached, _ := s.openAIModelsCache.get(cacheKey, time.Now()) ifNoneMatch := "" if cached != nil { ifNoneMatch = cached.upstreamETag @@ -1781,23 +1827,23 @@ func (s *OpenAIGatewayService) refreshCachedCodexModelsManifest(cacheKey string, 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) fetchCodexModelsManifestUpstreamForRequest(request codexModelsManifestRequest) func(ctx context.Context, ifNoneMatch string) (*CodexModelsManifest, error) { - return func(ctx context.Context, 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) fetchCodexModelsManifestUpstream(ctx context.Context, request codexModelsManifestRequest, ifNoneMatch string) (*CodexModelsManifest, error) { +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) @@ -1842,7 +1888,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)) @@ -1874,6 +1920,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 { @@ -1922,8 +1979,8 @@ 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, @@ -2094,7 +2151,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 } @@ -2422,9 +2479,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) @@ -2439,7 +2496,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 } @@ -2453,14 +2510,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 f6e51d3453..a58634b749 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -351,10 +351,13 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t 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"}, effortsFromConfiguredCodexLevels(gpt6Astra.SupportedReasoningLevels)) - require.NotContains(t, gpt6Astra.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "ultra"}) + 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{{ @@ -370,7 +373,10 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t require.Equal(t, int64(1_050_000), gpt6Astra.MaxContextWindow) gpt6 := newConfiguredCodexModelDescriptor("gpt-6") require.Equal(t, "GPT-6 (Astra)", gpt6.DisplayName) - require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt6.SupportedReasoningLevels)) + 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") @@ -409,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 { @@ -1081,7 +1106,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}}`), } @@ -1105,7 +1130,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() @@ -1150,8 +1175,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"]) @@ -1190,7 +1217,7 @@ func TestBuildGroupConfiguredCodexModelsManifestExpandsSelectedModelCoveredByWil group := &Group{ ID: groupID, Platform: PlatformOpenAI, - ModelsListConfig: GroupModelsListConfig{ + ModelAllowlist: GroupModelAllowlist{ Enabled: true, Models: []string{"gpt-5.6"}, }, @@ -1322,7 +1349,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"}]}`), } @@ -1357,7 +1384,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"}]}`), } @@ -1376,13 +1403,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}, }, @@ -1414,13 +1441,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) @@ -1428,7 +1455,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) @@ -2011,7 +2038,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") @@ -2047,7 +2074,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"]} @@ -2122,7 +2149,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, @@ -2201,7 +2228,7 @@ func TestCompleteAPIKeyCodexModelsManifestForClientMarksOnlyOfficialVisionGPTIma t.Parallel() svc := &OpenAIGatewayService{} - manifest := &CodexModelsManifest{Body: []byte(`{"models":[{"slug":"gpt-6-astra"},{"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)) @@ -2229,7 +2256,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)) @@ -2547,13 +2574,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} }() @@ -2681,7 +2708,7 @@ func TestFetchCodexModelsManifestAPIKeyCacheSurvivesClientMutation(t *testing.T) &Group{ ID: 81, Platform: PlatformOpenAI, - ModelsListConfig: GroupModelsListConfig{ + ModelAllowlist: GroupModelAllowlist{ Enabled: true, Models: []string{"model-a"}, }, @@ -2708,7 +2735,7 @@ func TestFetchCodexModelsManifestAPIKeyCacheSurvivesClientMutation(t *testing.T) &Group{ ID: 82, Platform: PlatformOpenAI, - ModelsListConfig: GroupModelsListConfig{ + ModelAllowlist: GroupModelAllowlist{ Enabled: true, Models: []string{"model-b"}, }, @@ -2863,21 +2890,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} }() @@ -2888,7 +2915,7 @@ func TestFetchCodexModelsManifestAPIKeyServesStaleWhileRefreshing(t *testing.T) } var staleResult struct { - manifest *CodexModelsManifest + manifest *OpenAIModelsResponse err error } select { @@ -2956,12 +2983,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 { @@ -2978,12 +3005,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 } @@ -3348,13 +3375,13 @@ func newCodexModelsOAuthCacheServer(t *testing.T, body string) (*httptest.Server } func expireCodexModelsManifestCache(s *OpenAIGatewayService, age time.Duration) { - 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(-age) - entry.staleUntil = time.Now().Add(codexModelsManifestCacheStaleTTL - age) - s.codexModelsManifestCache.entries[key] = entry + entry.staleUntil = time.Now().Add(openAIModelsCacheStaleTTL - age) + s.openAIModelsCache.entries[key] = entry } - s.codexModelsManifestCache.mu.Unlock() + s.openAIModelsCache.mu.Unlock() } func TestFetchCodexModelsManifestOAuthFreshWindowZeroUpstreamRequests(t *testing.T) { @@ -3397,7 +3424,7 @@ func TestFetchCodexModelsManifestOAuthStaleServesOldValueAndRefreshesInBackgroun expireCodexModelsManifestCache(s, 2*time.Minute) - resultCh := make(chan *CodexModelsManifest, 1) + resultCh := make(chan *OpenAIModelsResponse, 1) errCh := make(chan error, 1) go func() { manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "") @@ -3427,9 +3454,9 @@ func TestFetchCodexModelsManifestOAuthStaleServesOldValueAndRefreshesInBackgroun if fetchErr == nil && manifest != nil && !manifest.NotModified { require.Contains(t, string(manifest.Body), `"new"`) } - s.codexModelsManifestCache.mu.Lock() - fresh := len(s.codexModelsManifestCache.entries) > 0 - s.codexModelsManifestCache.mu.Unlock() + s.openAIModelsCache.mu.Lock() + fresh := len(s.openAIModelsCache.entries) > 0 + s.openAIModelsCache.mu.Unlock() if fresh { break } @@ -3548,8 +3575,8 @@ func TestFetchCodexModelsManifestOAuthSharedAcrossGroupsWithIndependentFiltering }, } account := newCodexModelsTestAccount() - groupA := &Group{ID: 91, Platform: PlatformOpenAI, ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"model-a"}}} - groupB := &Group{ID: 92, Platform: PlatformOpenAI, ModelsListConfig: GroupModelsListConfig{Enabled: true, Models: []string{"model-b"}}} + 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 { 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 ef722a93c1..a67d626a23 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -357,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"])) @@ -1392,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 899ba7d933..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", @@ -1497,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_embeddings.go b/backend/internal/service/openai_embeddings.go index 0d1fcbd1e1..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 diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 6458509389..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 diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 67ffc3d265..98394c07e7 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -159,6 +159,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( } } 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), diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index e92f8b680e..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") @@ -1136,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 @@ -1211,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), diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index e755548a7d..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 } diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index d5daef8442..6624c30f5a 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -158,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 @@ -177,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") diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 270762a0dc..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 } 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 7a949209b9..dfebb47a50 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -2079,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 b3f4153784..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{} diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 9defec9e65..af4825762b 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -1023,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 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_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index 9939c5b469..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) diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 01ea7b5f48..df7e66eb87 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -483,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 79a7999799..4570ed1631 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -472,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, ) } diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index 371b2986c2..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), diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 1ad5b5aaee..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), 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_model_alias.go b/backend/internal/service/openai_model_alias.go index 147ed20941..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 { diff --git a/backend/internal/service/openai_model_alias_test.go b/backend/internal/service/openai_model_alias_test.go index 9ac5636892..3cc4557c52 100644 --- a/backend/internal/service/openai_model_alias_test.go +++ b/backend/internal/service/openai_model_alias_test.go @@ -7,7 +7,7 @@ import ( ) func TestNormalizeKnownOpenAICodexModelGPT6Astra(t *testing.T) { - for _, model := range []string{"gpt-6-astra", "openai/gpt-6-astra", "gpt-6", "openai/gpt-6"} { + 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)) } } 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_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 0d1ad0dc5c..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,6 +20,9 @@ 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"} @@ -31,6 +35,17 @@ func normalizeReasoningEffortMappingSource(raw string) string { 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{} @@ -65,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 { @@ -298,7 +344,7 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp seen := make(map[string]struct{}, len(raw)) for i, mapping := range raw { from := normalizeReasoningEffortMappingSource(mapping.From) - to := NormalizeMaxReasoningEffort(mapping.To) + to := normalizeReasoningEffortMappingTarget(mapping.To) if from == "" || to == "" { return nil, fmt.Errorf("reasoning effort mapping %d contains an empty or unknown value", i+1) } @@ -315,8 +361,18 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp 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 { @@ -439,9 +495,10 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) { // 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. It understands both OpenAI and Anthropic request -// field shapes. +// 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) { @@ -463,7 +520,14 @@ func ApplyReasoningEffortPolicy(body []byte, maxEffort string, mappings []Reason 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 { diff --git a/backend/internal/service/openai_reasoning_effort_policy_test.go b/backend/internal/service/openai_reasoning_effort_policy_test.go index f3697bbe86..62cc394c3d 100644 --- a/backend/internal/service/openai_reasoning_effort_policy_test.go +++ b/backend/internal/service/openai_reasoning_effort_policy_test.go @@ -167,6 +167,27 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) { 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) @@ -255,15 +276,16 @@ 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}, @@ -419,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) { @@ -427,6 +484,20 @@ 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 @@ -442,3 +513,14 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { }) } } + +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_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_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index aedd078ec6..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, @@ -725,10 +731,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( 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 { diff --git a/backend/internal/service/openai_ws_http_bridge.go b/backend/internal/service/openai_ws_http_bridge.go index 03339deffc..52420c584a 100644 --- a/backend/internal/service/openai_ws_http_bridge.go +++ b/backend/internal/service/openai_ws_http_bridge.go @@ -590,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) 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_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_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_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 b7c2940965..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) { 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/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/pricing_service.go b/backend/internal/service/pricing_service.go index ba39465f52..75b2fc6a9c 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -1207,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) { @@ -1269,15 +1269,16 @@ func normalizeModelNameForPricing(model string) string { return normalizeGeminiThinkingTierAlias(model) } -// normalizeGeminiThinkingTierAlias maps Antigravity's Gemini 3.6 Flash +// 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 gemini-3.6-flash. +// -tiered requests on the corresponding base model's price card. func normalizeGeminiThinkingTierAlias(model string) string { - const baseModel = "gemini-3.6-flash" - for _, tier := range []string{"-high", "-low", "-medium", "-tiered"} { - if model == baseModel+tier { - return baseModel + for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} { + for _, tier := range []string{"-high", "-low", "-medium", "-tiered"} { + if model == baseModel+tier { + return baseModel + } } } return model diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index d65271dd16..3915666c38 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -566,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/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 3bd467f464..47f234f85d 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 } } @@ -1486,7 +1490,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/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/setting_parse.go b/backend/internal/service/setting_parse.go index 36e7706dda..ade4de0d41 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -192,7 +192,8 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyChannelMonitorHideThroughput: "true", SettingKeyChannelMonitorShowQuota: "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, 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_models.go b/backend/internal/service/upstream_models.go index 9d45d2f617..4b106ddfe2 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -940,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) @@ -991,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, @@ -1007,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) } 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/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/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/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/channels.ts b/frontend/src/api/channels.ts index c998fc3efb..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 } 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 @@